Build a SaaS Starter Kit

Authentication & User Management

5 min read

What You'll Build

A complete authentication system with:

  • Google and GitHub OAuth
  • Email magic link authentication
  • Session management with JWT
  • Protected routes and middleware
  • User profile management
  • Organization-based access control

Time to complete: ~45 minutes with AI assistance


Step 1: Install NextAuth.js v5

Ask AI to set up Auth.js (NextAuth.js v5):

Set up Auth.js (NextAuth.js v5) for Next.js 15 with:
- Google OAuth provider
- GitHub OAuth provider
- Email magic link with Resend
- Drizzle adapter for PostgreSQL
- JWT session strategy
- Custom sign-in and sign-up pages

Include all necessary files:
- src/lib/auth/config.ts
- src/lib/auth/index.ts
- src/app/api/auth/[...nextauth]/route.ts
- Environment variables needed

AI generates the auth configuration:

// src/lib/auth/config.ts
import { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Resend from 'next-auth/providers/resend';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
import { env } from '@/lib/env';

export const authConfig: NextAuthConfig = {
  adapter: DrizzleAdapter(db),
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/login',
    signUp: '/signup',
    error: '/auth-error',
  },
  providers: [
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    GitHub({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    }),
    Resend({
      apiKey: env.RESEND_API_KEY,
      from: 'noreply@yourdomain.com',
    }),
  ],
  callbacks: {
    async jwt({ token, user, account }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
};

Step 2: Create Auth UI Components

Ask AI to build authentication UI:

Create authentication UI components:

1. src/components/auth/social-buttons.tsx
   - Google sign-in button with icon
   - GitHub sign-in button with icon
   - Loading states
   - Error handling

2. src/components/auth/email-form.tsx
   - Email input with validation
   - Submit button with loading state
   - Success message after sending magic link

3. src/components/auth/auth-card.tsx
   - Wrapper card for auth pages
   - Logo, title, description
   - Divider between social and email

Use shadcn/ui components.
Add proper TypeScript types.
Include accessibility attributes.

Example generated component:

// src/components/auth/social-buttons.tsx
'use client';

import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Icons } from '@/components/ui/icons';

interface SocialButtonsProps {
  callbackUrl?: string;
}

export function SocialButtons({ callbackUrl = '/dashboard' }: SocialButtonsProps) {
  const [isGoogleLoading, setIsGoogleLoading] = useState(false);
  const [isGitHubLoading, setIsGitHubLoading] = useState(false);

  const handleGoogleSignIn = async () => {
    setIsGoogleLoading(true);
    await signIn('google', { callbackUrl });
  };

  const handleGitHubSignIn = async () => {
    setIsGitHubLoading(true);
    await signIn('github', { callbackUrl });
  };

  return (
    <div className="grid gap-3">
      <Button
        variant="outline"
        onClick={handleGoogleSignIn}
        disabled={isGoogleLoading || isGitHubLoading}
        className="w-full"
      >
        {isGoogleLoading ? (
          <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <Icons.google className="mr-2 h-4 w-4" />
        )}
        Continue with Google
      </Button>
      <Button
        variant="outline"
        onClick={handleGitHubSignIn}
        disabled={isGoogleLoading || isGitHubLoading}
        className="w-full"
      >
        {isGitHubLoading ? (
          <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <Icons.gitHub className="mr-2 h-4 w-4" />
        )}
        Continue with GitHub
      </Button>
    </div>
  );
}

Step 3: Build Login & Signup Pages

Create authentication pages:

1. src/app/(auth)/login/page.tsx
   - AuthCard with "Welcome back" title
   - SocialButtons component
   - Divider "or continue with email"
   - EmailForm component
   - Link to signup page

2. src/app/(auth)/signup/page.tsx
   - AuthCard with "Create an account" title
   - Same components as login
   - Link to login page
   - Terms of service checkbox

3. src/app/(auth)/layout.tsx
   - Centered layout with gradient background
   - Logo at top
   - Mobile-responsive

Make them beautiful and professional.

Step 4: Protected Route Middleware

Ask AI to create route protection:

Create middleware for route protection:

1. src/middleware.ts
   - Protect /dashboard/* routes
   - Redirect unauthenticated users to /login
   - Redirect authenticated users from /login to /dashboard
   - Handle API route protection

2. src/lib/auth/get-session.ts
   - Server-side session helper
   - Type-safe session with user data

Include proper Next.js 15 middleware configuration.

AI generates:

// src/middleware.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';

const publicRoutes = ['/', '/pricing', '/about', '/login', '/signup'];
const authRoutes = ['/login', '/signup'];

export default auth((req) => {
  const { nextUrl, auth: session } = req;
  const isLoggedIn = !!session?.user;
  const isPublicRoute = publicRoutes.includes(nextUrl.pathname);
  const isAuthRoute = authRoutes.includes(nextUrl.pathname);
  const isDashboardRoute = nextUrl.pathname.startsWith('/dashboard');

  // Redirect logged-in users away from auth pages
  if (isLoggedIn && isAuthRoute) {
    return NextResponse.redirect(new URL('/dashboard', nextUrl));
  }

  // Protect dashboard routes
  if (!isLoggedIn && isDashboardRoute) {
    const loginUrl = new URL('/login', nextUrl);
    loginUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Step 5: Organization Management

Create multi-tenant organization support:

Create organization management system:

1. src/lib/organizations/actions.ts (Server Actions)
   - createOrganization(name: string)
   - inviteMember(orgId: string, email: string, role: 'admin' | 'member')
   - removeMember(orgId: string, userId: string)
   - updateMemberRole(orgId: string, userId: string, role: string)
   - getOrganizationMembers(orgId: string)

2. src/components/dashboard/organization-switcher.tsx
   - Dropdown to switch between user's organizations
   - "Create new organization" option
   - Show current org name and logo

3. src/hooks/use-organization.ts
   - Get current organization from URL or session
   - Provide org context to components

Include proper error handling and optimistic updates.

Example server action:

// src/lib/organizations/actions.ts
'use server';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { organizations, organizationMembers } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const createOrgSchema = z.object({
  name: z.string().min(2).max(50),
});

export async function createOrganization(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) {
    throw new Error('Unauthorized');
  }

  const validated = createOrgSchema.parse({
    name: formData.get('name'),
  });

  const slug = validated.name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');

  const [org] = await db.insert(organizations).values({
    name: validated.name,
    slug: `${slug}-${Date.now()}`,
    ownerId: session.user.id,
  }).returning();

  // Add creator as owner
  await db.insert(organizationMembers).values({
    userId: session.user.id,
    organizationId: org.id,
    role: 'owner',
  });

  revalidatePath('/dashboard');
  return { success: true, organization: org };
}

export async function getOrganizationMembers(orgId: string) {
  const session = await auth();
  if (!session?.user?.id) {
    throw new Error('Unauthorized');
  }

  // Verify user has access to this org
  const membership = await db.query.organizationMembers.findFirst({
    where: and(
      eq(organizationMembers.userId, session.user.id),
      eq(organizationMembers.organizationId, orgId)
    ),
  });

  if (!membership) {
    throw new Error('Access denied');
  }

  return db.query.organizationMembers.findMany({
    where: eq(organizationMembers.organizationId, orgId),
    with: { user: true },
  });
}

Step 6: User Profile Settings

Create user profile management:

1. src/app/(dashboard)/settings/profile/page.tsx
   - Avatar upload (with image optimization)
   - Name editing
   - Email display (read-only)
   - Connected accounts (OAuth providers)
   - Delete account option

2. src/lib/users/actions.ts
   - updateProfile(data: { name?: string; avatarUrl?: string })
   - uploadAvatar(file: File)
   - deleteAccount()

3. src/components/dashboard/avatar-upload.tsx
   - Drag and drop support
   - Preview before upload
   - Crop functionality (optional)
   - Loading state during upload

Use Uploadthing or Cloudinary for image uploads.

Step 7: Role-Based Access Control

Implement RBAC:

Create role-based access control system:

1. src/lib/permissions.ts
   - Define permission types (read, write, delete, admin)
   - Map roles to permissions
   - hasPermission(role, permission) helper

2. src/components/auth/permission-gate.tsx
   - Component to conditionally render based on permission
   - Props: permission, fallback (optional)

3. Update organization member actions to check permissions

Example usage:
<PermissionGate permission="org:write">
  <InviteMemberButton />
</PermissionGate>

AI generates:

// src/lib/permissions.ts
export type Permission =
  | 'org:read'
  | 'org:write'
  | 'org:delete'
  | 'org:invite'
  | 'org:manage_members'
  | 'billing:read'
  | 'billing:write';

export type Role = 'owner' | 'admin' | 'member';

const rolePermissions: Record<Role, Permission[]> = {
  owner: [
    'org:read', 'org:write', 'org:delete', 'org:invite', 'org:manage_members',
    'billing:read', 'billing:write',
  ],
  admin: [
    'org:read', 'org:write', 'org:invite', 'org:manage_members',
    'billing:read',
  ],
  member: [
    'org:read',
  ],
};

export function hasPermission(role: Role, permission: Permission): boolean {
  return rolePermissions[role]?.includes(permission) ?? false;
}

export function getPermissions(role: Role): Permission[] {
  return rolePermissions[role] ?? [];
}
// src/components/auth/permission-gate.tsx
'use client';

import { useOrganization } from '@/hooks/use-organization';
import { hasPermission, Permission } from '@/lib/permissions';

interface PermissionGateProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function PermissionGate({
  permission,
  children,
  fallback = null,
}: PermissionGateProps) {
  const { membership } = useOrganization();

  if (!membership || !hasPermission(membership.role, permission)) {
    return fallback;
  }

  return children;
}

Step 8: Test Authentication Flow

Create a test checklist:

Test the complete authentication flow:

1. OAuth Sign-in
   - [ ] Google sign-in creates new user
   - [ ] GitHub sign-in creates new user
   - [ ] Existing user signs in correctly
   - [ ] Session persists across refreshes

2. Email Magic Link
   - [ ] Email is sent successfully
   - [ ] Magic link logs user in
   - [ ] Invalid/expired links show error

3. Protected Routes
   - [ ] /dashboard redirects to /login when not authenticated
   - [ ] /login redirects to /dashboard when authenticated
   - [ ] API routes return 401 for unauthenticated requests

4. Organizations
   - [ ] New users can create organizations
   - [ ] Organization switcher works
   - [ ] Members can be invited and removed
   - [ ] Role permissions are enforced

Create test accounts and verify each flow.

Security Checklist

Before shipping, verify:

  • CSRF protection enabled (NextAuth default)
  • Secure session cookies in production
  • Rate limiting on auth endpoints
  • Password complexity requirements (if using credentials)
  • Email verification before access
  • Audit logging for sensitive actions
  • Environment variables not exposed client-side

Key Takeaways

  1. Auth.js v5 simplifies auth - Edge-compatible, works with App Router
  2. Multi-tenant from the start - Organizations enable B2B SaaS
  3. RBAC scales - Role-based permissions grow with your app
  4. Server Actions are powerful - Type-safe mutations with revalidation
  5. Test auth thoroughly - Security bugs are costly

What's Next

In the next lesson, you'll integrate Stripe for billing, including subscription management, checkout flows, and webhooks.

:::

Quiz

Module 1: Build a SaaS Starter Kit

Take Quiz