Skip to main content
BlogWeb Dev

How to Build Next.js Middleware: Matcher, Redirect, Rewrite

A beginner guide to building Next.js middleware: middleware.ts, matcher, redirect, rewrite, headers and testing with curl, plus the most common errors.

Matthews Wong

Software Engineer

October 202610 min read
How to Build Next.js Middleware: Matcher, Redirect, Rewrite

Key Takeaway

To build Next.js middleware, create a middleware.ts file in the project root, next to the app folder, and export a function that receives the request and returns a NextResponse. Limit it with a matcher so it skips static files, use a redirect to move the URL, a rewrite to serve another page there, and test with curl.

You add a /dashboard page, then realise anyone who types the address can open it. You want one place that checks the request before the page renders. Once the middleware file exists, three things tend to happen: nothing happens at all, CSS and images get blocked too, or the browser spins until ERR_TOO_MANY_REDIRECTS appears.

This guide builds one small middleware file from scratch in Next.js 15 (App Router): a custom header, a matcher, a conditional redirect, a rewrite, and how to test it with curl. Every claim about the API was checked against the Next.js documentation listed in the sources, and I ran the matcher and NextResponse behaviour directly on Next.js 15.5.7. The matcher pattern is the same one used in this site's own middleware.ts.

Want the concepts first, with a simulation?

This guide focuses on steps and errors. Our free lesson covers execution order, content negotiation with the Accept header and why Vary matters, with a quiz: Next.js Middleware Tutorial: Matcher, Rewrites and Accept. Finish this guide first so you have working middleware, then continue to the lesson to see how this site uses it.

What you need

Everything runs on a laptop, with no server and no extra cost.

  • A Next.js 15 project with the App Router (the app folder), for example from create-next-app. This guide uses TypeScript.
  • A dev server running with npm run dev, usually at http://localhost:3000.
  • curl in a terminal. We test without opening a browser, because middleware is only about status, headers and location.
  • Three empty pages so the redirect and rewrite targets exist: app/login/page.tsx, app/dashboard/page.tsx and app/campaign/page.tsx.

Step 1: Create your first middleware.ts and set one header

Middleware is a single file named middleware.ts in the project root, next to the app folder (or inside src if you use it). It holds one function that receives the request and may rewrite it, redirect it, change headers, or answer directly. NextResponse.next() means continue as normal. We set one header as proof that the function runs.

// middleware.ts
// Wrong: app/middleware.ts or app/lib/middleware.ts. Next.js never loads it.
// Right: the project root, at the same level as the app folder (or inside src/).
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next(); // continue as normal
  response.headers.set("x-hello-from-middleware", "hello");
  return response;
}

// In a second terminal, while "npm run dev" is running:
//   curl -I http://localhost:3000/

Expected output: The response has status 200 and includes the line x-hello-from-middleware: hello. If that line is missing, the middleware was not picked up: see the first row of the error table.

A version note: this guide uses Next.js 15, where the file is named middleware.ts. In Next.js 16 that convention was renamed to proxy with the same ideas underneath; the migration steps are in Migrating middleware.ts to proxy.ts in Next.js 16. The docs also say this feature should be a last resort, so reach for it when next.config or a route cannot do the job.

A middleware.ts placed inside the app folder never runs. It must sit next to app, and one file may export only one middleware function.

Step 2: Limit it with a matcher so static files are skipped

Without a matcher, middleware runs on every request, including static files, image optimisation and everything in public. Once you add a redirect, CSS and images get bounced too. The matcher is a config export that decides which paths may reach the function.

// Add to the bottom of middleware.ts

export const config = {
  // Wrong: only api and Next.js internals are excluded, so /next.svg and
  //        every other file in public/ still goes through the function.
  // matcher: ["/((?!api|_next/static|_next/image).*)"],

  // Right: also exclude any path that contains a dot (a file extension).
  // The doubled backslash is one escaped dot inside a JavaScript string.
  matcher: ["/((?!api|_next|.*\\..*).*)"],

  // Alternative: list only the paths that need the function.
  // matcher: ["/dashboard/:path*", "/promo"],
};

// Matcher values must be literal constants. A variable is ignored,
// because Next.js reads this export at build time.

//   curl -I http://localhost:3000/api/hello
//   curl -I http://localhost:3000/next.svg
//   curl -I http://localhost:3000/

The pattern below is a negative lookahead: match every path except those starting with api or _next, and except any path containing a dot. That dot clause is what removes files such as next.svg, robots.txt and sitemap.xml. Two rules from the docs: a source must start with a slash, and matcher values must be constants because they are analysed at build time, so variables are ignored.

Expected output: curl -I to /api/hello and to /next.svg no longer includes x-hello-from-middleware, while an ordinary page such as / still does.

Step 3: Redirect pages that need a login

A redirect sends the client to another URL: the browser moves and the address changes. We redirect /dashboard to /login when the cookie named session is missing, and carry the original address in a from query so the login page can send the user back. The docs example builds the target with a URL object based on request.url.

