Middleware

Run a little code before every matching request — and keep it little.

12of 16
≈ 10Min read
7Parts

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.

middleware.js, beside app/
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

and that is genuinely all
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;
Redirect or rewrite?

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.

match narrowly — it runs on every request otherwise
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, no net, 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 fetch are available, and that is the intended surface.
Do not verify a session against the database here

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

four real uses
// 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

redirecting to a page that also matches
// 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();
}

Practice