
Next.js Proxy
Every request that hits your Next.js app has to travel through routing, rendering, and eventually a response before it reaches the browser. Most of the time you don't need to touch that pipeline at all — you write a page, Next.js renders it, done. But sometimes you need to intercept a request before any of that happens: redirect a logged-out user away from a dashboard, rewrite /blog/old-slug to /blog/new-slug without a client-side redirect, attach a request ID header for tracing, or reject a request outright based on a cookie. That's the job Proxy exists to do.
If you've used Next.js before version 16, you know this feature by a different name: Middleware. Starting with Next.js 16, the middleware.ts file convention was renamed to proxy.ts. The underlying mechanism didn't change — same single file, same NextRequest/NextResponse API, same execution point in the request lifecycle — but the name did, and for good reason. This article covers what Proxy does, why the rename happened, and how to use it correctly, including a few things the docs mention only in passing that will save you a debugging session later.
What Proxy Actually Does
Proxy runs on the server, before a request reaches a route. It sits in front of your rendering pipeline and gets first look at every incoming request that matches its configuration. Based on that request, your Proxy function can do one of a few things: let the request through unchanged, rewrite it to a different internal path, redirect the client to a different URL entirely, modify request or response headers, or short-circuit the whole thing and return a response directly.
That gives you a small but powerful toolkit for cross-cutting concerns — logic that doesn't belong to any single page, but needs to run for many of them. The official use cases the docs call out are:
- Modifying headers for all pages or a subset of pages
- Rewriting to different pages based on A/B tests or experiments
- Programmatic redirects based on properties of the incoming request (a cookie, a header, a query parameter)
Notice what's not on that list: full authentication and session management. This is worth taking seriously, because it's the single most common way people misuse Proxy. It's tempting to put all of your auth logic in one file that guards your entire app, and on the surface that looks clean. In practice, Proxy is meant for fast, cheap checks — an "optimistic" pass at permission-based redirects, as the docs put it — not a substitute for verifying permissions inside the route or Server Function itself. We'll come back to exactly why that distinction matters when we talk about execution order.
Proxy is also explicitly not meant for slow data fetching. If you use fetch inside a Proxy function, the cache, next.revalidate, and next.tags options have no effect — none of Next.js's data cache machinery applies here. If your Proxy needs to check something against a database or a remote API, keep that check as fast as you possibly can, because it runs synchronously in front of every matched request, and a slow Proxy makes your whole site feel slow.
If all you need is a static redirect — /old-path always goes to /new-path, with no logic involved — reach for the redirects option in next.config.ts instead. It's declarative, it's resolved earlier in the pipeline, and it doesn't require running a JavaScript function on every request. Save Proxy for cases where you genuinely need access to request data (headers, cookies, the URL) or conditional logic that a static config file can't express.
From Middleware to Proxy: What Changed and Why
The rename isn't cosmetic marketing — it's a genuine attempt to fix a naming problem that caused real confusion. "Middleware" is a term borrowed from Express.js and similar server frameworks, where middleware functions form a chain that every request passes through, each one able to inspect, modify, or terminate the request. Next.js's Middleware worked differently enough from that model — single file, edge-oriented, request/response interception rather than a composable chain — that developers coming from an Express background regularly expected behavior it didn't have. Worse, the name encouraged people to treat it as a general-purpose place to put any server logic, when the Next.js team's own guidance was the opposite: use it only when nothing else fits, because it's a blunt instrument that runs in front of everything.
"Proxy" describes the actual behavior more accurately. A proxy sits in front of your application as a network boundary, intercepting and potentially altering traffic before it reaches the real destination. That's precisely what this feature does — it can even run outside your app's main runtime and be deployed to a CDN for fast redirect and rewrite handling in some deployment setups. The new name sets the right expectations from the start.
Functionally, nothing changed. If you have an existing middleware.ts file, Next.js ships a codemod to migrate it automatically:
npx @next/codemod@canary middleware-to-proxy .
This renames the file to proxy.ts and renames the exported function from middleware to proxy:
// middleware.ts -> proxy.ts
- export function middleware() {
+ export function proxy() {
Everything else — the NextRequest/NextResponse API, the config.matcher option, the execution order relative to next.config.js rewrites and redirects — carries over untouched. If you're maintaining an older codebase or reading older tutorials, mentally substitute "Proxy" for "Middleware" and the two are interchangeable in behavior.
Creating Your First Proxy File
The convention is simple: create a proxy.ts (or .js) file at the root of your project, at the same level as your app or pages directory. If your project uses a src directory, put it inside src, next to app.
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/home", request.url));
}
export const config = {
matcher: "/about/:path*",
};
You can export the function either as a named proxy export or as a default export — both work identically. Only one Proxy file is supported per project, which is a deliberate constraint. It prevents the kind of tangled, order-dependent chain of interceptors that made classic Express middleware hard to reason about. If your Proxy logic grows complicated, don't try to work around the single-file rule — instead, break the logic into separate modules (an auth-proxy.ts, a geo-proxy.ts, whatever makes sense) and import and compose them inside your one proxy.ts entry point. You get modularity in your own code without Next.js having to guess at the order multiple proxy files should run in.
If you've customized pageExtensions in your next.config.js — say, to .page.ts — you'll need to name the file proxy.page.ts to match, or Next.js won't pick it up.
The Two Parameters: request and event
Next.js calls your Proxy function with up to two arguments, in this order: request, then event. You only need to declare the ones you actually use.
request is an instance of NextRequest, an extension of the standard Web Request object. It's where you read the incoming URL, headers, and cookies.
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
console.log(request.nextUrl.pathname);
}
event is an instance of NextFetchEvent, and its main use is the waitUntil() method — a way to schedule background work that should complete even after your Proxy has already sent its response back. This is exactly what you want for fire-and-forget logging or analytics calls that shouldn't block the response:
import type { NextFetchEvent, NextRequest } from "next/server";
export function proxy(request: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch("https://example.com/log", {
method: "POST",
body: JSON.stringify({ pathname: request.nextUrl.pathname }),
}),
);
}
Without waitUntil(), there's no guarantee that fetch call finishes before the runtime tears down the invocation — you'd be relying on luck. waitUntil() explicitly extends the lifetime of the Proxy invocation until the promise it's given settles.
If you'd rather not write out both types by hand, Next.js also exports a NextProxy type that infers both parameter types for you:
import type { NextProxy } from "next/server";
export const proxy: NextProxy = (request, event) => {
event.waitUntil(Promise.resolve());
return Response.json({ pathname: request.nextUrl.pathname });
};
It's a small convenience, but worth knowing about if you're writing Proxy logic in a shared module and want the parameter types to line up automatically with whatever Next.js version you're on.
Controlling Where Proxy Runs: the matcher Config
This is the part of Proxy that's easy to get wrong in a way that quietly breaks your site. Without a matcher, your Proxy function runs on every single request — including static assets served from _next/static, image optimization requests through _next/image, and anything sitting in your public/ folder. If your Proxy contains auth logic that redirects unauthenticated users, and you forget to exclude static assets, you can end up redirecting the browser's request for your own CSS and JS bundles, breaking your site's styling and interactivity for anyone who isn't logged in.
The matcher option, exported alongside your Proxy function as part of a config object, is how you scope it down:
export const config = {
matcher: "/about/:path*",
};
You can match multiple paths with an array:
export const config = {
matcher: ["/about/:path*", "/dashboard/:path*"],
};
Path patterns follow a small set of rules, borrowed from path-to-regexp:
- A pattern must start with
/. - Named parameters are supported:
/about/:pathmatches/about/aand/about/b, but not the nested/about/a/c. - Modifiers change how greedy a named parameter is:
*means zero or more segments (/about/:path*matches/about/a/b/c),?means zero or one, and+means one or more. - You can drop down to a full regular expression in parentheses:
/about/(.*)behaves the same as/about/:path*. - Patterns are anchored to the start of the path, so
/aboutmatches/aboutand/about/team, but not/blog/about.
For more surgical control, you can exclude paths with a negative lookahead instead of listing every path you do want to match:
export const config = {
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
This is the pattern you'll reach for most often in real projects — it says "run on everything except API routes, static build assets, optimized images, and PNG files." A good default negative matcher, adapted directly from Next.js's own documentation, looks like this:
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
Start every new Proxy file from something like this, then narrow it further, rather than starting with no matcher at all and hoping you remember every exclusion later.
For genuinely advanced routing needs, matcher also accepts an array of objects, each with a source pattern plus optional has and missing conditions that check for the presence or absence of specific headers, cookies, or query parameters:
export const config = {
matcher: [
{
source: "/api/:path*",
locale: false,
has: [
{ type: "header", key: "Authorization", value: "Bearer Token" },
{ type: "query", key: "userId", value: "123" },
],
missing: [{ type: "cookie", key: "session", value: "active" }],
},
],
};
One constraint that trips people up: matcher values must be statically analyzable at build time. You cannot build the matcher array dynamically from an environment variable or a runtime computation — Next.js needs to read it as a literal at build time, so dynamic values are silently ignored. If you need conditional behavior, put the condition inside your Proxy function body, and use the matcher only to define the outer boundary of where it runs at all.
There's also a subtle security-relevant detail buried in the docs: even if you exclude _next/data in a negative matcher pattern, Proxy will still be invoked for _next/data routes. This is intentional — it exists specifically to prevent the scenario where you protect a page's HTML but forget that its corresponding data route needs the same protection. Don't fight this behavior; it's there to save you from a real vulnerability class.
Producing a Response
Once your Proxy function decides what to do with a request, it needs to actually produce an outcome. There are a few shapes this can take.
Rewriting changes what content is served without changing the URL the browser sees. This is the classic A/B testing or feature-flagging use case:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/about")) {
return NextResponse.rewrite(new URL("/about-2", request.url));
}
if (request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.rewrite(new URL("/dashboard/user", request.url));
}
}
Redirecting sends the browser to a different URL entirely, changing the address bar:
return NextResponse.redirect(new URL("/home", request.url));
Passing through unchanged is done with NextResponse.next() — you'll use this constantly, either as an explicit pass-through or as the base object you attach modified headers or cookies to before returning it.
Responding directly, without ever reaching a page or Route Handler, has been possible since Next.js 13.1. This is the right tool for rejecting a request outright — an API check that fails authentication, for example:
import type { NextRequest } from "next/server";
import { isAuthenticated } from "@lib/auth";
export const config = {
matcher: "/api/:function*",
};
export function proxy(request: NextRequest) {
if (!isAuthenticated(request)) {
return Response.json(
{ success: false, message: "authentication failed" },
{ status: 401 },
);
}
}
Notice this example uses the plain Web Response.json() rather than NextResponse — both work for producing a response directly, and for a simple redirect you can even use Response.redirect instead of NextResponse.redirect. NextResponse only becomes necessary once you need Next.js-specific behavior like rewrite() or the convenience cookie API.
Working with Headers
Setting headers in Proxy has a wrinkle that's easy to miss: there's a real difference between headers meant for your own app to read further down the pipeline, and headers meant for the client.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-hello-from-proxy1", "hello");
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set("x-hello-from-proxy2", "hello");
return response;
}
NextResponse.next({ request: { headers: requestHeaders } }) makes those headers available to your route as it continues processing the request — this is how you pass computed information (a decoded user ID, a feature flag value) from Proxy into a Server Component or Route Handler without a shared database round trip. Calling NextResponse.next({ headers: requestHeaders }) instead — without the nested request key — sends those headers to the client in the response, which is a completely different thing and a common copy-paste mistake. If your downstream route isn't seeing a header you set in Proxy, check for this exact typo first.
Watch header size too. Setting large headers can trigger a 431 Request Header Fields Too Large error depending on your backend server configuration — this is easy to hit if you're passing something like a full JWT or a serialized object through a header rather than a cookie or a lookup.
There's also a good-to-know detail specific to the App Router's React Server Component requests: during an RSC request, Next.js strips internal "Flight" headers — things like rsc, next-router-state-tree, and next-router-prefetch — from what request.headers exposes inside Proxy. This is intentional, to stop you from accidentally treating an RSC navigation request differently from a full HTML request when they need to stay aligned. If you use NextResponse.rewrite(), Next.js automatically forwards the RSC headers your rewrite needs. But if you implement your own rewrite logic manually with fetch() instead of NextResponse.rewrite(), you can lose those headers unless you forward them yourself — and in that case, enabling skipProxyUrlNormalize in next.config.js gives your custom logic the raw URL shape and headers it needs.
Working with Cookies
Cookies are just headers under the hood — Cookie on the request, Set-Cookie on the response — but Next.js gives you a much friendlier API for reading and writing them than parsing raw header strings yourself.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// Assume "Cookie: nextjs=fast" on the incoming request
let cookie = request.cookies.get("nextjs");
console.log(cookie); // { name: 'nextjs', value: 'fast', Path: '/' }
const allCookies = request.cookies.getAll();
request.cookies.has("nextjs"); // true
request.cookies.delete("nextjs");
request.cookies.has("nextjs"); // false
const response = NextResponse.next();
response.cookies.set("vercel", "fast");
response.cookies.set({ name: "vercel", value: "fast", path: "/" });
cookie = response.cookies.get("vercel");
// Outgoing response now carries `Set-Cookie: vercel=fast; path=/`
return response;
}
Incoming request cookies support get, getAll, has, delete, and clear (to remove everything at once). Outgoing response cookies support get, getAll, set, and delete. This is the natural place to implement something like reading a session cookie, checking a lightweight signal from it (not a full re-authentication against your database — remember, keep Proxy fast), and setting a rotated or refreshed cookie on the way back out.
Handling CORS in Proxy
If you're building an API surface inside your Next.js app that other origins need to call, Proxy is a reasonable place to centralize CORS handling rather than repeating header logic in every Route Handler:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const allowedOrigins = ["https://acme.com", "https://my-app.org"];
const corsOptions = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export function proxy(request: NextRequest) {
const origin = request.headers.get("origin") ?? "";
const isAllowedOrigin = allowedOrigins.includes(origin);
const isPreflight = request.method === "OPTIONS";
if (isPreflight) {
const preflightHeaders = {
...(isAllowedOrigin && { "Access-Control-Allow-Origin": origin }),
...corsOptions,
};
return NextResponse.json({}, { headers: preflightHeaders });
}
const response = NextResponse.next();
if (isAllowedOrigin) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
export const config = {
matcher: "/api/:path*",
};
This handles both simple requests and the OPTIONS preflight that browsers send ahead of "complex" cross-origin requests. If you only need CORS on a handful of specific routes rather than a whole API surface, it's often simpler to configure headers per-route inside the Route Handler itself instead — Proxy is the right call when the same CORS policy needs to apply broadly and consistently.
Execution Order: Where Proxy Actually Fits
Understanding exactly when Proxy runs relative to everything else in next.config.js matters more than it might seem, especially once you start combining Proxy with rewrites and redirects defined in config. The order is:
headersfromnext.config.jsredirectsfromnext.config.js- Proxy (
rewrites,redirects, etc., defined in your Proxy function) beforeFilesrewrites fromnext.config.js- Filesystem routes (
public/,_next/static/,pages/,app/, etc.) afterFilesrewrites fromnext.config.js- Dynamic routes (
/blog/[slug]) fallbackrewrites fromnext.config.js
Two things to take away from this. First, static next.config.js redirects and headers run before Proxy — so if you're debugging why a redirect isn't taking effect, check your config file's redirects() first, since it may already be resolving the request before your Proxy ever sees it. Second, and more important: Server Functions (Server Actions) are not separate routes in this chain. They're dispatched as POST requests to whatever route they're called from. That means a Proxy matcher that excludes a given path will also silently skip any Server Function invoked from that path.
This is the sharpest edge in the whole feature, and it's exactly why the docs — and this article — keep repeating that Proxy should never be your only line of defense for authorization. Picture this scenario: you protect /dashboard with a Proxy check that redirects unauthenticated users, but you exclude /dashboard from your matcher for some unrelated reason, or a refactor moves a sensitive Server Function to a route your matcher doesn't cover. The Server Function itself has no idea Proxy was ever supposed to be guarding it — it will happily execute for anyone who can reach it directly. The only reliable fix is to verify authentication and authorization inside the Server Function or Route Handler itself, treating Proxy's checks as a fast, optimistic first pass rather than the actual security boundary.
Runtime and Platform Support
Proxy defaults to the Node.js runtime. This is itself a relatively recent shift — earlier versions of Middleware ran on a restricted Edge runtime by default, which meant you couldn't use full Node.js APIs or many npm packages inside it. As of Next.js 15.5, the Node.js runtime became stable for this feature, and Proxy inherits that. One consequence: the runtime route segment config option, which you might use elsewhere to force Edge behavior on a route, is not available for Proxy files — setting it there throws a build error.
Deployment support varies by target:
| Deployment Option | Supported |
|---|---|
| Node.js server | Yes |
| Docker container | Yes |
| Static export | No |
| Adapters (third-party platforms) | Platform-specific |
If you're building toward a static export — no server at request time at all — Proxy simply isn't available to you, for the obvious reason that there's no server process to run it on. If you're deploying through a third-party adapter, check that platform's documentation for how it implements Proxy support, since behavior (and performance characteristics, like whether it runs at the edge or in a regional function) can differ meaningfully between providers.
Advanced Flags for Edge Cases
Two configuration flags in next.config.js, introduced back in v13.1, exist for cases where Next.js's default URL handling gets in the way of custom logic.
skipTrailingSlashRedirect disables Next.js's automatic redirect behavior for adding or removing trailing slashes, letting your own Proxy logic decide trailing-slash handling per path — useful during incremental migrations where legacy paths need different treatment than new ones:
// next.config.js
module.exports = {
skipTrailingSlashRedirect: true,
};
// proxy.ts
const legacyPrefixes = ["/docs", "/blog"];
export default async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next();
}
if (
!pathname.endsWith("/") &&
!pathname.match(/((?!\.well-known(?:\/.*)?)(?:[^/]+\/)*[^/]+\.\w+)/)
) {
return NextResponse.redirect(new URL(`${pathname}/`, req.nextUrl));
}
}
skipProxyUrlNormalize disables Next.js's URL normalization so that direct page loads and client-side navigations present the same raw URL shape to your Proxy logic. This matters mostly if you're implementing custom rewrite logic with fetch() instead of NextResponse.rewrite(), since normalization would otherwise transform something like /_next/data/build-id/hello.json into /hello before your code ever sees it. Most projects will never touch either of these flags — they exist specifically for teams building custom routing infrastructure or migrating a large legacy site incrementally.
Testing Proxy Logic
Since Next.js 15.1, there's a dedicated testing utility under next/experimental/testing/server for verifying Proxy behavior without spinning up a full server. unstable_doesProxyMatch lets you assert whether a given URL, set of headers, and cookies would actually trigger your Proxy at all — genuinely useful for catching matcher regressions before they ship:
import { unstable_doesProxyMatch } from "next/experimental/testing/server";
expect(
unstable_doesProxyMatch({
config,
nextConfig,
url: "/test",
}),
).toEqual(false);
You can also invoke and assert against the whole function's behavior directly:
import { isRewrite, getRewrittenUrl } from "next/experimental/testing/server";
const request = new NextRequest("https://nextjs.org/docs");
const response = await proxy(request);
expect(isRewrite(response)).toEqual(true);
expect(getRewrittenUrl(response)).toEqual("https://other-domain.com/docs");
Given how easy it is to accidentally exclude or include the wrong paths in a matcher — and how invisible that mistake is until someone hits the broken case in production — it's worth writing at least a handful of tests against your matcher configuration on any project where Proxy guards something security-relevant.
Common Mistakes and Practical Notes
A few things worth internalizing that don't come through clearly from the reference docs alone:
Every unmatched request still costs something. Even a Proxy that returns NextResponse.next() immediately still runs your function for every matched request. If your matcher is too broad — or missing entirely — you're paying that cost on every static asset request too. Start with a tight negative matcher and loosen it deliberately, not the other way around.
Proxy is not the place for your source of truth on authorization. Treat it strictly as an optimistic, fast-path check — redirecting an obviously logged-out user away from /dashboard before it even renders is a good user-experience win. Relying on it as the only check protecting sensitive data or actions is the mistake that eventually bites people, especially once Server Functions enter the picture, since a matcher gap silently strips Proxy's coverage from a POST endpint that looks, from the outside, like it should still be protected.
Don't reach for Proxy first. Given how broad its blast radius is — one file, potentially every request — the Next.js team's own recommendation is to look for a narrower tool first: next.config.js redirects for static redirects, per-route logic for anything that's genuinely route-specific, and Proxy only for logic that truly needs to run across many routes based on request-level data.
waitUntil() is easy to forget and easy to misuse. It's the right tool for logging or analytics you don't want to block the response on, but it's not a queue — if your background work is expensive or unreliable, a proper task queue or webhook is a better fit than stacking work inside waitUntil() calls in Proxy.
Key Takeaways
Proxy is the modern name for what used to be Middleware — same mechanism, same API, clearer intent behind the name. It's a single file that intercepts requests before they're rendered, giving you the ability to rewrite, redirect, adjust headers and cookies, or respond directly, scoped down precisely with the matcher config so it doesn't run where it shouldn't.
| Scenario | What to use |
|---|---|
| Static, unconditional redirect | redirects in next.config.js, not Proxy |
| Redirect based on request data (cookie, header) | Proxy with NextResponse.redirect() |
| Serve different content at the same URL | Proxy with NextResponse.rewrite() |
| Pass computed data to a downstream route | NextResponse.next({ request: { headers } }) |
| Reject a request outright (e.g. failed auth) | Return Response.json() or NextResponse.json() directly |
| Background logging/analytics | event.waitUntil(promise) |
| Excluding static assets and API routes | A negative-lookahead matcher pattern |
Migrating from middleware.ts | npx @next/codemod@canary middleware-to-proxy . |
Get the matcher right, keep the logic inside it fast, and never treat it as your only line of defense for anything security-critical, and Proxy becomes exactly what it's meant to be: a small, sharp tool for the handful of cases where you genuinely need to intercept a request before your app ever gets to render it.


