How to Add Authentication to Your Next.js App Router Project (2026)
Authentication is one of those things that sounds simple until you're actually doing it. Then you're four hours deep into JWT configuration, wondering why your session doesn't persist across page refreshes, and questioning your life choices.
This guide covers everything you need to set up production-ready authentication in a Next.js App Router project — Google OAuth, magic links, protected routes, and session handling. No hand-waving. No "left as an exercise for the reader."
What We're Building
By the end of this guide you'll have:
- Google OAuth login
- Passwordless magic link login
- Server-side session handling with JWT
- Protected routes using middleware
- Account linking (same user, multiple providers)
We're using NextAuth v5 (Auth.js), which is the current standard for Next.js App Router authentication in 2026.
Prerequisites
- Next.js 14+ with App Router
- A PostgreSQL database (Supabase, Neon, or Railway all work)
- A Google Cloud project with OAuth credentials
- An email provider for magic links (Resend recommended)
Step 1: Install NextAuth
npm install next-auth@beta
npm install @auth/prisma-adapter
NextAuth v5 is the App Router-native version. If you're on v4, the configuration is significantly different — this guide covers v5 only.
Step 2: Set Up Environment Variables
Create or update your .env.local:
# NextAuth
AUTH_SECRET=your-secret-here # generate with: openssl rand -base64 32
AUTH_URL=http://localhost:3000
# Google OAuth
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
# Email (for magic links)
AUTH_RESEND_KEY=your-resend-api-key
# Database
DATABASE_URL=your-postgresql-connection-string
Generate AUTH_SECRET with:
openssl rand -base64 32
Never commit this to version control.
Step 3: Configure NextAuth
Create auth.ts at the root of your project:
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Resend from "next-auth/providers/resend";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
Resend({
apiKey: process.env.AUTH_RESEND_KEY!,
from: "noreply@yourdomain.com",
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
},
});
The allowDangerousEmailAccountLinking: true on Google is intentional — it allows a user who signed up with a magic link to later link their Google account without creating a duplicate. Without this, your users will hit confusing errors.
Step 4: Add the Route Handler
Create app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
That's the entire file. NextAuth handles all the OAuth callbacks, magic link verification, and session management through this single route.
Step 5: Set Up the Prisma Schema
NextAuth requires specific database tables. Add this to your schema.prisma:
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
Run migrations:
npx prisma migrate dev --name add-auth-tables
Step 6: Protect Routes with Middleware
Create middleware.ts at the root of your project:
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
const isOnApi = req.nextUrl.pathname.startsWith("/api");
if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
This runs on every request and redirects unauthenticated users away from protected routes before the page even renders. Much more secure than client-side route guards.
Step 7: Access Session in Server Components
In any Server Component:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
</div>
);
}
No useSession hook, no client component required. Just a direct async call.
Step 8: Build the Login Page
Create app/login/page.tsx:
import { signIn } from "@/auth";
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-4 p-8">
<h1 className="text-2xl font-bold">Sign in to your account</h1>
{/* Google OAuth */}
<form
action={async () => {
"use server";
await signIn("google", { redirectTo: "/dashboard" });
}}
>
<button type="submit" className="w-full rounded-md bg-white border px-4 py-2">
Continue with Google
</button>
</form>
{/* Magic Link */}
<form
action={async (formData: FormData) => {
"use server";
await signIn("resend", {
email: formData.get("email") as string,
redirectTo: "/dashboard",
});
}}
>
<input
name="email"
type="email"
placeholder="you@example.com"
className="w-full rounded-md border px-4 py-2"
/>
<button type="submit" className="w-full rounded-md bg-black text-white px-4 py-2 mt-2">
Send magic link
</button>
</form>
</div>
</div>
);
}
Common Mistakes to Avoid
Not setting AUTH_URL in production. NextAuth needs to know the canonical URL of your app for OAuth redirects. Set it explicitly in your hosting environment variables — don't rely on auto-detection.
Using client-side session checks as the only protection. Middleware runs before the page renders. A client-side useSession check is a UI concern, not a security measure. Always protect sensitive routes in middleware or server components.
Forgetting cascade deletes. The Prisma schema above includes onDelete: Cascade on Account and Session. Without this, deleting a user leaves orphaned records that will cause database errors.
Not handling the email verification flow. Magic links require the user to check their email. Make sure your login page shows a confirmation state after submission so users know what to do next.
Testing Locally
For magic links locally, use Resend's test mode or point to a local SMTP server. Magic links won't work if your email provider isn't configured.
For Google OAuth locally, add http://localhost:3000/api/auth/callback/google to your Google Cloud Console's authorized redirect URIs.
The Honest Time Estimate
Done properly, this setup takes about 3–4 hours including:
- Setting up the Google Cloud project
- Configuring Resend
- Debugging the inevitable OAuth redirect mismatch
- Testing the full flow end to end
If you'd rather skip all of it, ZeroDrag ships with this entire auth setup pre-configured — Google OAuth, magic links, account linking, protected routes, and server-side session helpers. It's the first thing that's ready when you clone the repo.
Written by Utkarsh Singh. Last updated March 2026.