middleware.ts to proxy.ts in Next.js 16: Migrating Auth Without Breaking Edge (and Why proxy Is Node-Only)
In short
Next.js 16 GA renamed middleware.ts to proxy.ts, and the new file runs only on the Node.js runtime, not Edge. If your auth depended on Edge behavior or runtime: 'edge', it can break quietly. Run the official codemod, rename the matcher config, retest next-intl, and read the whole migration map before you ship.

On this page
- What actually changed from middleware.ts to proxy.ts in Next.js 16?
- How do I run the codemod to migrate to proxy.ts?
- Why does my Edge-dependent auth break, and how do I fix it?
- What config flags were renamed, and what about next-intl?
- When should I deliberately stay on middleware.ts?
- What is the safe migration checklist?
If your auth broke the moment you upgraded to Next.js 16, the cause is almost always the same: Next.js 16 GA renamed middleware.ts to proxy.ts, and the new file runs only on the Node.js runtime. The Edge runtime is not supported for it. So any auth that depended on Edge behavior, or on export const runtime = 'edge' inside your middleware, can fail quietly without a build error. The fix is to run the official codemod, update the renamed config, drop Edge-only assumptions, and retest libraries like next-intl that hooked into the old entry point.
This is a rename with teeth. The function moved layers, not just files, and that is why so many people are seeing redirect loops, missing session cookies, and locale routes that suddenly 404.
What actually changed from middleware.ts to proxy.ts in Next.js 16?
In short: the file was renamed, the convention moved to the Node.js runtime, and Edge support for this layer was removed. Same position in the request pipeline, different execution environment.
The old model gave you a single middleware.ts at the project root that ran on the Edge runtime by default. That meant a constrained set of APIs, no Node built-ins, and a runtime optimized for running close to the user. A lot of auth libraries bent themselves around those limits.
Next.js 16 replaces that with proxy.ts at the same location. It still intercepts requests before they hit your routes, you still return NextResponse, and the matcher config still works. What changed is the runtime underneath. proxy.ts is Node-only. You now have access to Node built-ins, but you lose the Edge runtime entirely for this layer.
Here is the practical difference:
| Aspect | middleware.ts (Next.js 15 and earlier) | proxy.ts (Next.js 16) |
| File name | middleware.ts | proxy.ts |
| Exported function | middleware() | proxy() |
| Default runtime | Edge | Node.js |
| Edge runtime support | Yes | No, Node-only |
Node built-ins (crypto, fs patterns) | Restricted on Edge | Available |
| Request matching | export const config = { matcher } | export const config = { matcher } |
| Return type | NextResponse | NextResponse |
| Codemod available | n/a | Yes, official |
The reason this breaks auth specifically is that authentication is usually the heaviest thing running in this layer. Session verification, JWT decoding, redirect logic, cookie rewriting. If any of that was written against Edge constraints or assumed Edge timing, moving to Node can shift behavior in ways that are not always obvious until a user cannot log in.
How do I run the codemod to migrate to proxy.ts?
Run the official Next.js 16 codemod, which renames the file and the exported function for you. It handles the mechanical part, not the logic.
npx @next/codemod@latest middleware-to-proxy .
The codemod does three things: it renames middleware.ts to proxy.ts, it renames the exported middleware function to proxy, and it updates obvious references. What it does not do is reason about your runtime assumptions. If your old file had export const runtime = 'edge', the codemod will not magically rewrite your auth to be safe on Node. That part is on you.
Here is what a migrated proxy.ts should look like at the skeleton level:
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const session = request.cookies.get('session')?.value
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}
Notice there is no runtime export. You do not declare runtime: 'edge' here anymore, because there is no Edge option for this layer. If you copy that line over from your old middleware, remove it.
After running the codemod, do not assume you are done. The rename is the easy 20 percent. The hard 80 percent is verifying that your auth still behaves correctly now that it is on Node.
Why does my Edge-dependent auth break, and how do I fix it?
It breaks because logic that relied on the Edge runtime now runs on Node, and the silent failures come from assumptions that were never explicit in code. The fix is to find every Edge-specific dependency and make it Node-safe.
In my experience the failures cluster into a few patterns:
- Auth libraries pinned to Edge. Some auth setups had an
edgeentry point or a config flag that assumed the Edge runtime. On Node, that import path can resolve differently or pull in a build that expects globals Edge provided. Switch to the library's Node-compatible entry and check its Next.js 16 notes. - Session verification using Web Crypto only. Edge gave you
crypto.subtleand not much else. Code written defensively for Edge often used the Web Crypto API directly. That still works on Node, but if you had conditional branches that detected the runtime, those branches may now take the wrong path. - Cookie handling timing. A few auth flows depended on the order in which Edge processed cookies versus how Node does it. If your login worked but a refresh loops, look at where you read and write the session cookie inside
proxy(). - Environment variable access. Some setups deferred secrets loading because Edge restricted it. On Node those secrets are available earlier, which can change initialization order.
The honest debugging path is to add logging at the top of
proxy() and watch what runs:export function proxy(request: NextRequest) {
console.log('[proxy] path:', request.nextUrl.pathname)
console.log('[proxy] has session:', request.cookies.has('session'))
// ... your auth logic
return NextResponse.next()
}
Then hit a protected route and watch the server logs. Because you are on Node now, those logs land in your normal server output, which is itself a tell that you moved runtimes. If your auth worked on Edge and breaks here, the mismatch will usually show up as a redirect that fires when it should not, or a session that reads as empty when the cookie is clearly set.
When the auth in this layer gets genuinely tangled, that is often a signal to keep proxy.ts thin and push verification into route handlers or server components where you have the full Node runtime and clearer control. I cover the broader caching and runtime shifts that pair with this in the use cache migration ↗ writeup, because the two changes tend to land in the same upgrade.
What config flags were renamed, and what about next-intl?
The matcher config stays the same, but anything that referenced the middleware name or the Edge runtime needs updating, and next-intl is the most common third-party casualty.
The export const config = { matcher: [...] } block carries over unchanged. That is the good news. What needs attention:
- Remove
export const runtime = 'edge'from the proxy file. There is no Edge option here. - Update any internal tooling, custom server logic, or CI checks that grepped for
middleware.tsby name. - Check
next.configfor experimental flags tied to middleware behavior from your previous version and confirm they still apply.
next-intl deserves its own paragraph because it is one of the most popular libraries that wired itself through the old middleware.ts entry for locale detection and routing. If your locale prefixes suddenly stop resolving after the upgrade, this is usually why. The fix is to upgrade next-intl to a release that targets proxy.ts, then re-export its handler from the new file:// proxy.ts
import createMiddleware from 'next-intl/middleware'
const handleI18n = createMiddleware({
locales: ['en', 'es'],
defaultLocale: 'en',
})
export function proxy(request: NextRequest) {
// run i18n routing, then layer your auth on the result
return handleI18n(request)
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}
If you combine next-intl with auth in the same file, the order matters. Run locale resolution first so your auth redirects point at locale-correct URLs, otherwise you get redirect loops where the auth check and the locale rewrite fight each other. Test every locale, not just your default. The default locale often works while the prefixed ones quietly 404.
When should I deliberately stay on middleware.ts?
Stay only if you have a hard Edge dependency you cannot remove yet, and treat it as a deadline, not a destination. Next.js 16 points you at proxy.ts, so leaning on the old name is a stopgap.
There are real cases where pausing makes sense:
- You have geographically distributed auth that genuinely needs Edge. If you were running auth at the Edge for latency reasons and your provider's Node path is meaningfully slower for your users, measure before you commit. You may want to move that logic to an Edge function elsewhere in your stack rather than cram it into
proxy.ts. - A critical dependency has not shipped proxy support. If a library you cannot replace still assumes the old entry, wait for its update rather than fork it under deadline pressure.
- You are mid-release and cannot absorb risk this sprint. Sequencing matters. Do not stack a runtime migration on top of a feature launch.
But be clear-eyed. This is the same kind of forced-migration pressure as the security upgrades hitting the framework right now, and the right move is usually to plan the work, not dodge it. If you are weighing how aggressively to upgrade across the board, the version-by-version upgrade map ↗ lays out the tradeoffs, and the React Compiler forms fix ↗ is a good example of when waiting one release is the correct call.
What is the safe migration checklist?
The safe path is mechanical first, behavioral second, then verify under real auth flows. Do not ship on the codemod alone.
Here is the sequence I follow:
- Branch, then run
npx @next/codemod@latest middleware-to-proxy . - Open the new
proxy.tsand delete anyexport const runtime = 'edge'. - Audit your auth library imports for Edge-specific entry points and switch to Node-compatible ones.
- Upgrade
next-intl(or any other middleware-coupled library) and re-export its handler fromproxy.ts. - Add temporary logging at the top of
proxy()and confirm it runs on Node. - Manually test the full login, logout, refresh, and protected-route flow, in every locale.
- Test the unauthenticated path explicitly, since redirect loops only show up when there is no session.
- Remove the logging and ship.
This is the kind of upgrade where a careful hour of testing saves a day of production firefighting. The rename looks trivial in a diff and is anything but, because the runtime moved underneath it.
If you are staring at a broken auth flow after a Next.js 16 upgrade and you would rather have someone handle the migration cleanly, this is exactly the sort of work I do under web development ↗. And if you just want a second pair of eyes on your proxy.ts before you ship, my contact page ↗ is open and I read everything that comes through it.
The short version: the rename is real, proxy.ts is Node-only, Edge is not supported here, and your auth broke because it assumed otherwise. Run the codemod, strip the Edge assumptions, retest under real sessions, and you are through it.
FAQ
Does proxy.ts run on the Edge runtime in Next.js 16?
No, proxy.ts runs only on the Node.js runtime in Next.js 16, and the Edge runtime is not supported for it, which is the single most important behavior change to plan for.
What does the Next.js 16 codemod for middleware do?
The official codemod renames your middleware.ts to proxy.ts and updates the exported function name, but it does not fix runtime assumptions or Edge-only dependencies in your auth logic.
Will my next-intl middleware still work after upgrading to Next.js 16?
It can break if your locale middleware was wired through the old middleware.ts entry, so you should upgrade next-intl to a version that supports proxy.ts and retest locale routing before shipping.
Can I keep using middleware.ts in Next.js 16?
Next.js 16 treats proxy.ts as the supported entry point, so staying on the old name is at best a temporary measure and you should plan the migration rather than rely on it long term.
Why did my auth break after upgrading to Next.js 16?
Most likely your auth ran logic that assumed the Edge runtime, and since proxy.ts is Node-only, anything depending on Edge-specific timing or APIs now behaves differently or fails silently.
Working on something like this?
I build web apps, AI features, and mobile products for clients. If this article matches a problem you have, tell me about it.
Start a conversationMalik Hamza Shabbir · Full-Stack & AI Engineer
I build full-stack and AI products solo: a reputation SaaS in production, RAG pipelines, and React Native apps. I write from what I ship, not from documentation summaries.
Related articles
Building Next.js Apps for AI Agents: AGENTS.md, @vercel/next-browser, and Agent DevTools in 16.2
Next.js 16.2 makes AI agents first-class users: create-next-app writes a version-matched AGENTS.md (100% vs 79% eval pass rate), @vercel/next-browser hands an LLM screenshots, network, and console in one call, and experimental Agent DevTools exposes framework state. Here is how I set it all up.
Next.js 16 'use cache' Migration: Replacing Implicit Caching and unstable_cache Without a Cross-Tenant Leak
Migrating off unstable_cache to Next.js 16 'use cache' is easy to get wrong in one specific way: a cached function that reads the tenant from request context instead of taking it as an argument will serve one tenant's data to another. Here is the function-scope-first playbook and audit checklist I use on production multi-tenant SaaS.
Adding Semantic Search to an Existing SaaS in a Weekend: pgvector Before You Reach for Pinecone
If your SaaS already runs on Postgres, you can add real semantic search in a weekend with pgvector and one Next.js route, no second database. Here is the embed-on-write migration, the HNSW query, a semantic cache, and the vector count where Pinecone or Milvus actually starts to make sense.