Overview

A proxy.ts at the project root runs before the matched request reaches a route. Next.js 16 renamed this file from middleware.ts to proxy.ts to make its job clearer: it sits between the request and the app, proxying and rewriting before a route ever runs. Use it for cross-cutting work that has to happen on every request: auth gating, locale routing, A/B redirects, header injection. Unlike the old middleware, proxy.ts runs on the Node.js runtime only; Edge is no longer an option here. The cost still compounds because the function fires on every matched request, so the matcher and the runtime rules below still apply.

Place proxy.ts at the project root

The file lives at the project root, not inside app/ or pages/. There is one proxy file per project. It exports a named proxy function that accepts a NextRequest and returns a NextResponse.

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function proxy(req: NextRequest) {
  return NextResponse.next();
}

A return value of NextResponse.next() lets the request proceed. Any other response short-circuits the pipeline; the route handler never runs.

middleware.ts still works in Next.js 16 for backward compatibility, but it is deprecated and slated for removal; migrate by renaming the file to proxy.ts and the exported function to proxy. Do not keep both files in the same project; Next.js only recognizes one.

Constrain the matcher; do not run on every asset

The default matcher runs on every request, including static assets. That is wasteful and expensive. Constrain it.

export const config = {
  matcher: [
    // Skip Next internals and static files; match everything else.
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)).*)",
  ],
};

For specific routes, list them: matcher: ["/dashboard/:path*", "/admin/:path*"]. The matcher is the cheapest filter; pruning here pays for itself on every page load.

Pick redirect, rewrite, or next deliberately

Three primitives cover most proxy logic:

  • NextResponse.redirect(url, status): tell the browser to navigate elsewhere. The URL changes; the user sees a 30x.
  • NextResponse.rewrite(url): serve a different route under the original URL. The browser does not see the rewrite; the user keeps the URL they typed.
  • NextResponse.next(): continue to the matched route, optionally with mutated request headers.
export function proxy(req: NextRequest) {
  const country = req.geo?.country ?? "US";
  if (req.nextUrl.pathname === "/") {
    return NextResponse.rewrite(new URL(`/landing/${country.toLowerCase()}`, req.url));
  }
  return NextResponse.next();
}

Use rewrites for A/B routing and locale serving. Use redirects for canonical URL enforcement and moved pages. Use next when you only need to mutate headers or cookies.

Read and write cookies on req and the response

Cookies live on req.cookies for reading and on the response for writing. Set a cookie in the response that goes back to the browser.

const res = NextResponse.next();
const sid = req.cookies.get("sid")?.value;
if (!sid) {
  res.cookies.set("sid", crypto.randomUUID(), {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });
}
return res;

Always set httpOnly, secure, and an explicit sameSite on auth cookies. The proxy is the right place to rotate session tokens, because it runs before the page reads them.

Forward signal via request headers, not globals

To pass data from the proxy to a page or handler, set request headers on the response and read them server-side in the route.

const res = NextResponse.next({
  request: { headers: new Headers({ ...req.headers, "x-user-id": userId }) },
});
return res;

In the route, read with headers() from next/headers. Do not try to share state in module scope; each invocation is isolated.

proxy.ts runs on Node.js only; there is no runtime switch

Node.js runtime is not configurable for proxy.ts; it always runs on Node.js. This is the biggest behavioral change from the old middleware, which defaulted to the Edge runtime (V8 isolates, Web APIs only, no node:* modules) and let you opt into Node with export const config = { runtime: "nodejs" }. That config option is gone: a Next.js 16 proxy file has full Node API access by default, at the cost of Edge’s sub-50ms cold starts and global execution.

If your deployment target relies on Edge-only proxy execution for latency, that trade-off no longer exists in Next.js 16; budget for Node cold starts instead, or push latency-sensitive checks (auth token verification, geolocation) to a CDN-level rule outside Next.js.

Treat the proxy as a hot path

The proxy runs on every matched request. A 50ms proxy function adds 50ms to every page in the matcher. Budget accordingly.

  • Cache token-verification results in a cookie or a fast KV. Do not call the auth provider on every request.
  • Skip JSON parsing of large request bodies; reroute the work to the route handler.
  • Avoid calling external APIs unless the request truly needs the call.
  • Profile cold and warm invocations on the host (Vercel surfaces this; see vercel).

For per-request work that is not cross-cutting, move it to the route. For cross-cutting work that is too expensive to run on every request, push it to the route and let the proxy do only the matcher and the redirect.