Next.js Middleware Tutorial: Matcher, Rewrites and Accept
Learn Next.js middleware with a real file: the matcher, rewrites vs redirects, and Accept header content negotiation that serves Markdown at the same URL.
By Matthews WongReviewed on
Key takeaway
Next.js middleware is a function that runs on the server before a route is rendered, so it can rewrite, redirect or answer a request. A matcher decides which requests reach it. This site uses one to read the Accept header and quietly rewrite agents that ask for Markdown to a separate route, at the same URL.
You will be able to
- Write a matcher that skips static files and API routes.
- Choose between a rewrite and a redirect, and say what the client sees.
- Serve two representations at one URL with the Accept header, and know why Vary matters.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
One URL, two answers: HTML or Markdown
A request passes the matcher into middleware, which reads Accept: a browser continues to next-intl and the HTML page, while text/markdown is rewritten to app/agent-md, which answers with Vary: Accept.
Keys: ← → step · Home / End · K play or pause
One URL, two audiences. Middleware sits in front of every page, reads the request, and decides which of two routes answers it.
The matcher skips api, _next and any path with a dot. A page path like /en/about/ passes, so middleware runs before anything renders.
matcher: ["/((?!api|_next|.*\\..*).*)"],A browser names text/html and lists a wildcard at q=0.8. Markdown is never named, so middleware hands the request to next-intl and the normal page.
const response = intlMiddleware(request);Predict the next step Next, an agent sends Accept: text/markdown. Does its address change? (Yes, it is redirected to /agent-md · No, it is rewritten and keeps its URL)Markdown is named and ranks at least as high as HTML, so middleware calls NextResponse.rewrite to app/agent-md. The client's URL never changes and no locale redirect happens.
return NextResponse.rewrite(url);Inside the rewritten route, request.nextUrl still holds the URL the client asked for. The handler reads its path from its own params instead.
const page = resolvePageMarkdown(requested);Predict the next step The route replies in Markdown at the page's own URL. Which header lets a cache tell it from HTML? (Vary: Accept · Content-Language · X-Content-Type-Options)The reply is text/markdown with Vary: Accept, so a shared cache keys on Accept instead of serving the Markdown to a browser at the same URL.
Vary: "Accept",
What is Next.js middleware, and when does it run?
Middleware is a file at the project root, next to the app folder, that exports one function. Next.js runs it on the server before a request is completed, and it can rewrite the request, redirect it, change headers, or respond directly. The docs state it plainly: it executes before routes are rendered, which makes it the right place for cross-cutting decisions such as authentication, logging and redirects.
Read more (2)Show less
Order matters. The docs list it: headers and redirects from next.config come first, then middleware, then the beforeFiles rewrites, then the filesystem routes such as app and public, then afterFiles rewrites, dynamic routes and finally fallback rewrites. So a rewrite in middleware happens before Next.js looks for the page.
A naming note. This repository runs Next.js 15.5, where the file is middleware.ts. The current docs say Next.js 16 deprecated that convention and renamed it proxy, with a codemod to migrate and the same ideas underneath. They also say middleware should be a last resort, so reach for it when a next.config rewrite or a route cannot do the job.
How does the matcher decide which requests run it?
Without a matcher, middleware runs on every request, including static files, image optimisation and everything in public. The docs warn that logic such as authentication or redirects can then block CSS, JS or images. The fix is a config export with a matcher: a path, an array of paths, or a regular expression.
Read more (1)Show less
This site wants every page but not the API, Next.js internals or files with an extension, so it uses a negative lookahead: everything except paths starting with api or _next, and except any path containing a dot. That last clause is what keeps robots.txt, sitemap.xml and images out. The docs add rules worth remembering: a source must start with a slash, patterns are anchored to the start of the path, and matcher values must be constants because they are analysed at build time.
Rewrite or redirect: what does the client see?
NextResponse offers both. A redirect sends the request to a different URL. A rewrite displays the content of a given URL in place of the one requested. The docs name two ways to produce a response from middleware: rewrite to a route, either a page or a route handler, or return a NextResponse directly.
Read more (1)Show less
That is why this site rewrites instead of redirecting. A Markdown request is rewritten to app/agent-md, a route handler that renders the page's Markdown from the same data the HTML uses. The agent keeps the URL it asked for and gets a 200 there, with no locale redirect in between, because the rewrite runs before the next-intl middleware.
How does the Accept header choose HTML or Markdown?
This is server-driven content negotiation. MDN describes it as the client sending headers that describe its preferences, with the server using them as hints; the selection algorithm is up to the server. The Accept header lists the media types the client will process, each with an optional quality factor q that ranks them.
Read more (2)Show less
RFC 9110 says recipients should treat a parameter named q as the weight, and that when more than one media range applies to a type, the most specific reference has precedence. The repository's prefersMarkdown follows both. It parses every range with its q, looks for text/markdown, and compares it with the quality the client gives text/html, using the most specific matching range.
Then it adds a safety rule of its own: Markdown must be named explicitly, so a wildcard never selects it. A browser's usual header names text/html and puts the any-type wildcard at q=0.8, so browsers and bare HTTP clients keep getting HTML. A tie goes to Markdown, because the client named it.
Why does Vary: Accept matter, and where can you set it?
The same URL now has two possible bodies. MDN explains that the server's Vary header lists the request headers it used to choose, so a cache can reproduce the decision. RFC 9110 goes further: a cache must not reuse a stored response for a later request unless the listed header fields match, or the origin validates the reuse. Without Vary: Accept, a cache could serve Markdown to a browser.
Read more (1)Show less
In this repo the route handler sends Vary: Accept on the Markdown reply, and middleware appends it to next-intl's responses. A rendered HTML page cannot carry it, though. The code comment records that Next's app page handler replaces the Vary header at render time, and the handler in node_modules/next (15.5.7) does call setHeader with its own value. Nothing caches in front of the site today, so the plan is to add the header in nginx if a shared cache ever appears.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
export default function middleware(request: NextRequest): NextResponse {
if (prefersMarkdown(request.headers.get("accept"))) {
const url = request.nextUrl.clone();
url.pathname = `${MARKDOWN_ROUTE}${request.nextUrl.pathname}`;
return NextResponse.rewrite(url);
}
const response = intlMiddleware(request);
response.headers.append("Vary", "Accept");
return response;
}
export const config = {The whole decision is the first branch. Markdown is rewritten to the internal route and returns at once, before next-intl can add a locale redirect. Everything else goes through next-intl, and the file appends Vary: Accept to that response too.
// Match all pathnames except for
// - API routes
// - Static files
// - _next (Next.js internals)
matcher: ["/((?!api|_next|.*\\..*).*)"],A negative lookahead: match every path except those that start with api or _next, or that contain a dot. The doubled backslashes are one escaped dot in a JavaScript string. This is the clause that keeps files like sitemap.xml away from the function.
// From the route's own segments: after a middleware rewrite,
// request.nextUrl still holds the URL the client asked for.
const { path = [] } = await params;
const requested = path.length ? `/${path.join("/")}/` : "/";
const page = resolvePageMarkdown(requested);The trap in one comment. After the rewrite the handler could be tempted to read request.nextUrl, but that is still the original URL. It builds the requested path from params, the route's own segments, so it renders the page that was asked for.
export function prefersMarkdown(accept: string | null): boolean {
const ranges = parseAccept(accept);
const markdownQ = Math.max(
0,
...ranges
.filter((range) => MARKDOWN_ALIASES.has(`${range.type}/${range.subtype}`))
.map((range) => range.q),
);
if (markdownQ === 0) return false;
return markdownQ >= qualityFor(ranges, HTML_TYPE);
}Only an explicit text/markdown range counts, so a wildcard never selects it. Markdown wins when its q is at least what the client gives HTML, which makes a tie go to Markdown.
Mistakes that cost real time
No matcher, or one that is too wide
Without a matcher the function runs on every request, including static files and images, so a rewrite or redirect can break CSS, JS or pictures. Add a negative lookahead for api, _next and files with an extension, and keep matcher values constant.
A wildcard Accept selects the wrong representation
If your check treats star-slash-star as a match, every browser and curl call gets Markdown. Require the type to be named, and test the exact headers browsers send, a bare star-slash-star, and a q=0 case.
Reading request.nextUrl in the rewritten route
This repo records that after a rewrite request.nextUrl still holds the URL the client asked for. A handler that uses it never sees its own route's path. Read the path from params instead.
Expecting Vary on a page to survive
A header you set in middleware on a rendered HTML page can be replaced when the page renders, since Next's page handler sets Vary itself. Set it on responses you own, such as a route handler, or add it at the proxy in front of the app.
Do it on your own machine
Run the site locally and watch one URL answer twice.
Start the dev server, then ask for a page the way a browser does and read the content type.
npm run dev curl -s -o /dev/null -D - -H "Accept: text/html" http://localhost:3000/en/about/Ask for the same URL with Accept: text/markdown. The content type and the Vary header change, and the URL does not.
curl -s -D - -H "Accept: text/markdown" http://localhost:3000/en/about/ | head -20Send a browser-style header with a wildcard and confirm it still returns HTML, then set q=0 on Markdown.
curl -s -o /dev/null -D - -H "Accept: text/html,*/*;q=0.8" http://localhost:3000/en/about/ curl -s -o /dev/null -D - -H "Accept: text/markdown;q=0" http://localhost:3000/en/about/Run the negotiation tests that pin every one of those cases.
node --test tests/agent-readiness.test.mjs
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why can't you rely on middleware to set Vary on an HTML page here?Show the answer
Answer:Next's app page handler sets the Vary header itself when the page renders, replacing the value. Set it on a route handler you own, or at the proxy in front of the app if a shared cache is added.
2.In the Markdown route, where do you read the requested path from, and why?Show the answer
Answer:From the route's own params. After a middleware rewrite, request.nextUrl still holds the URL the client asked for, as the comment in the handler records.
3.What changes when you move this project from Next.js 15 to 16?Show the answer
Answer:The docs say the middleware file convention is deprecated and renamed proxy: the file becomes proxy.ts and the function is named proxy, and a codemod does the rename. The matcher and rewrite ideas carry over.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.