Founder & Lead Engineer, RAITHub
If proxy.ts is not running in Next.js 16, check five things in order: the file sits at the same level as your app folder (inside src/ if you use one); it exports a function named proxy or a default export; the matcher is a constant that starts with / and matches your path; there is no runtime setting; and your deploy target supports proxy.
Next.js 16 renamed the middleware file convention to proxy. The RAITHub website runs Next.js 16 with a src/proxy.ts that protects the admin area, so we checked every point below against the current Next.js documentation (version 16.3) and against our own build. The dangerous part of this bug is that it is silent: a proxy that never runs produces no error, and any auth check inside it simply disappears.
What changed from middleware.ts to proxy.ts in Next.js 16?
The file name, the function name, a few config flag names, and the runtime. The Next.js upgrade guide says "the middleware filename is deprecated, and has been renamed to proxy", and that "the named export middleware is also deprecated. Rename your function to proxy" (Next.js: upgrading to version 16).
| What | Next.js 15 | Next.js 16 |
|---|---|---|
| File | middleware.ts | proxy.ts (middleware.ts deprecated) |
| Function | export function middleware() | export function proxy() or a default export |
| Default runtime | Edge (Node.js stable from 15.5) | Node.js, and it cannot be configured |
| Edge runtime | Supported | Not supported in proxy; keep middleware.ts if you need it |
| Config flags | skipMiddlewareUrlNormalize | skipProxyUrlNormalize |
Next.js ships a codemod that renames the file and the function: npx @next/codemod@canary middleware-to-proxy . (Next.js: proxy.js). The general upgrade codemod also migrates the convention and the renamed flags.
Is proxy.ts in the right folder?
It must sit at the same level as your app (or pages) folder. The docs say: "Create a proxy.ts (or .js) file in the project root, or inside src if applicable, so that it is located at the same level as pages or app."
The common mistake is a project that uses src/app with a proxy file in the repository root. The file is not next to app, so it is not picked up. This site's layout is:
raithub-website/
next.config.ts
src/
proxy.ts <- same level as app
app/
admin/
api/
blog/
Two more location details:
- Custom page extensions. If you set
pageExtensionsto something like.page.ts, the file must be namedproxy.page.ts. - One exported function. The docs say the file must export a single function; multiple proxy functions from the same file are not supported. Split logic into modules and call them from one
proxy.
Is the function exported with the right name?
Export it as proxy, or as the default export. The docs say "the file must export a single function, either as a default export or named proxy".
A frequent half-migration is renaming the file but keeping export function middleware. That name is deprecated and does not meet the proxy.ts contract, so rename the function too. Next.js recommends naming the function proxy even when you use a default export, which also makes stack traces easier to read.
This is the proxy this site runs, from src/proxy.ts:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const token = await getToken({ req: request })
if (!token || token.role !== 'ADMIN') {
if (pathname.startsWith('/api/admin')) {
return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const loginUrl = new URL('/admin/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin', '/admin/((?!login).+)', '/api/admin/:path*'],
}
Does your matcher actually match the request?
This is the most common cause when the file and export are right. The matcher decides which paths run the proxy at all, and a small pattern mistake means it never fires. The rules from the docs:
- It must be a constant. "The
matchervalues need to be constants so they can be statically analyzed at build-time. Dynamic values such as variables will be ignored." A matcher built from an imported array or a function call is silently dropped. - Every source must start with
/. - Patterns are anchored to the start of the path.
/aboutmatches/aboutand/about/team, but not/blog/about. - Modifiers matter.
/about/:pathmatches one segment;/about/:path*matches zero or more. Forgetting the*is a classic reason nested routes are unprotected. - Regex goes in parentheses.
/about/(.*)is the same as/about/:path*.
This site's matcher shows two deliberate choices. /admin/((?!login).+) covers every admin page except the login page, because a proxy that redirected the login page to itself would loop. /admin is listed separately because that pattern needs at least one character after the slash, so it would not match the bare /admin path.
The opposite problem is a proxy with no matcher. The docs warn that without one, proxy "runs on every request", including _next/static, image optimisation and files in public/, so auth redirects can block your CSS and JavaScript. Two more behaviours surprise people: proxy still runs for _next/data routes even if you exclude them, and Server Functions are handled as POST requests to the page that uses them, so excluding a path also skips its Server Functions.
You can test a matcher without deploying. Next.js 15.1 and later include an experimental helper:
import { unstable_doesProxyMatch } from 'next/experimental/testing/server'
import nextConfig from '../next.config'
import { config } from '@/proxy'
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/admin/blog' })).toBe(true)
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/admin/login' })).toBe(false)
The helper is experimental, so pin your Next.js version if you rely on it in CI.
Why does setting a runtime break proxy.ts?
Because proxy only runs on Node.js. The docs say "Proxy defaults to using the Node.js runtime. The runtime config option is not available in Proxy files. Setting the runtime config option in Proxy will throw an error." The upgrade guide adds that the edge runtime is not supported in proxy at all.
So an old export const config = { runtime: 'edge', matcher: [...] } carried over from middleware.ts has to lose its runtime key. If you genuinely need the edge runtime, the guide's advice is to keep using middleware.ts for now. The upside of Node.js is that Node-only libraries now work in proxy. This site's proxy calls next-auth's getToken on the default runtime.
Does your deploy target support proxy?
Most do, with one clear exception. The proxy docs list Node.js servers and Docker containers as supported, adapters as platform-specific, and static export as not supported. If you build with output: 'export', there is no server to run a proxy, so nothing in it will take effect on the exported site.
What does each symptom point to?
| Symptom | Most likely cause | Fix |
|---|---|---|
| Proxy never runs on any path | File not next to app, or wrong export name | Move to src/proxy.ts if you use src; export proxy |
| Runs on some paths, not nested ones | :path instead of :path* | Add the modifier; test with unstable_doesProxyMatch |
| Matcher seems ignored entirely | Matcher built from a variable | Inline a literal array |
| Build or runtime error mentioning runtime | runtime key left in config | Remove it; proxy is always Node.js |
| CSS, JS or images blocked | No matcher, so auth runs on static assets | Add a matcher or a negative lookahead |
| Redirect loop on the login page | Matcher includes the login route | Exclude it, as in /admin/((?!login).+) |
| Works locally, not in production | Static export, or a stale build | Check output; clear the build cache and rebuild |
How do you prove proxy.ts is running?
Check the build output, then the HTTP responses on the protected paths. A quiet build is not proof: a proxy that is ignored produces no error at all.
- Build output. On this site,
next buildlists "Proxy (Middleware)" in its output. If it is missing, Next.js did not find your file. - A protected page redirects when logged out.
NextResponse.redirectsends a 307 by default. - A protected API returns 401 without a session.
# Logged out: expect 307 with a Location header to the login page
curl -sI http://localhost:3000/admin | grep -iE '^HTTP|^location'
# Logged out: expect 401 and a JSON error
curl -s -w "\n%{http_code}\n" http://localhost:3000/api/admin/leads
Run these against npm run build and npm start, not only the dev server, and again on the deployed site. We ran exactly this after the Next.js 16 upgrade: admin pages redirected to login and the admin API returned 401 without a session.
One more rule from the docs is worth following whatever your proxy does: verify authentication and authorisation inside each Server Function and route as well. A matcher change or a moved route "can silently remove Proxy coverage", so proxy should be a first gate, not the only one.
Why RAITHub for Next.js upgrade problems?
Because we run Next.js 16 in production, with a proxy guarding real admin routes, and we verified the upgrade with HTTP checks rather than a green build.
- Security-critical config gets proved, not assumed. After any framework upgrade, we check that auth gates still load, as described in the code rescue playbook.
- Tests on the rules. Redirects, 401s and matchers get automated checks, so a later refactor cannot remove them silently.
- Stack experience. Our Next.js development service covers upgrades, App Router migrations and production hardening.
When you don't need us
- You only need the rename. Run the codemod, rebuild and do the curl checks. That is usually under an hour.
- You need the edge runtime. Keep
middleware.tsfor now, as the Next.js guide advises, and revisit when the promised edge guidance arrives. - Your proxy only sets headers. Static headers belong in
next.configheaders(), which runs before proxy. This site sets its security headers there.
If your proxy still does not run after these checks, see how a fixed-price fix works, or send us the build output and your proxy file for a free 15-minute technical audit.
Checked against the Next.js 16.3 documentation and this site's Next.js 16 build. Last reviewed: 29 September 2026.
Frequently asked questions
Why is my Next.js 16 proxy.ts not running?
Usually because it is in the wrong folder, exports the wrong name, or has a matcher that never matches. Put it at the same level as app, export proxy or a default function, and use a literal matcher starting with /.
Does middleware.ts still work in Next.js 16?
It is deprecated but still the documented route if you need the edge runtime, because proxy only supports Node.js. For everything else, rename it to proxy.ts and the function to proxy, or run the codemod.
Where do I put proxy.ts when I use a src folder?
Inside src, next to src/app. A proxy file in the repository root is not at the same level as app in that layout, so Next.js does not use it.
Can proxy.ts use the edge runtime?
No. Proxy runs on Node.js and the runtime option cannot be set; setting it throws an error. Next.js says to keep middleware.ts if you need edge.
How do I test a Next.js proxy matcher?
Use unstable_doesProxyMatch from next/experimental/testing/server (Next.js 15.1 and later) in a unit test, then confirm with curl against a production build that protected paths redirect or return 401.
Is proxy.ts enough to protect admin routes?
It is a good first gate, but not enough on its own. The Next.js docs recommend checking authentication inside each Server Function too, because a matcher change can silently remove proxy coverage.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.