Protected Routes & Middleware
Protected Routes & Middleware
Protecting routes in Next.js has two layers: middleware for fast edge-level checks, and server-side auth verification in layouts and pages. Understanding both ensures your app is secure without creating a confusing user experience.
Middleware — The First Line of Defense
Middleware runs before any page is rendered. It's the ideal place to check authentication for every request to protected routes, before any database query or server-side rendering happens.
// src/middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export default auth(function middleware(req) {
const { nextUrl, auth: session } = req;
const isLoggedIn = !!session;
// Define protected route patterns
const isProtected = nextUrl.pathname.startsWith("/dashboard") ||
nextUrl.pathname.startsWith("/settings") ||
nextUrl.pathname.startsWith("/api/protected");
const isAuthPage = nextUrl.pathname.startsWith("/login") ||
nextUrl.pathname.startsWith("/register");
// Redirect unauthenticated users from protected routes
if (isProtected && !isLoggedIn) {
const redirectUrl = new URL("/login", nextUrl.origin);
redirectUrl.searchParams.set("callbackUrl", nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// Redirect authenticated users away from auth pages
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl.origin));
}
return NextResponse.next();
});
// Tell Next.js which routes to run middleware on
export const config = {
matcher: [
// Match all routes except static files and Next.js internals
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
Role-Based Access Control in Middleware
// src/middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth(function middleware(req) {
const session = req.auth;
const { pathname } = req.nextUrl;
// Admin-only routes
if (pathname.startsWith("/admin")) {
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (session.user.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", req.url));
}
}
// Instructor-only routes
if (pathname.startsWith("/studio")) {
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (!["admin", "instructor"].includes(session.user.role)) {
return NextResponse.redirect(new URL("/unauthorized", req.url));
}
}
// Regular protected routes
if (pathname.startsWith("/dashboard")) {
if (!session) {
const url = new URL("/login", req.url);
url.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(url);
}
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next|api/auth|_vercel|.*\\..*).*)", "/api/:path*"],
};
Layout-Level Protection
Middleware is the first check. Layouts provide a second layer — useful when the layout itself needs auth data to render correctly:
// src/app/dashboard/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
// Double-check auth in the layout (middleware might have race conditions during deploy)
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<DashboardSidebar user={session.user} />
<main className="flex-1 bg-gray-50">{children}</main>
</div>
);
}
This is important: middleware runs at the edge and might have stale session data during Vercel deployment rollovers. The layout check ensures correctness.
Page-Level Protection
For pages that need fine-grained checks beyond "is logged in":
// src/app/courses/[slug]/edit/page.tsx
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import { db } from "@/lib/db";
export default async function EditCoursePage({ params }: { params: { slug: string } }) {
const session = await auth();
if (!session) redirect("/login");
const course = await db.course.findUnique({
where: { slug: params.slug },
select: { id: true, title: true, authorId: true },
});
if (!course) notFound();
// Only the author or an admin can edit
if (course.authorId !== session.user.id && session.user.role !== "admin") {
redirect("/unauthorized");
}
return <CourseEditor courseId={course.id} />;
}
API Route Protection
For API routes in the App Router:
// src/app/api/courses/[id]/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const course = await db.course.findUnique({ where: { id: params.id } });
if (!course) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (course.authorId !== session.user.id && session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await db.course.delete({ where: { id: params.id } });
return new Response(null, { status: 204 });
}
The Callback URL Pattern
After login, redirect users back to what they were trying to access:
// src/app/login/page.tsx
"use client";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
export default function LoginPage() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
callbackUrl, // redirect to original destination after login
});
}
return (
<form onSubmit={handleLogin}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign in</button>
</form>
);
}
User tries to visit /dashboard/settings → Middleware redirects to /login?callbackUrl=/dashboard/settings → User logs in → Redirects to /dashboard/settings.
Unauthorized Page
// src/app/unauthorized/page.tsx
import Link from "next/link";
export default function UnauthorizedPage() {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">Access Denied</h1>
<p className="text-gray-500 mt-3">
You don't have permission to access this page.
</p>
<div className="flex gap-3 justify-center mt-6">
<Link href="/dashboard" className="text-blue-600 hover:underline">
Go to Dashboard
</Link>
<span className="text-gray-300">|</span>
<Link href="/" className="text-blue-600 hover:underline">
Go Home
</Link>
</div>
</div>
</main>
);
}
Next lesson: Tailwind CSS in Next.js — typography plugin, advanced theming, and component patterns.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises