//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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*"],
};// 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}</>;
}// 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;
}
}// 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");
}server-only import guarantees that lib/auth.ts can never be bundled into client code, preventing secret leakage.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().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}</>;
}cookies().get() returns { name: string; value: string } | undefined. Always use optional chaining.{ error: string }) cannot also call redirect() in the same code path, because redirect() throws.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.cookies() is async in Next.js 15+. Forgetting await produces a runtime error.config.matcher array. Use conditional logic inside the middleware function instead.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.export const dynamic = "force-dynamic" or perform auth in middleware for statically generated pages.| 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 |
cookies() is async in Next.js 15+ and returns a Promise.await means you call .get() on a Promise, which returns undefined.AUTH_SECRET and JWT signing logic from leaking into the browser bundle.redirect() throws a special NEXT_REDIRECT error internally.NEXT_REDIRECT errors or call redirect() outside the try/catch./login.pathname is appended as ?callbackUrl=/dashboard (or whatever path was requested).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.config.matcher must be statically analyzable at build time.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.null on failure so callers can do a simple truthy check.export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// ...
}React.ReactNode for the children prop, which covers elements, strings, numbers, fragments, and null.export const dynamic = "force-dynamic" or protect the route via middleware instead.auth as middleware directly, which handles session checks automatically.AUTH_SECRET safelyReviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