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
- Auth.js v5 simplifies auth - Edge-compatible, works with App Router
- Multi-tenant from the start - Organizations enable B2B SaaS
- RBAC scales - Role-based permissions grow with your app
- Server Actions are powerful - Type-safe mutations with revalidation
- 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.
:::