One file, at the root
It runs before the route is resolved, for every request that matches. One file for the whole application — not one per route.
import { NextResponse } from "next/server";
export function middleware(request) {
const session = request.cookies.get("sid");
if (!session) {
const url = new URL("/login", request.url);
url.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/doors/:id/edit"],
};
The four things it can do
NextResponse.next() // carry on
NextResponse.redirect(new URL("/login", request.url)) // 307, and the URL changes
NextResponse.rewrite(new URL("/uk/doors", request.url)) // the URL stays, the content changes
new NextResponse("Too many requests", { status: 429 }) // answer it here
// and it can add to what passes through
const res = NextResponse.next();
res.headers.set("x-nonce", nonce);
res.cookies.set("locale", "az");
return res;
A redirect tells the browser to go elsewhere — the address bar changes. A rewrite serves different content at the same address, which is how A/B tests, locales and multi-tenant hosts are usually built.
The matcher
The negative form is common and worth reading slowly: everything that does not start with api, _next/static, _next/image or favicon.ico. Without a matcher, middleware runs for every asset request too.
export const config = {
matcher: [
"/dashboard/:path*", // and everything below it
"/doors/:id", // one segment
"/((?!api|_next/static|_next/image|favicon.ico).*)", // everything EXCEPT
],
};
It runs in a restricted runtime
- No Node APIs: no
fs, nonet, no native modules — and often no database driver. - No heavy dependencies: it is on the path of every matching request, so a 200 ms import is 200 ms added to everything.
- Cookies, headers, the URL and
fetchare available, and that is the intended surface.
Check that a cookie exists and is well formed, and do the real authorisation in the page, the layout or the action — where the data lives and where a failure can render something sensible.
What it is genuinely good for
// 1. bounce anonymous visitors before a protected page renders
// 2. locale: read a cookie or Accept-Language, rewrite to /az/…
// 3. multi-tenant: read the host, rewrite to /sites/<tenant>/…
// 4. security headers and a per-request CSP nonce
What it is not for
- Authorisation logic of any depth. A cookie check is a gate, not a permission system.
- Reading or writing data.
- Anything slow. Every matched request pays for it.
- Rate limiting with an in-memory counter: instances do not share memory. Use Redis, or the platform.
Order, and the loop you will write once
// middleware redirects /login → /login → /login …
export const config = { matcher: ["/((?!_next).*)"] };
export function middleware(request) {
if (request.nextUrl.pathname === "/login") return NextResponse.next(); // let it through
if (!request.cookies.get("sid")) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}