
Next.js proxy.js
If you've worked with an older Next.js codebase, you know this file by a different name: middleware.ts. As of Next.js 16, that convention is deprecated and renamed to proxy.ts — not a cosmetic rename, but a deliberate correction of what the feature is actually for and what it should be encouraged to do. This reference covers the file's full API surface as it exists today, under its current name, with the rename's rationale and the migration path folded in rather than treated as a footnote.
What Proxy Actually Does
proxy.js|ts runs server-side code before a request is completed — before routing, before rendering. Based on the incoming request, you can rewrite it, redirect it, modify request or response headers, or respond directly without ever reaching your application's normal rendering path.
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*",
};
One structural warning worth internalizing before writing anything real here: Proxy is meant to be invoked separately from your render code, and in optimized deployments may run at the CDN edge rather than inside your application's main runtime. Don't rely on shared modules or globals between Proxy and the rest of your app — treat it as its own isolated execution context. To pass information forward into your application, use headers, cookies, rewrites, redirects, or the URL itself — not module-level state.
Place proxy.ts (or .js) at the project root, alongside pages/app — or inside src if that's your layout. If you've customized pageExtensions (say, to .page.ts), name the file proxy.page.ts to match.
Exports
The Proxy Function
Export a single function, either as the default export or named proxy. Multiple proxy functions from the same file aren't supported — there's exactly one entry point per file.
export default function proxy(request) {
// Proxy logic
}
The Config Object (Optional)
An optional config export controls where the function runs, primarily via matcher.
matcher — Precisely Targeting Where Proxy Runs
Without a matcher, Proxy runs on every single request — including static files under _next/static, image optimization requests under _next/image, and everything in your public/ folder. That's a common footgun: auth logic without a matcher can unintentionally block your own CSS, JS, and images from loading, since Proxy intercepts those requests too.
You can target paths several ways. A single string:
matcher: "/about";
An array for multiple paths:
matcher: ["/about", "/contact"];
Or a full regular expression for more complex targeting, including negative matches:
export const config = {
matcher: [
// Exclude API routes, static files, image optimizations, and .png files
"/((?!api|_next/static|_next/image|.*\\.png$).*)",
],
};
For finer control than path patterns alone, matcher accepts an array of objects with source, and optional locale, has, and missing keys:
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" }],
},
],
};
The source path syntax follows the same conventions as Next.js redirects/rewrites: it must start with /, supports named parameters (/about/:path), supports modifiers on those parameters (* for zero-or-more, ? for zero-or-one, + for one-or-more), supports parenthesized regex, and is always anchored to the start of the path.
Two easy-to-miss rules: matcher values must be constants the build can statically analyze — a variable computed at runtime is silently ignored, not evaluated. And for backward compatibility, Next.js always treats /public as /public/index, so a matcher targeting /public/:path will match accordingly.
Parameters
Next.js invokes the proxy function with two arguments, in order — declare only the ones you actually use.
request
A NextRequest instance — an extension of the Web Request API with convenience additions like cookies and a parsed nextUrl object.
event
A NextFetchEvent instance, exposing one method: waitUntil(promise). This keeps the Proxy invocation alive until the given promise settles, so background work — logging, analytics — can finish even after the response has already been sent to the client:
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 }),
}),
);
}
If you want the shorthand, the NextProxy type infers both parameter types automatically:
import type { NextProxy } from "next/server";
export const proxy: NextProxy = (request, event) => {
event.waitUntil(Promise.resolve());
return Response.json({ pathname: request.nextUrl.pathname });
};
Producing a Response
You have two options: rewrite to a route (a Page or Route Handler) that produces the response, or return a NextResponse (or plain Response) directly:
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 },
);
}
}
Execution Order
This is worth memorizing precisely, since it determines what actually reaches your Proxy function and in what shape:
headersfromnext.config.jsredirectsfromnext.config.js- Proxy (rewrites, redirects, etc.)
beforeFilesrewrites fromnext.config.js- Filesystem routes (
public/,_next/static/,pages/,app/, etc.) afterFilesrewrites fromnext.config.js- Dynamic Routes (
/blog/[slug]) fallbackrewrites fromnext.config.js
One subtlety that genuinely causes production incidents: Server Functions aren't separate routes in this chain. They're handled as POST requests to whatever route they're used from — meaning a Proxy matcher that excludes a given path also silently skips Server Function calls made from that path. A matcher change, or a refactor that moves a Server Function to a different route, can quietly remove Proxy coverage without any error surfacing. The recommended defense: verify authentication and authorization inside each Server Function itself, rather than trusting Proxy alone to gate access to it.
Runtime
Proxy defaults to the Node.js runtime as of v16. The runtime route-segment config option is explicitly unavailable inside Proxy files — attempting to set it there throws an error, rather than silently being ignored.
Advanced Flags
Two flags handle edge cases that most apps never need but that matter a great deal when you do:
skipTrailingSlashRedirect disables Next.js's automatic trailing-slash redirect handling, letting your own Proxy logic decide trailing-slash behavior per path — genuinely useful for incremental migrations where legacy paths need different trailing-slash rules than new ones.
skipProxyUrlNormalize disables Next.js's URL normalization, so direct visits and client-side transitions present the same raw URL shape to your Proxy logic — useful for advanced rewrite setups that need to see the actual requested path (like /_next/data/build-id/hello.json) rather than the normalized equivalent.
Cookies and Headers
Cookies are just headers under the hood — Cookie on the request, Set-Cookie on the response — and NextRequest/NextResponse wrap them in a convenient cookies API with get, getAll, set, delete, has, and clear.
For headers specifically, there's a detail that's easy to get backward: to make a modified header available upstream to your rendering code, pass it through NextResponse.next({ request: { headers: requestHeaders } }) — not NextResponse.next({ headers: requestHeaders }), which instead makes it available to the client, a meaningfully different and frequently confused outcome.
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;
RSC Requests Need Special Care
During RSC requests, Next.js strips internal Flight headers (rsc, next-router-state-tree, next-router-prefetch) from what your Proxy code sees on request.headers — deliberately, to prevent you from accidentally handling an RSC request differently than its corresponding HTML request when the two need to stay aligned. NextResponse.rewrite() automatically propagates the RSC rewrite headers it needs; if you implement custom rewrite logic with raw fetch() instead, you can lose those headers unless you forward them manually — skipProxyUrlNormalize is the escape hatch if you need direct access to the original URL shape and headers in that scenario.
CORS
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) {
return NextResponse.json(
{},
{
headers: {
...(isAllowedOrigin && { "Access-Control-Allow-Origin": origin }),
...corsOptions,
},
},
);
}
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*" };
Negative Matching, and a Deliberate Exception
Full regex negative lookaheads let you match everything except specific paths:
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
Here's a deliberate design choice worth knowing: even when _next/data is excluded via a negative matcher pattern, Proxy still runs for _next/data routes anyway. This is intentional — it exists specifically to prevent a scenario where you protect a page's rendered route but forget its corresponding data route, leaving a security gap. Don't treat that exclusion in your matcher as reliably applying to the data route too.
Testing (Experimental)
next/experimental/testing/server provides unstable_doesProxyMatch to assert whether Proxy will run for a given URL/headers/cookies combination, without invoking the function itself:
import { unstable_doesProxyMatch } from "next/experimental/testing/server";
expect(unstable_doesProxyMatch({ config, nextConfig, url: "/test" })).toEqual(
false,
);
The full function can also be exercised directly, with isRewrite and getRewrittenUrl helpers to inspect the resulting response.
Platform Support
| Deployment Option | Supported |
|---|---|
| Node.js server | Yes |
| Docker container | Yes |
| Static export | No |
| Adapters | Platform-specific |
Migration to Proxy
Why "Middleware" Was the Wrong Name
The term "middleware" invited direct comparison to Express.js middleware — a comparison that misrepresented what this feature actually does and, worse, encouraged overusing it for things Next.js now provides purpose-built APIs for instead. The Next.js team's stated direction is to give developers ergonomic APIs that achieve their goals without reaching for this feature at all, reserving it as something closer to a last resort.
Why "Proxy"
"Proxy" more accurately signals what's actually happening: this code represents a network boundary sitting in front of your application. It can run outside your app's main runtime and intercept requests before they ever reach your rendering code — which is precisely what "proxy" means as a term, and precisely what "middleware" obscured.
Running the Codemod
npx @next/codemod@canary middleware-to-proxy .
This renames the file from middleware.ts to proxy.ts and updates the exported function name accordingly:
// middleware.ts -> proxy.ts
- export function middleware() {
+ export function proxy() {
Version History
| Version | Changes |
|---|---|
v16.0.0 | Middleware deprecated and renamed to Proxy; Proxy defaults to the Node.js runtime |
v15.5.0 | Node.js runtime support became stable |
v15.2.0 | Node.js runtime support added (experimental) |
v13.1.0 | Advanced flags added |
v13.0.0 | Header/response modification support added |
v12.2.0 | Middleware became stable |
v12.0.0 | Middleware (Beta) added |
Key Takeaways
| Concept | Detail |
|---|---|
| Naming | middleware.ts is deprecated — the current, correct name is proxy.ts |
| Runs on | Every route by default — always scope with matcher to avoid blocking static assets |
| Default runtime | Node.js (as of v16) |
| Server Functions | Not separate routes — a matcher excluding a path also silently skips Server Functions there; authorize inside them directly |
_next/data exception | Always runs regardless of negative matcher exclusions, by design |
| Header propagation | Use NextResponse.next({ request: { headers } }) to reach your app; plain headers reaches the client instead |
| Migration | npx @next/codemod@canary middleware-to-proxy . handles the rename automatically |
The rename from Middleware to Proxy isn't cosmetic — it's the Next.js team correcting a name that invited misuse, and steering this feature toward its actual role: a fast, isolated network boundary in front of your app, not a general-purpose place to run arbitrary application logic.


