NextjsProtecting Routes & Authorization

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
Middleware runs before a request even reaches a route, making it the earliest — and cheapest — place to reject an unauthenticated request. It's well suited to broad, coarse-grained rules like "every route under /dashboard requires a session".

middleware.ts

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
A shared 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

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

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

Note
These layers are complementary, not exclusive. A typical real app uses middleware for the broad strokes, a layout for section-wide session loading, and a per-page check for anything that depends on which record is actually being requested.
  • 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.