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@beta

Next, 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_string

Pro 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/pg

Create 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:pg

Common 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 ngrok or Vercel preview URLs for testing OAuth locally rather than raw localhost with some providers.
  • Not setting AUTH_TRUST_HOST in production: On non-Vercel hosts (like Fly.io or Railway), add AUTH_TRUST_HOST=true to 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() from next-auth/react in any client components, add an onUnauthenticated handler 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 Credentials provider; pair it with bcryptjs for hashed password storage.
  • Role-based access control (RBAC): Extend your users table with a role column and check it inside your middleware for admin vs. standard user routing.
  • Multi-tenancy: Add an organisations table 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.