“How would you protect the /dashboard section of a Next.js app?” Most candidates answer “with middleware”, and the interviewer’s next questions decide the outcome: “Which requests does your matcher miss?”, “What happens if someone calls the Server Action directly?” and “Have you heard of CVE-2025-29927?”
Interviewers ask about middleware because it sits at an awkward spot: it is the most convenient place to redirect signed-out users, and also the easiest place to build a false sense of security. A strong answer shows what middleware is good at, how its matcher works, and why the real permission check lives somewhere else.
Before you start
You should know HTTP redirects and status codes, cookies, and how an App Router request reaches a page, a Route Handler or a Server Action. Familiarity with the Web Request and Response APIs helps, because NextRequest and NextResponse extend them. Examples use Next.js 15.5. Next.js 16 renames the file convention from middleware.ts to proxy.ts with the same ideas.
The short answer
Middleware is one middleware.ts file at the project root, or in src/, that runs before a request reaches routes and caches. It can redirect, rewrite to a different path, read and set cookies, change request and response headers, or return a response directly. A config.matcher decides which paths it runs on. It is ideal for optimistic checks such as “no session cookie, send to /login”, locale routing and A/B rewrites. It should not be the only authorisation layer: matchers can miss routes, actions and Route Handlers are separate endpoints, and the check must be repeated next to the data.
How it works
For every request whose path matches, Next runs middleware first. Its decision shapes the rest of the pipeline:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const session = req.cookies.get('session')?.value;
if (pathname.startsWith('/dashboard') && !session) {
const url = new URL('/login', req.url);
url.searchParams.set('next', pathname);
return NextResponse.redirect(url); // 307 by default
}
if (pathname === '/hello') {
return NextResponse.rewrite(new URL('/en/hello', req.url)); // URL bar keeps /hello
}
return NextResponse.next(); // continue to the route
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};A redirect tells the browser to go elsewhere, so the URL changes. A rewrite serves different content under the same URL, which is how locale prefixes and experiments are often implemented. NextResponse.next() continues normally, optionally with modified headers.
The matcher uses path patterns. '/dashboard/:path*' matches /dashboard and anything below it, but not /dashboards. The negative-lookahead pattern above runs on everything except API routes, static assets and the favicon. Without a matcher, middleware runs for every request, including JavaScript chunks, images and the RSC requests sent when Link prefetches a route.
By default middleware uses the Edge runtime: a restricted environment with Web APIs and no Node.js modules such as fs. Next.js 15.5 made the Node.js runtime stable via export const config = { runtime: 'nodejs' }, which allows Node libraries but does not make heavy per-request work a good idea. Every millisecond spent here is added to every matched request.
Step-by-step walkthrough
Step 1: Make an optimistic session check
Read the session cookie and redirect if it is missing or obviously invalid. If your session is a signed token such as a JWT, verifying the signature with a Web Crypto based library is cheap enough here. Database lookups are not.
import { jwtVerify } from 'jose';
const key = new TextEncoder().encode(process.env.SESSION_SECRET);
async function readSession(token?: string) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, key);
return payload as { sub: string; role: string };
} catch {
return null;
}
}Step 2: Scope the matcher deliberately
Decide whether you list the private paths or the public ones. A matcher of private paths such as ['/dashboard/:path*', '/settings/:path*'] is easy to read but misses every new private section someone adds later. Running on almost everything and listing the public paths in code fails safe: a new route is protected until someone marks it public.
const PUBLIC = ['/', '/login', '/pricing'];
export async function middleware(req: NextRequest) {
const isPublic = PUBLIC.includes(req.nextUrl.pathname) || req.nextUrl.pathname.startsWith('/blog');
const session = await readSession(req.cookies.get('session')?.value);
if (!isPublic && !session) return NextResponse.redirect(new URL('/login', req.url));
return NextResponse.next();
}Step 3: Pass trusted data with request headers, carefully
Middleware can add headers that Server Components read with headers(). Clients can send any header too, so delete the header first and only then set it.
const requestHeaders = new Headers(req.headers);
requestHeaders.delete('x-user-id'); // drop anything the client sent
if (session) requestHeaders.set('x-user-id', session.sub);
return NextResponse.next({ request: { headers: requestHeaders } });In a test, a request with a forged x-user-id: admin header reached the page as the real session user once this deletion was in place. Without the delete, an unauthenticated request would keep the forged value.
Step 4: Authorise again next to the data
Create a small data access layer that verifies the session and the permission for each operation. Use React cache() so several calls in one render share one check.
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export const verifySession = cache(async () => {
const session = await readSession((await cookies()).get('session')?.value);
if (!session) redirect('/login');
return session;
});
export async function getInvoice(id: string) {
const session = await verifySession();
const invoice = await db.invoice.findUnique({ where: { id } });
if (!invoice || invoice.ownerId !== session.sub) return null; // per-record check
return { id: invoice.id, total: invoice.total };
}Pages, Server Actions and Route Handlers all call these functions, so the rule is enforced whichever entry point a request uses.
Worked scenario
A SaaS app protected its private area with matcher: ['/dashboard/:path*'] and nothing else. Two problems surfaced in one quarter.
First, a team added a reporting section at /reports, outside the matcher. The pages loaded data directly in Server Components with no session check, because “middleware handles auth”. Exported revenue reports were reachable by anyone who guessed the URL.
Second, a security advisory arrived: CVE-2025-29927. Next.js used an internal x-middleware-subrequest header to prevent middleware from calling itself recursively, and a crafted value of that header made the server skip middleware entirely. Self-hosted apps on unpatched versions that relied only on middleware were open. The fix was released in Next.js 15.2.3 and 14.2.25, with backports for older lines, and blocking the header at the proxy was the interim mitigation.
The remediation had three parts: upgrade Next.js; switch the middleware to the public-paths style from Step 2; and add verifySession plus per-record checks in the data access layer, so that bypassing middleware, by bug or by matcher gap, would reveal nothing.
Common mistake
- “Middleware is my auth.” It is a convenience layer. Authorise where the data is read or changed.
- Database calls on every request. Middleware runs for each matched request, including prefetches. Keep it to cookie and token checks.
- Redirect loops. Redirecting to
/loginwhen/loginitself matches and has no session sends the browser around in circles. Exclude the target path. - Trusting request headers. Any header can be forged unless middleware deletes and resets it.
- Expecting several middleware files. There is one per project; compose logic with plain functions inside it.
Verify the behavior
Use curl against a production build and check status codes and locations.
npx next build && npx next start -p 3000 &
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:3000/dashboard/settings
# 307 http://localhost:3000/login?next=%2Fdashboard%2Fsettings
curl -s -o /dev/null -w '%{http_code}\n' -b session=valid-token localhost:3000/dashboard/settings
# 200
curl -s localhost:3000/hello | grep -o 'EN HELLO' # rewrite served /en/helloThen call a protected Server Action or Route Handler directly, with no cookie and the middleware temporarily disabled. It must still refuse. That second test is the one that proves the data access layer works.
Follow-up questions
Redirect or rewrite for locales? Redirect when the locale should be visible and shareable in the URL, such as /de/pricing. Rewrite when you want clean URLs while serving locale-specific content internally.
Does middleware run for static pages? Yes. It runs before the cache, so a statically generated page can still be protected or rewritten.
Can middleware read the request body? It can, but should not for auth or routing. Bodies can be large, and actions and handlers will parse them anyway.
Does middleware run for Link prefetches and client navigations? Yes. Those are requests for RSC payloads and pass through middleware like page loads, so a redirect there is followed by the client router. It is also why a slow middleware makes every prefetch slower.
Interview exercise
You need locale detection: visitors to /pricing should land on /en/pricing or /de/pricing based on a locale cookie, falling back to the Accept-Language header. Write the middleware logic and explain how you avoid loops and wasted work.
Answer and reasoning
Run only on paths that lack a locale prefix: in code, return NextResponse.next() immediately if the pathname already starts with /en or /de, which is what prevents redirect loops. Exclude _next, API routes and files with extensions in the matcher so assets never pay for this. Otherwise pick the locale from the cookie, then from the first supported language in Accept-Language, then default to en. Redirect with NextResponse.redirect(new URL('/' + locale + pathname + search, req.url)), preserving the query string. Use a redirect rather than a rewrite because locale URLs should be shareable and indexable separately. Optionally set the cookie on the response so later visits skip header parsing. The reasoning interviewers listen for is the guard clause, the matcher exclusions and keeping the work cheap.
Continue learning
Practise more routing and security questions in the Next.js interview questions and the Next.js MCQs. Read Next.js Server Actions for why actions need their own checks, and Next.js App Router vs Pages Router for where middleware fits in migration. The official references are the Next.js 15 middleware API and the CVE-2025-29927 advisory.