Protecting Routes with Middleware
- nextjs
- react
- typescript
- security
- middleware
- authentication
- webdev
- softwareengineering
In Authentication in Next.js I covered how to issue and store a session once a user logs in. That article stopped at the moment the cookie lands in the browser. It didn't answer the question that actually matters for a production app: how do you stop an unauthenticated request from ever reaching a protected page in the first place? That's the job of middleware, and it's easy to get subtly wrong in ways that don't show up until a real user hits them.
This is part of the React & Next.js Complete Guide series. Where the previous article was about issuing and reading sessions, this one is about enforcing them at the edge of your app, before a route handler or a page component ever runs.
Route protection sounds solved: check if the user is logged in, redirect if not. The nuance is in where that check happens and what it should deliberately avoid doing. Get the boundaries wrong and you end up with either a security hole or a middleware file doing database lookups on every request.
Why middleware is the right layer for this check
Next.js middleware runs before a request is matched to a page, layout, or route handler. It executes on the Edge Runtime, which is a stripped-down JavaScript environment, not a full Node.js process. That constraint is actually the point: it means middleware runs close to the user, before your app has spun up any rendering work, so an unauthenticated request gets turned away as cheaply as possible.
Compare that to checking auth inside a Server Component or a layout. By the time a layout runs, Next.js has already resolved the route and begun rendering. If the user turns out to be unauthenticated, that work was wasted, and you're relying on every layout in the tree to remember the check. Middleware gives you one place to enforce "you must have a valid session to be here" instead of scattering that logic across every protected page.
The tradeoff is that the Edge Runtime doesn't have access to most of Node's standard library: no fs, no most native modules, and no bcrypt or Node's built-in crypto module the way you'd use them in a NestJS service. If your session token is a JWT, you need an edge-compatible verification library. jose is the common choice here, since it's built on the Web Crypto API, which the Edge Runtime does support.
Implementing the check
A typical middleware setup verifies a signed session token from a cookie, and redirects to the login page if it's missing or invalid.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const PROTECTED_PREFIXES = ["/dashboard", "/settings", "/billing"];
const PUBLIC_ONLY_PATHS = ["/login", "/signup"];
const secret = new TextEncoder().encode(process.env.SESSION_JWT_SECRET);
async function getSession(token: string | undefined) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as { sub: string; orgId: string; role: string };
} catch {
// Expired, malformed, or tampered token. Treat all of these the same:
// as no session.
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get("session")?.value;
const session = await getSession(token);
const isProtected = PROTECTED_PREFIXES.some((prefix) =>
pathname.startsWith(prefix),
);
const isPublicOnly = PUBLIC_ONLY_PATHS.some((path) => pathname === path);
if (isProtected && !session) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirectTo", pathname);
return NextResponse.redirect(loginUrl);
}
if (isPublicOnly && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/billing/:path*",
"/login",
"/signup",
],
};
Two details in this file are doing more work than they look like. First, the matcher config is a static, build-time array. Middleware only runs for requests matching one of these patterns, so anything you forget to list here never touches this check at all. Second, the redirect to /login carries the original path in redirectTo, so the login page can send the user back to where they were going instead of dumping them on a generic dashboard.
Notice what the middleware does not do: it doesn't hit the database to confirm the user still exists, isn't suspended, or hasn't had their role changed since the token was issued. It only verifies the token's signature and expiry. That's a deliberate scope limit, not an oversight.
Where middleware should stop
Middleware is good at answering one question: does this request carry a validly signed, unexpired session token? It's a poor place to answer a second, more expensive question: does this specific user have permission to update this specific organization's billing settings?
Trying to cram authorization logic into middleware tends to go one of two ways. Either you skip the database entirely and trust whatever role claim is baked into the JWT, which means a role change doesn't take effect until the token naturally expires and gets reissued. Or you start making database calls from middleware. That runs on every matched request, so it adds latency to every navigation. It also pushes real query logic into a runtime that was never designed to hold a database connection pool.
The better split is:
- Middleware: is there a session at all, and is it structurally valid? Redirect to login if not.
- Route handlers, Server Components, or a NestJS guard on the API side: given this specific user and this specific resource, is the action allowed? Check the current role and tenant scope against the database or a cache, not against a claim in a token that might be stale.
This is the same defense-in-depth idea behind role-based access control done well: one layer decides whether the request can be here at all, a second decides whether it can do this specific thing. Middleware should never be the last line of defense for sensitive actions; it's a coarse filter, and the fine-grained checks belong in the layer that actually talks to your data.
Production pitfalls worth knowing before they bite you
Matcher mistakes leave routes unprotected. Add a new page under /settings/team while the matcher only lists /settings without a wildcard, and that route silently stops being protected. Prefer whole prefixes with :path* over listing individual pages, and write a test that hits each protected route without a session and asserts a redirect.
Redirect loops from protecting the login page itself. If the matcher accidentally puts /login in the protected list instead of the public-only list, an unauthenticated user gets redirected to /login, which redirects them back to /login, forever. This happens easily when you copy a matcher array and forget to move a path between the two lists.
Cookies need the right flags, or the check is theater. The session cookie should carry httpOnly so client-side JavaScript can't read it, secure so it's never sent over plain HTTP, and an explicit sameSite policy. Skip httpOnly and an XSS bug anywhere in your client bundle turns into full session theft, no matter how solid the middleware logic is.
API routes need their own check, separate from page middleware. A page being protected doesn't automatically protect the API routes it calls, since those routes can be hit directly by curl or a mobile client, not just by browser navigation from a protected page. Make sure the matcher explicitly covers API paths that need protection.
Middleware runs on every matched request, including prefetches. Next.js prefetches links in the viewport by default, so anything even mildly expensive in your middleware, like an external call, gets paid on every prefetch. Keep middleware logic to token verification and routing decisions; do database enrichment downstream in the layout or route handler where you know a real navigation is happening.
Combining middleware with layout-level guards
Even with solid middleware, I'd still add a lightweight check in the root layout of a protected route group, reading the session from headers() or a server-side helper rather than trusting that middleware definitely ran. A matcher misconfiguration, or a route added under a segment the matcher array doesn't cover, is exactly the kind of gap this second check catches. The layout check should stay cheap, reading the same verified session rather than re-implementing JWT verification. Two independent layers catching the same class of bug is a reasonable cost for how expensive an authentication bypass is to discover after the fact.
Key takeaways
- Middleware runs on the Edge Runtime before any page or route handler, making it the cheapest place to turn away unauthenticated requests, but it can't use Node-only APIs like `bcrypt`; use an edge-compatible library such as `jose` for JWT verification.
- Keep middleware scoped to authentication (does a valid session exist), not authorization (is this specific action allowed); push role and tenant checks down into route handlers, Server Components, or your API layer where you can hit the database or cache directly.
- The `matcher` config is a static allowlist. Anything not matched never runs through your protection logic, so prefer prefix wildcards over listing individual routes and test the unprotected case explicitly.
- Never include the login page itself in the protected matcher list, or you'll create a redirect loop for every unauthenticated visitor.
- Session cookies need `httpOnly`, `secure`, and an explicit `sameSite` policy; middleware checking for a cookie's presence doesn't help if the cookie can be read or stolen by client-side script.
- Add a lightweight session check at the layout level as a second, independent layer, not a replacement for middleware, since matcher misconfigurations are an easy way to silently lose coverage on a new route.
Frequently asked questions
Does Next.js middleware run on Node.js or a different runtime?
It runs on the Edge Runtime by default, a restricted JavaScript environment based on the Web platform APIs rather than full Node.js. That means no `fs`, no most native Node modules, and you need edge-compatible libraries for things like JWT verification.
Can I check a user's role in middleware and skip further checks downstream?
You can read a role claim out of a JWT in middleware, but that claim reflects the role at the time the token was issued, not necessarily right now. If roles can change without forcing a re-login, treat the middleware check as coarse-grained and verify the current role against your database or cache before allowing sensitive actions.
Why did my protected route stop redirecting after I added a new page under it?
Check your `matcher` config in `middleware.ts`. Middleware only executes for paths that match one of the listed patterns, so a new route added under a prefix that isn't covered by a wildcard (`:path*`) won't be protected even if a sibling route is.
Should middleware make a database call to verify the session is still valid?
Generally no. Middleware runs on every matched request, including link prefetches, so adding a database round trip there adds latency across your whole app. Verify the token's signature and expiry in middleware, and defer any database-backed checks to the route handler or Server Component that actually needs fresh data.
What's the difference between protecting a page with middleware versus checking auth in a layout?
Middleware runs earlier and stops the request before any rendering work starts, which is cheaper. A layout-level check runs after the route has already been matched and rendering has begun. Using both isn't redundant: the layout check is a safety net for the case where a matcher misconfiguration lets an unprotected request through.
Do I need to protect API routes separately from the pages that call them?
Yes. A page being protected by middleware doesn't automatically protect the API routes it calls, since those routes can be hit directly by any client, not just your browser navigating from a protected page. Make sure your matcher configuration explicitly covers the API paths that need the same session check.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.