Moving from Next.js middleware to proxy is mostly a rename. In Next.js 16, middleware.ts becomes proxy.ts, the exported middleware function becomes proxy, and config flags carrying the old name change too. The one real behaviour change is the runtime: proxy always runs on Node.js, so middleware that depends on the Edge runtime needs a decision first.
The Next.js 16 upgrade guide calls the middleware file name deprecated, not removed. An app still using it builds on 16.x today, so nothing forces the rename this week. The rename carries a second message, though. The proxy.js API reference says the team renamed the feature partly to discourage overuse, and that it “is recommended to be used as a last resort”. That has consequences for what you keep in the file.
What does the middleware-to-proxy codemod change?
There is a dedicated codemod:
npx @next/codemod@canary middleware-to-proxy .
According to the API reference, it renames the file and renames the function from middleware to proxy. The broader upgrade latest codemod runs the same migration as part of the 15-to-16 jump. It also updates flags that had “middleware” in their name. The example the guide gives is skipMiddlewareUrlNormalize, which is now skipProxyUrlNormalize:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
skipProxyUrlNormalize: true,
}
export default nextConfig
Nothing else in the API moves. NextRequest, NextResponse, NextFetchEvent and event.waitUntil() behave as before, and so do the config.matcher syntax and the has/missing conditions. after() works in proxy too, as our write-up on background work with after() explains. The file still lives at the project root, or inside src next to app. A default export still works. The upgrade guide recommends naming the function proxy anyway, and there is a NextProxy type if you prefer typing an arrow function in one go.
If you set a custom pageExtensions, the file follows it, so .page.ts projects need proxy.page.ts.
Why is proxy locked to the Node.js runtime?
This is where a straight rename can break a build. The upgrade guide is explicit: “The edge runtime is NOT supported in proxy. The proxy runtime is nodejs, and it cannot be configured.” According to the API reference, setting runtime in a proxy file throws an error.
The history explains it. Node.js support for middleware arrived as experimental in 15.2 and went stable in 15.5, and in 16.0 it became the default and the only option for the renamed file. The current runtime segment config page (docs version 16.3.6, as of September 2026) marks 'edge' as deprecated across pages, layouts and route handlers too, with a migration note that says to delete the runtime export.
So there are three cases.
If your middleware never set a runtime and only uses web APIs, it was on Edge by default and will now run on Node. For cookie reads, redirects and header rewrites you will not notice, and you gain access to Node modules you could not import before.
If it explicitly exports runtime = 'edge' because of where your host runs it, keep middleware.ts for now. The upgrade guide says exactly that, and it promised further Edge guidance in a later minor release. Until your host gives you a Node path, a deprecation warning is cheaper than a broken deploy.
If you deploy to Cloudflare Workers, check your adapter before renaming. Our post on running Next.js on Cloudflare Workers covers which adapters support Node middleware and which do not.
Check platform support as well. The API reference lists proxy as supported on a Node.js server and in Docker, platform-specific on adapters, and unsupported with a static export.
Should auth checks still live in proxy?
Only the cheap ones. The authentication guide now calls proxy checks “optimistic”: decode the session from the cookie, redirect if it is missing, and avoid database calls. The reason is load. Proxy runs on every matched request, including prefetches, so a database round trip here multiplies with every <Link> in the viewport.
The deeper reason is that proxy is a single gate, and requests can reach your code without passing it. The API reference documents two ways.
Server Functions are not separate routes. They are POST requests to the page that uses them, so a matcher that excludes a path also skips the Server Functions on it. Move an action to a different route in a refactor and it can silently lose proxy coverage.
Excluding _next/data in a negative matcher does not stop proxy running for those requests. Next.js does that on purpose, so a protected page cannot leak through its data route. Your matcher does not fully describe where the code runs.
A third way was a bug. CVE-2025-29927 let an attacker skip middleware entirely by sending a spoofed x-middleware-subrequest header, an internal header Next.js used to stop recursion. Self-hosted apps on next start and output: 'standalone' were affected until 15.2.3, 14.2.25, 13.5.9 and 12.3.5. On those deployments, an app whose only auth check lived in middleware was open.
The data security guide says the same thing in calmer language: authorise inside each Server Function, Route Handler and data access function, and treat proxy.ts and route.ts as files that deserve extra audit time. Proxy is the right place for a fast redirect to /login. It is the wrong place to be the only thing standing between a user and someone else’s invoice.
A proxy.ts that does only a proxy’s job
This version reads a signed cookie, redirects unauthenticated visitors away from protected sections, and leaves every real authorisation decision to the data layer. It uses jose, which the Next.js auth guide also uses for stateless sessions:
// proxy.ts (project root, next to app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.SESSION_SECRET)
const protectedPrefixes = ['/dashboard', '/account']
async function hasValidSession(token: string | undefined): Promise<boolean> {
if (!token) return false
try {
await jwtVerify(token, secret, { algorithms: ['HS256'] })
return true
} catch {
return false
}
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = protectedPrefixes.some((prefix) => pathname.startsWith(prefix))
if (isProtected && !(await hasValidSession(request.cookies.get('session')?.value))) {
const login = new URL('/login', request.url)
login.searchParams.set('next', pathname)
return NextResponse.redirect(login)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}
The matcher excludes static assets because, without one, proxy runs on _next/static, _next/image and everything in public/. A redirect rule there can quietly block your CSS. Leave api in the matched set if your route handlers also sit behind a session. Each handler should still check again, but the redirect gives browsers a sensible response.
Testing which paths proxy runs on
Matchers are regular expressions that must be constants, analysed at build time, and they are easy to get wrong. The docs describe an experimental helper, unstable_doesProxyMatch from next/experimental/testing/server, that answers “would proxy run for this URL?” without starting a server:
// proxy.test.ts
import { expect, test } from 'vitest'
import { unstable_doesProxyMatch } from 'next/experimental/testing/server'
import { config } from './proxy'
import nextConfig from './next.config'
test('proxy skips static assets but covers the dashboard', () => {
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/_next/static/app.js' })).toBe(false)
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/dashboard/billing' })).toBe(true)
})
It carries the unstable_ prefix, so expect the name to change, and pin your Next.js version in CI if you rely on it.
Should you migrate now?
Rename now if your middleware runs on the default runtime and your host runs Node. The codemod is mechanical, the diff is small, and you get rid of a deprecation warning before it turns into a removal. Do the audit alongside it: anything in the file that queries a database or makes the final call on access belongs in a Server Function or a data access function that checks the session itself.
Hold off if you explicitly pin the Edge runtime, or your adapter does not yet support Node proxy. In that case, keep middleware.ts, remove any auth logic that is not a cheap cookie check, and revisit when your platform’s release notes mention proxy.
We build and upgrade Next.js applications at Whoooop, including 15-to-16 migrations where proxy, caching and self-hosting all change at once. If you want a second pair of eyes on yours, our Next.js development page covers how we work.