Authentication Patterns
Recipe
Protect pages and API routes in a Next.js 15+ App Router application using middleware-based session checks, server-side auth validation, and per-page guarding.
Working Example
Middleware Session Check
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
const protectedPaths = ["/dashboard", "/settings", "/account"];
export function middleware(request: NextRequest) {
const sessionToken = request.cookies.get("session-token")?.value;
const { pathname } = request.nextUrl;
const isProtected = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (isProtected && !sessionToken) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/account/:path*"],
};Server-Side Auth in a Layout
// app/dashboard/layout.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifySession } from "@/lib/auth";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const token = cookieStore.get("session-token")?.value;
if (!token) {
redirect("/login");
}
const session = await verifySession(token);
if (!session) {
redirect("/login");
}
return <>{children}</>;
}Auth Utility (Server-Only)
// lib/auth.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
export async function createSession(userId: string) {
return new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(secret);
}
export async function verifySession(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null;
}
}Login Server Action
// app/login/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createSession } from "@/lib/auth";
export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// Validate credentials against your database
const user = await authenticateUser(email, password);
if (!user) {
return { error: "Invalid credentials" };
}
const token = await createSession(user.id);
const cookieStore = await cookies();
cookieStore.set("session-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
redirect("/dashboard");
}Deep Dive
How It Works
- Middleware runs at the edge before any page or API route renders. It can read cookies and headers but cannot access a database directly. Use it for fast, coarse-grained checks (is a token present?).
- Server Components and layouts run on the Node.js server. They can perform full session verification against a database or JWT secret.
- The
server-onlyimport guarantees thatlib/auth.tscan never be bundled into client code, preventing secret leakage. - Cookies are the primary session transport in App Router. The
cookies()function is async in Next.js 15+ and must be awaited. redirect()throws internally, so code after it is unreachable. No explicitreturnis needed afterredirect().
Variations
Using NextAuth.js (Auth.js v5):
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
});
// middleware.ts
export { auth as middleware } from "./auth";Role-Based Access:
// app/admin/layout.tsx
import { verifySession } from "@/lib/auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const token = cookieStore.get("session-token")?.value;
const session = await verifySession(token!);
if (session?.role !== "admin") {
redirect("/unauthorized");
}
return <>{children}</>;
}TypeScript Notes
cookies().get()returns{ name: string; value: string } | undefined. Always use optional chaining.- Server Actions that return data (like
{ error: string }) cannot also callredirect()in the same code path, becauseredirect()throws. - Use
import "server-only"at the top of any module containing secrets to get a compile-time error if it is imported from a Client Component.
Gotchas
- Middleware cannot access databases or Node.js APIs. It runs in the Edge Runtime by default. Only check for token presence; verify tokens in Server Components or Route Handlers.
cookies()is async in Next.js 15+. Forgettingawaitproduces a runtime error.- Middleware matcher must be static. You cannot use runtime variables in the
config.matcherarray. Use conditional logic inside the middleware function instead. redirect()inside a try/catch will be swallowed because it throws a specialNEXT_REDIRECTerror. Either rethrow it or callredirect()outside the try/catch.- Static pages cannot check auth at request time. Use
export const dynamic = "force-dynamic"or perform auth in middleware for statically generated pages.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Middleware-only | Fast, runs before rendering | Cannot verify tokens against DB |
| Layout-based auth | Full server access, can query DB | Runs after middleware, slight latency |
| NextAuth.js (Auth.js) | Built-in providers, session management | Extra dependency, migration churn |
| Clerk or Supabase Auth | Managed service, less code | Vendor lock-in, cost at scale |
| Iron Session | Encrypted cookies, no JWT | Smaller community, manual setup |
FAQs
Why does the middleware only check for the presence of a token instead of fully verifying it?
- Middleware runs in the Edge Runtime, which cannot access databases or most Node.js APIs.
- A full JWT verify with a database lookup is too heavy for the edge.
- Middleware performs a coarse check (is the cookie present?), then Server Components do the full verification.
What happens if you forget to await the cookies() call in Next.js 15+?
cookies()is async in Next.js 15+ and returns aPromise.- Forgetting
awaitmeans you call.get()on a Promise, which returnsundefined. - This silently breaks auth checks, making protected routes appear as if no token exists.
Why is import "server-only" used at the top of lib/auth.ts?
- It guarantees a compile-time error if any Client Component imports this module.
- This prevents your
AUTH_SECRETand JWT signing logic from leaking into the browser bundle.
Can you call redirect() inside a try/catch block?
redirect()throws a specialNEXT_REDIRECTerror internally.- If wrapped in a try/catch, the redirect is caught and swallowed, so the user is never redirected.
- Either rethrow
NEXT_REDIRECTerrors or callredirect()outside the try/catch.
How does the callbackUrl pattern work in the middleware redirect?
- When an unauthenticated user hits a protected path, the middleware redirects to
/login. - The original
pathnameis appended as?callbackUrl=/dashboard(or whatever path was requested). - After successful login, your login handler can read this param and redirect back to the original page.
What is the difference between middleware-based auth and layout-based auth?
- Middleware runs before any rendering, at the edge, and can only do lightweight checks.
- Layout-based auth runs on the Node.js server, can query a database, and fully verify a session.
- Best practice is to combine both: middleware for a fast gate, layout for full verification.
Why does the Login Server Action set httpOnly: true and secure: true on the cookie?
httpOnlyprevents JavaScript from reading the cookie, mitigating XSS attacks.secureensures the cookie is only sent over HTTPS in production.sameSite: "lax"provides basic CSRF protection.
Gotcha: What happens if you use runtime variables in the middleware config.matcher array?
- The
config.matchermust be statically analyzable at build time. - Using variables or function calls causes the matcher to silently fail or throw a build error.
- Put conditional logic inside the middleware function body instead.
How would you type the return value of verifySession in TypeScript?
import { JWTPayload } from "jose";
export async function verifySession(
token: string
): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null;
}
}JWTPayloadfromjoseprovides the base type withiss,sub,exp, etc.- Return
nullon failure so callers can do a simple truthy check.
How do you type the children prop in a layout component with TypeScript?
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// ...
}- Use
React.ReactNodefor thechildrenprop, which covers elements, strings, numbers, fragments, andnull.
Why can't statically generated pages check auth at request time?
- Static pages are pre-rendered at build time and served as plain HTML files.
- There is no server-side execution at request time to read cookies or verify sessions.
- Use
export const dynamic = "force-dynamic"or protect the route via middleware instead.
How does NextAuth.js (Auth.js v5) simplify the middleware setup?
- You export
auth as middlewaredirectly, which handles session checks automatically. - Auth.js manages providers, session storage, and token rotation out of the box.
- The trade-off is an extra dependency and keeping up with Auth.js migration changes.
Related
- Route Handlers - protect API endpoints with the same auth utilities
- Environment Variables - storing
AUTH_SECRETsafely - Error Handling - handling auth failures gracefully
- Next.js Middleware Docs