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/dashboardThe 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/promoThe 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.
| Symptom | Cause | Fix |
|---|---|---|
| Middleware does not run: the x-hello-from-middleware header is missing | The 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 too | There 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_REDIRECTS | The 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 ignored | Its 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 stay | You 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 client | The 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 reachable | A 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 deprecated | The 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.

