Protecting Routes & Authorization
Once you can tell whether a request is authenticated, the next question is where to enforce it. The App Router gives you three natural places to check, each trading off how early the check runs against how much context it has available.
Middleware-based protection
/dashboard requires a session".middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const session = request.cookies.get('session')
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}Layout-based protection
layout.tsx can check authentication once for every page nested beneath it — useful when the check needs slightly more than a cookie's presence (e.g. verifying the session is still valid, or loading the user record every protected page needs anyway).app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/session'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) redirect('/login')
return <div className="dashboard-shell">{children}</div>
}Server Component fine-grained checks
Middleware and layouts are good at "is this user logged in?". They're a poor fit for "is this specific user allowed to see this specific record?" — that's authorization, and it usually needs data the outer layers don't have. That check belongs in the Server Component rendering the actual resource.
app/dashboard/invoices/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getSession } from '@/lib/session'
import { getInvoice } from '@/lib/invoices'
export default async function InvoicePage({
params,
}: {
params: { id: string }
}) {
const session = await getSession()
const invoice = await getInvoice(params.id)
// Not just "logged in" — specifically the owner of this invoice.
if (!invoice || invoice.ownerId !== session?.userId) {
notFound()
}
return <InvoiceDetails invoice={invoice} />
}Which approach fits which scenario
Approach | Runs | Best for | Not great for |
|---|---|---|---|
Middleware | Before the route is resolved, on every matching request | Broad gate-keeping: entire route subtrees requiring any session | Per-record authorization — has no access to route data yet |
Layout | Once per nested route render, on the shared parent layout | Section-wide auth plus shared setup (loading the user once for every child page) | Rules that differ page-to-page within the same section |
Server Component | On the specific page rendering a resource | Fine-grained authorization tied to the actual data being shown | Using it as the only gate — leaves a window where unauthenticated requests still reach the component's data-fetching |
Middleware is the earliest and cheapest gate, suited to coarse "must be logged in" rules.
A shared layout centralizes section-wide checks and avoids repeating the same session lookup on every page.
Fine-grained, per-record authorization belongs in the Server Component that actually loads the resource.
Most real apps combine all three rather than relying on just one.