// middleware.ts  (replace the function body from step 1)
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Wrong: if (!request.cookies.has("session")) redirect for EVERY path.
  //        /login matches too, so it redirects to /login forever.
  // Right: only guard the paths that need a login.
  if (pathname.startsWith("/dashboard") && !request.cookies.has("session")) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("from", pathname); // remember where they were going
    return NextResponse.redirect(loginUrl); // 307 by default
  }

  const response = NextResponse.next();
  response.headers.set("x-hello-from-middleware", "hello");
  return response;
}

//   curl -i http://localhost:3000/dashboard
//   curl -i -H "Cookie: session=abc" http://localhost:3000/dashboard

The code deliberately checks only paths that start with /dashboard. That prevents a loop: if every path without a cookie were redirected to /login, then /login itself would be redirected to /login forever, and the browser gives up with ERR_TOO_MANY_REDIRECTS. The default status of NextResponse.redirect is 307, a temporary redirect that keeps the request method and body.

Expected output: Without the cookie, curl -i shows HTTP 307 and a location line pointing to http://localhost:3000/login?from=%2Fdashboard. With the session cookie, there is no 307 to /login any more.

A cookie check like this is only optimistic: a fake cookie named session passes too. The Next.js authentication docs advise reading only the cookie in middleware, with no database query, and stress that middleware must not be your only line of defence. Real checks belong as close to the data as possible.

Step 4: Rewrite to serve another page at the same URL

A rewrite displays the content of one URL in place of the one requested, and the address in the browser does not change. In this example /promo shows the /campaign page. Here is the complete middleware.ts up to this step.

// middleware.ts  (the whole file so far)
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (pathname.startsWith("/dashboard") && !request.cookies.has("session")) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("from", pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Wrong: NextResponse.redirect(...) here. The address bar would change to /campaign.
  // Right: rewrite. The visitor keeps /promo and gets the /campaign page.
  if (pathname === "/promo") {
    return NextResponse.rewrite(new URL("/campaign", request.url));
  }

  const response = NextResponse.next();
  response.headers.set("x-hello-from-middleware", "hello");
  return response;
}

export const config = {
  matcher: ["/((?!api|_next|.*\\..*).*)"],
};

//   curl -i http://localhost:3000/promo

The rule for choosing is simple. Use a redirect when the address really has to move, for example a page was relocated or the user must log in. Use a rewrite when the address must stay but the content comes from somewhere else. This site uses a rewrite in its middleware.ts to serve a Markdown version at the same page URL for agents that ask for text/markdown, with no redirect hop.

Expected output: curl -i to /promo returns HTTP 200 with no location line, and the body is the campaign page. Compare that with /dashboard in step 3, which returned 307.

Step 5: Pass headers to the page in the right direction

Sometimes middleware needs to hand information to the page or route behind it. There are two similar ways that point in opposite directions. NextResponse.next with a request option holding headers forwards them upstream, to the page, route or server action, and does not send them to the client. NextResponse.next with a headers option sends them straight to the client, and the docs mark that as bad practice because it can override headers such as Content-Type that the framework relies on.

// Replace the last three lines of the function (NextResponse.next ... return response)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-version", "123");

// Right: forward upstream. The page or route sees x-version; the client does not.
const response = NextResponse.next({
  request: { headers: requestHeaders },
});

// Wrong: NextResponse.next({ headers: requestHeaders })
// That sends them to the CLIENT, and can override headers such as Content-Type.

// A header meant for the client is set on the response itself.
response.headers.set("x-hello-from-middleware", "hello");
return response;

//   curl -I http://localhost:3000/

A real use of this pattern is passing a nonce for a Content Security Policy; the story is in Content Security Policy in Next.js with a nonce. Do not copy every request header. Build a subset with an allow-list and drop authorization and cookie. Avoid large headers because they can trigger a 431 Request Header Fields Too Large error.

Expected output: curl -I to a page does not show x-version in the response, because that header is only forwarded upstream. The x-hello-from-middleware header still appears because we set it directly on the response.

Step 6: Test everything with curl

A middleware result is only a status, headers and a Location, so curl is enough to test it. Save the commands below as a small script and run it every time you change the matcher or the logic. One loop checks five kinds of path at once: an ordinary page, an API route, a static file, a redirected page and a rewritten page.

# check-middleware.sh  (run while "npm run dev" is up)
# -s silent, -o /dev/null drop the body, -D - print only the response headers.
for p in / /api/hello /next.svg /dashboard /promo; do
  echo "== $p"
  curl -s -o /dev/null -D - "http://localhost:3000$p" | grep -iE "^(HTTP|location|x-hello)"
done

# The passing branch of the redirect:
curl -s -o /dev/null -D - -H "Cookie: session=abc" http://localhost:3000/dashboard | grep -iE "^(HTTP|location)"

