Next.js 16 proxy.ts Not Working? The middleware.ts Migration Checklist
Next.js 16 renamed middleware.ts to proxy.ts and moved it to Node.js. If redirects or auth checks stopped firing, this tested checklist finds the cause.
If your proxy.ts doesn’t run in Next.js 16, the cause is almost always one of two silent mistakes: the file is in the wrong folder, or its matcher doesn’t cover the path you are testing. Most other migration mistakes don’t fail silently at all — they stop the build with an error that names the problem.
Next.js 16, released on 21 October 2025, renamed middleware.ts to proxy.ts. The new name says what the file is: a network boundary in front of your app, not Express-style middleware. It also changed the runtime: proxy.ts always runs on Node.js, while middleware.ts ran on the Edge runtime by default.
Everything here was tested on 26 October 2026 with Next.js 16.4.3 (the latest release), React 19.3.1, Node.js 22.14 and the default bundler, Turbopack, using production builds (next build and next start) unless a section says otherwise. The runtime rules were also checked on 16.0.11 and 16.3.8.
First check: does the build see your proxy?
next build lists the proxy below the route table:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /dashboard
├ ○ /login
└ ○ /settings
ƒ Proxy (Middleware)
If that ƒ Proxy (Middleware) line is missing, Next.js didn’t find your file, and nothing else in it matters yet: go to the location check below. If the line is there, the file was found, and the question is which requests it runs for.
The migration
The codemod does the rename for you. Commit first, so you can review its changes:
npx @next/codemod@latest middleware-to-proxy .
It renames middleware.ts to proxy.ts, renames the exported function middleware to proxy, and renames four options in next.config: skipMiddlewareUrlNormalize, experimental.middlewarePrefetch, experimental.middlewareClientMaxBodySize and experimental.externalMiddlewareRewritesResolve (in each name, “middleware” becomes “proxy”). In testing, it also deleted a runtime line from the config without saying so — correct, because proxy.ts can’t set a runtime, but it means an Edge middleware silently becomes a Node.js proxy. More on that below.
By hand, the change is small. The logic, NextRequest, NextResponse and the matcher stay the same:
// proxy.ts (was middleware.ts)
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) { // was: export function middleware
if (!request.cookies.has("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
A default export (export default function proxy) works too.
When the proxy silently doesn’t run
1. The file is in the wrong folder
proxy.ts must sit at the same level as your app/ (or pages/) folder:
| Project layout | Where proxy.ts goes |
Tested result |
|---|---|---|
app/ at the project root |
./proxy.ts |
Runs |
src/app/ |
./src/proxy.ts |
Runs |
app/ at the project root |
./src/proxy.ts |
Ignored: build passes, no ƒ Proxy line |
| any | ./app/proxy.ts |
Ignored: build passes, no ƒ Proxy line |
The last two are the dangerous ones: no warning, no error, and every request goes straight through.
2. The matcher doesn’t cover the path
Matchers are path patterns, and the most common slip is a pattern that covers only one exact path. These are the paths each matcher ran the proxy for:
matcher |
/ |
/dashboard |
/dashboard/team |
/logo.svg, /_next/static/… |
|---|---|---|---|---|
| none | yes | yes | yes | yes |
"/dashboard" |
no | yes | no | no |
"/dashboard/:path*" |
no | yes | yes | no |
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg)$).*)" |
yes | yes | yes | no |
So "/dashboard" protects /dashboard and nothing below it; use "/dashboard/:path*" for the whole section. With no matcher at all, the proxy runs for every request, including scripts, images and public/ files, which is rarely what you want for an auth check.
The last pattern is the one from the Next.js documentation: everything except API routes, static files, image optimisation and a few file types. In a TypeScript file it is written as a normal string:
export const config = {
matcher: [
// everything except API routes, static files, image optimisation and images
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg)$).*)",
],
};
To see which requests reach the proxy, add a temporary console.log(request.nextUrl.pathname) at the top of the function. The output appears in the terminal running next dev or next start (or in your host’s runtime logs in production), never in the browser console, because the proxy runs on the server.
One more gap the documentation warns about: Server Actions aren’t separate routes. They are POST requests to the page that uses them, so a matcher that excludes a page also skips its Server Actions.
When the build stops instead
These mistakes are loud, which makes them easy to fix once you know what the message means. Each was reproduced on 16.4.3:
| Mistake | What next build says |
|---|---|
proxy.ts still exports function middleware |
Proxy is missing expected function export name |
Both middleware.ts and proxy.ts exist |
Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. Please use "./proxy.ts" only. |
runtime: "nodejs" in the proxy’s config |
Route segment config is not allowed in Proxy file at "./proxy.ts". Proxy always runs on Node.js runtime. |
runtime: "edge" in the proxy’s config |
Proxy does not support Edge runtime. |
| The matcher comes from a variable | `matcher` needs to be a static string or array of static strings or array of static objects. |
A matcher without a leading / |
source must start with / at "matcher[0]" |
In next dev, the wrong export name doesn’t stop the server; instead, requests to the paths it matches fail with a 500 error, and the terminal prints The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.
Old option names in next.config don’t break anything on their own: skipMiddlewareUrlNormalize and the rest still work and only print a deprecation warning. They become an error only if you set the old and the new name together.
The runtime changed: Edge to Node.js
Renaming the file changes where your code runs. A middleware.ts without a runtime setting still runs on the Edge runtime in Next.js 16; the same code as proxy.ts runs on Node.js, and that can’t be changed. In testing, that worked in the proxy’s favour: a middleware.ts that imported node:crypto built fine but failed every request with Native module not found: node:crypto, while the same file as proxy.ts worked. You can use Node.js APIs and ordinary npm packages in the proxy, and drop Edge-compatible workarounds.
If you need to stay on Edge for now, keep middleware.ts: it still works, with a deprecation warning, and Next.js says it will be removed in a future version. Two details from testing:
- Don’t write
runtime: "edge"in its config. On 16.0.11, 16.3.8 and 16.4.3 (and on 16.4.3 with webpack as well as Turbopack), that fails the build withPage /middleware provided runtime 'edge', the edge runtime for rendering is currently experimental. Use runtime 'experimental-edge' instead.Leaveruntimeout (Edge is the default formiddleware.ts) or use"experimental-edge". - The Edge runtime itself is now deprecated. Builds on 16.3.8 and 16.4.3 (but not 16.0.11) warn
The Edge Runtime is deprecated. You can use the "nodejs" runtime instead.Staying on Edge postpones the migration; it doesn’t avoid it.
Cookies set on the wrong response are lost
If the proxy refreshes a session token, the new Set-Cookie must be on the response you actually return. This is easy to get wrong when the code first prepares a NextResponse.next() and then decides to redirect:
// Lost: the cookie is set on a response that is never returned
const res = NextResponse.next();
res.cookies.set("session", newToken, { httpOnly: true, secure: true, path: "/" });
return NextResponse.redirect(new URL("/dashboard", request.url));
// Kept: set the cookie on the redirect itself
const res = NextResponse.redirect(new URL("/dashboard", request.url));
res.cookies.set("session", newToken, { httpOnly: true, secure: true, path: "/" });
return res;
In testing, the first version returned a 307 with no Set-Cookie header; the second returned set-cookie: session=fresh-token; Path=/; Secure; HttpOnly. When the proxy sets a cookie on NextResponse.next() and returns that response, the page rendered for the same request already sees the new value through cookies().
Do you need to restart the dev server?
In testing, next dev with Turbopack picked up every proxy change without a restart: renaming middleware.ts to proxy.ts, editing the matcher, deleting the file and adding it back. If your setup behaves differently, restart it before assuming the code is wrong. In production there is no hot reload: rebuild and redeploy.
The proxy is not your authentication layer
In March 2025, CVE-2025-29927 showed why. On unpatched versions of Next.js (before 12.3.5, 13.5.9, 14.2.25 and 15.2.3), sending an internal header, x-middleware-subrequest, made Next.js skip middleware entirely, along with any authorisation check inside it. Current versions are fixed: Next.js 16.4.3 still redirected requests that carried the header.
The deeper problem is that a proxy check like request.cookies.has("session") is only optimistic: it checks that a cookie exists, not that it is valid. In testing, a request with a made-up session=forged cookie passed the proxy and was stopped only by a check in the page. The Next.js authentication guide draws the same line: use the proxy for quick, optimistic redirects, and do the secure check — against your session store — close to the data.
So use the proxy for redirects, rewrites, headers, locale routing and quick “is there a session cookie?” redirects. Do the real authorisation in Server Components, Route Handlers, Server Actions and your data-access layer:
// lib/auth.ts
import { cookies } from "next/headers";
export async function getSession() {
const token = (await cookies()).get("session")?.value;
// Look the token up in your session store, or verify its signature.
// Never trust that the cookie merely exists.
return token ? await findSession(token) : null;
}
// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function Dashboard() {
const session = await getSession(); // real verification, on the server
if (!session) redirect("/login");
return <p>Welcome, {session.name}</p>;
}
findSession stands for your own lookup. Check the session the same way in every Server Action and Route Handler that reads or changes sensitive data, because none of them is protected by a proxy that doesn’t run for it.
Checklist
- Run
next buildand look for theƒ Proxy (Middleware)line. - Put
proxy.tsnext toapp/: at the root, or insrc/only ifapp/is insrc/. - Export
proxy(or a default function), and delete the oldmiddleware.ts. - Remove
runtimefrom the proxy’s config; expect Node.js, not Edge. - Use
:path*for whole sections, and check which paths your matcher really covers. - Log
request.nextUrl.pathnameand read the server terminal, not the browser console. - Set refreshed cookies on the response you return, including redirects.
- Rename
middlewareoptions innext.config, or let the codemod do it. - Keep
middleware.tsonly if you must stay on Edge, withoutruntime: "edge", and plan to move. - Verify sessions in Server Components, Route Handlers and Server Actions, not only in the proxy.
References and further reading
- proxy.js file convention, Next.js documentation
- How to upgrade to version 16, Next.js documentation
- Codemods, Next.js documentation
- Next.js 16, Next.js blog
- Authentication, Next.js documentation
- CVE-2025-29927, NIST National Vulnerability Database