React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

authmiddlewaresessionjwtnext-authprotected-routes

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-only import guarantees that lib/auth.ts can 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 explicit return is needed after redirect().

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 call redirect() in the same code path, because redirect() 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

  1. 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.
  2. cookies() is async in Next.js 15+. Forgetting await produces a runtime error.
  3. Middleware matcher must be static. You cannot use runtime variables in the config.matcher array. Use conditional logic inside the middleware function instead.
  4. redirect() inside a try/catch will be swallowed because it throws a special NEXT_REDIRECT error. Either rethrow it or call redirect() outside the try/catch.
  5. Static pages cannot check auth at request time. Use export const dynamic = "force-dynamic" or perform auth in middleware for statically generated pages.

Alternatives

ApproachProsCons
Middleware-onlyFast, runs before renderingCannot verify tokens against DB
Layout-based authFull server access, can query DBRuns after middleware, slight latency
NextAuth.js (Auth.js)Built-in providers, session managementExtra dependency, migration churn
Clerk or Supabase AuthManaged service, less codeVendor lock-in, cost at scale
Iron SessionEncrypted cookies, no JWTSmaller 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 a Promise.
  • Forgetting await means you call .get() on a Promise, which returns undefined.
  • 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_SECRET and JWT signing logic from leaking into the browser bundle.
Can you call redirect() inside a try/catch block?
  • redirect() throws a special NEXT_REDIRECT error internally.
  • If wrapped in a try/catch, the redirect is caught and swallowed, so the user is never redirected.
  • Either rethrow NEXT_REDIRECT errors or call redirect() 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 pathname is 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?
  • httpOnly prevents JavaScript from reading the cookie, mitigating XSS attacks.
  • secure ensures 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.matcher must 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;
  }
}
  • JWTPayload from jose provides the base type with iss, sub, exp, etc.
  • Return null on 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.ReactNode for the children prop, which covers elements, strings, numbers, fragments, and null.
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 middleware directly, 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.