Then repeat for /dashboard with the cookie, so the passing branch is proven too.

Expected output: The / page returns 200 and x-hello. /api/hello and /next.svg do not include x-hello (their status follows the route or file itself). /dashboard returns 307 with a location to /login?from=%2Fdashboard. /promo returns 200 with no location. With the cookie, /dashboard is no longer 307.

Want automated tests? Next.js ships experimental helpers in next/experimental/testing/server: unstable_doesMiddlewareMatch to assert whether the matcher matches a URL, plus isRewrite, getRewrittenUrl and getRedirectUrl to inspect the result. The unstable prefix marks them as experimental, and in the Next.js 16 docs the first one is called unstable_doesProxyMatch.

Common errors and how to fix them

Eight problems that most often leave beginners stuck the first time they build middleware in Next.js.

SymptomCauseFix
Middleware does not run: the x-hello-from-middleware header is missingThe file is inside the app folder, is misnamed, or the function is not exported.Move it to the project root next to app (or into src), name it middleware.ts, and export a function named middleware or a default export.
CSS, images or files in public get redirected or rewritten tooThere is no matcher, or the pattern does not exclude paths with an extension (for example it only lists api and _next/static).Add a config with a negative lookahead that excludes api, _next and any path containing a dot.
The browser shows ERR_TOO_MANY_REDIRECTSThe redirect target (for example /login) is caught by the same redirect rule.Limit the rule to the paths that need it (pathname.startsWith) or narrow the matcher so the target page is not included.
The matcher seems to be ignoredIts value is a variable or a computed result, while the matcher is analysed at build time, or the source does not start with a slash.Write the matcher as a literal constant string that starts with a slash.
The address in the browser changes when you wanted it to stayYou used NextResponse.redirect.Use NextResponse.rewrite so the content comes from another URL while the address stays.
A header from middleware is not readable in the page, or shows up in the response to the clientThe direction is swapped: the headers option on next sends to the client, not to the page.Use next with a request option holding headers to forward upstream, and response.headers.set for headers meant for the client.
The page is protected, but a Server Action or a path excluded by the matcher is still reachableA matcher that excludes a path also skips Server Functions on that path, and a cookie check is only optimistic.Verify authentication and authorisation inside every Server Action and Route Handler, as close to the data as possible.
After moving to Next.js 16, the docs say the middleware convention is deprecatedThe middleware file convention was renamed to proxy.Run the official middleware-to-proxy codemod through npx @next/codemod@canary in the project folder. It renames the file and the function.

Checklist before you deploy

  • The file is named middleware.ts and sits next to the app folder (proxy.ts in Next.js 16).
  • The matcher uses constants and excludes api, _next and paths with an extension.
  • Redirects apply only to paths that need them, and the target page is not redirected itself.
  • The cookie check is treated as optimistic; real authorisation lives near the data and in every Server Action.
  • Headers for the page are forwarded through the request option, and not every request header is copied.
  • Every case was tested with curl: ordinary page, API, static file, redirect and rewrite.

Go deeper

You now have a middleware.ts that adds a header, selects requests with a matcher, redirects, rewrites and can be tested with curl. To understand why the execution order is what it is, how this site reads the Accept header to serve Markdown at the same URL, and why Vary: Accept matters, continue with the free lesson below.

Next.js Middleware Tutorial: Matcher, Rewrites and Accept

That lesson walks through this site's middleware.ts line by line, with an interactive simulation and a quiz at the end.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is middleware in Next.js and when should I use it?

Middleware is a function in a middleware.ts file that runs on the server before a request is completed, and it can rewrite, redirect, change headers or respond directly. It suits cross-cutting decisions such as redirects, logging and optimistic login checks. The Next.js docs say to treat it as a last resort, so try next.config or a route first.

Why is my middleware.ts not running in Next.js?

The most common cause is the location: the file must sit in the project root next to the app folder, or inside src if you use it, not inside app. Also check the file name and that you export a function named middleware or a default export. If a matcher is set, confirm the URL you test is actually matched by it.

What is the difference between rewrite and redirect in Next.js middleware?

A redirect sends the client to another URL, so the address in the browser changes, and NextResponse.redirect uses 307 by default. A rewrite displays the content of another URL while the address stays the same. Use a redirect when the address must move and a rewrite when only the content should come from elsewhere.

Is middleware enough to protect a dashboard page behind a login?

No. A cookie check in middleware is only optimistic, because a fake cookie with the right name passes. The Next.js authentication docs say middleware should not be your only line of defence and that real checks belong close to the data, including inside every Server Action and Route Handler.

Has middleware been replaced by proxy in Next.js 16?

Yes, the Next.js 16 docs say the middleware file convention is deprecated and renamed to proxy, so the file becomes proxy.ts and the function is named proxy. The ideas, including the matcher, redirects and rewrites, stay the same. A codemod, middleware-to-proxy, renames the file and the function for you.