Introduction
Authentication is one of those things that looks straightforward until you're three hours deep and wondering why your session cookies aren't persisting across deployments. With Next.js 15's App Router now fully mature and Auth.js v5 (formerly NextAuth.js) released as stable, there's finally a clean, modern pattern for handling auth that works seamlessly with React Server Components, edge middleware, and serverless deployments on platforms like Vercel, Fly.io, or AWS Lambda.
This tutorial walks you through implementing a complete authentication flow — including sign-up, sign-in, protected routes, and session management — from scratch. Whether you're building a SaaS app, a client portal, or an internal tool, this pattern scales with you.
What You'll Need
- Node.js 20+ installed locally
- A Next.js 15 project (App Router) — new or existing
- A database (we'll use PostgreSQL via Neon or Supabase's free tier)
- An OAuth provider app — we'll use GitHub OAuth as an example
- Basic familiarity with TypeScript and React Server Components
All tools used here are free to get started. The full setup takes roughly 45–60 minutes.
Step 1: Install and Configure Auth.js v5
Auth.js v5 is the current standard for Next.js authentication. It drops the legacy API route pattern in favour of a single unified handler that works with the App Router.
Install the package:
npm install next-auth@betaNext, create your core Auth.js config file at the root of your project:
// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
session: { strategy: 'database' },
});This single file exports everything you'll need across your app — the route handlers, the auth() session getter, and the signIn/signOut server actions.
1a. Add the Route Handler
Create the catch-all API route that Auth.js expects:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;1b. Set Your Environment Variables
Add these to your .env.local file:
AUTH_SECRET=your_random_secret_here # generate with: npx auth secret
AUTH_GITHUB_ID=your_github_client_id
AUTH_GITHUB_SECRET=your_github_client_secret
DATABASE_URL=your_postgres_connection_stringPro tip: Run npx auth secret in your terminal to generate a cryptographically secure AUTH_SECRET. Never commit this to version control.
Step 2: Set Up Your Database Schema
Auth.js v5 with a database adapter requires four tables: users, accounts, sessions, and verification_tokens. We'll use Drizzle ORM, which pairs cleanly with Next.js 15 and works on edge runtimes.
npm install drizzle-orm @auth/drizzle-adapter pg
npm install -D drizzle-kit @types/pgCreate your schema file:
// lib/db/schema.ts
import {
pgTable, text, timestamp, primaryKey, integer
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email').notNull().unique(),
emailVerified: timestamp('email_verified', { mode: 'date' }),
image: text('image'),
});
export const accounts = pgTable('accounts', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
provider: text('provider').notNull(),
providerAccountId: text('provider_account_id').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
}, (account) => ({
compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] }),
}));
export const sessions = pgTable('sessions', {
sessionToken: text('session_token').notNull().primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
});
export const verificationTokens = pgTable('verification_tokens', {
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull(),
}, (vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
}));Run your migration:
npx drizzle-kit push:pgCommon pitfall: If you're using Neon's serverless driver, swap pg for @neondatabase/serverless and use drizzle-orm/neon-http as the adapter. Neon's HTTP driver is edge-compatible, which matters if you're running middleware on Vercel's edge network.
Step 3: Protect Routes With Middleware
Next.js 15 middleware runs on every request before it hits your pages or API routes, making it the right place to enforce authentication at the edge.
Create a middleware.ts file at the root of your project:
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith('/login');
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};This pattern redirects unauthenticated users away from /dashboard and bounces already-logged-in users away from /login — two of the most common UX annoyances in auth flows.
Step 4: Build the Sign-In Page
Create a simple sign-in page using Auth.js server actions — no client-side fetch calls needed:
// app/login/page.tsx
import { signIn } from '@/auth';
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm p-8 rounded-2xl border border-zinc-200 shadow-sm">
<h1 className="text-2xl font-semibold mb-6">Sign in</h1>
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}
>
<button
type="submit"
className="w-full py-2 px-4 bg-zinc-900 text-white rounded-lg hover:bg-zinc-700 transition"
>
Continue with GitHub
</button>
</form>
</div>
</main>
);
}Pro tip: If you're building a B2B SaaS product aimed at Australian or Singaporean businesses, adding Google Workspace OAuth alongside GitHub (or instead of it) dramatically increases sign-up conversion. Add it in your auth.ts providers array with minimal extra code.
Step 5: Access Session Data in Server Components
One of the best features of Auth.js v5 is how cleanly it integrates with React Server Components. You can read the session directly without any context providers or client-side hooks:
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect('/login');
return (
<main className="p-8">
<h1 className="text-2xl font-bold">
Welcome back, {session.user?.name}
</h1>
<p className="text-zinc-500 mt-2">{session.user?.email}</p>
</main>
);
}This runs entirely on the server — no unnecessary client-side JavaScript, no loading flicker, and no session token exposure to the browser beyond the secure HTTP-only cookie Auth.js sets automatically.
Step 6: Add a Sign-Out Button
Signing out is just as simple — use a server action directly in your component:
// components/SignOutButton.tsx
import { signOut } from '@/auth';
export function SignOutButton() {
return (
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/login' });
}}
>
<button type="submit" className="text-sm text-red-500 hover:underline">
Sign out
</button>
</form>
);
}Common Pitfalls to Avoid
- Skipping HTTPS in development: OAuth callbacks won't work without HTTPS on many providers. Use
ngrokor Vercel preview URLs for testing OAuth locally rather than rawlocalhostwith some providers. - Not setting
AUTH_TRUST_HOSTin production: On non-Vercel hosts (like Fly.io or Railway), addAUTH_TRUST_HOST=trueto your environment variables or Auth.js will reject requests from your custom domain. - Using JWT sessions with a database adapter: If you configure a database adapter, always use
session: { strategy: 'database' }. Mixing JWT strategy with a DB adapter causes unpredictable session behaviour. - Forgetting to handle session expiry on the client: If you use
useSession()fromnext-auth/reactin any client components, add anonUnauthenticatedhandler so expired sessions redirect cleanly rather than silently failing.
Next Steps
You now have a working, production-grade authentication flow in Next.js 15 — with protected routes, database-backed sessions, OAuth sign-in, and clean server component integration. From here, consider these natural extensions:
- Add email/password credentials: Auth.js v5 supports a
Credentialsprovider; pair it withbcryptjsfor hashed password storage. - Role-based access control (RBAC): Extend your
userstable with arolecolumn and check it inside your middleware for admin vs. standard user routing. - Multi-tenancy: Add an
organisationstable and link users via a junction table — this is the foundation for any SaaS product serving teams rather than individuals. - Audit logging: Log sign-in events and provider metadata to a separate table for compliance — particularly relevant if you're building for clients in regulated industries in Canada or Australia.
If you're building a web app that needs more than just auth — custom onboarding flows, a polished dashboard, or a full-stack architecture that scales — the team at Lenka Studio works with SMBs across Australia, Singapore, Canada, and the US to design and build exactly that. We specialise in turning technical foundations like this into products users actually enjoy. Reach out and tell us what you're building — we're happy to help you scope it out.




