Next.js 16 proxy.ts Not Running in Production: Fix

Next.js 16 proxy.ts not running in production after migrating from middleware.ts? Fix matcher format, file location, and the runtime config with code.
Next.jsReactProxy
April 23, 20265 min read923 words

The Problem

Migrated a client project from Next.js 15 to 16 last week. Renamed middleware.ts to proxy.ts, shipped the build, and auth redirects stopped firing in production. Locally with next dev everything worked. On Vercel the proxy.ts file just did not run. No logs, no redirects, no header rewrites. Direct requests to protected routes returned the page instead of a 302 to the login screen.

If you hit this during a 15 → 16 migration and your proxy.ts is silently skipped, here is what is going on and how to get it firing again.

Why It Happens

Next.js 16 renamed middleware.ts to proxy.ts and changed three things the upgrade codemod does not always catch.

Change 1: The file must be at the project root or inside src/, never in app/. In 15 a surprising number of projects had app/middleware.ts and it still worked because of how the old compiler resolved it. The new Turbopack-based resolver is strict, so app/proxy.ts is treated as a route handler segment and gets ignored as a proxy file.

Change 2: Matcher syntax is stricter. The 15 matcher accepted loose patterns like '/((?!_next).*)'. The 16 matcher requires the new matcher array format with explicit source and optional has/missing conditions. A bare regex string that worked in 15 will be silently ignored in 16.

Change 3: Runtime must be declared. middleware.ts defaulted to the edge runtime. proxy.ts requires an explicit export const runtime = 'edge' or 'nodejs' in 16. No runtime export means Vercel treats the file as dead code and strips it from the build output. This is the one that bit me. next dev is forgiving, production is not.

There is a fourth gotcha specific to monorepos. If you have a turbo.json with a globalDependencies array that did not include proxy.ts, Turborepo cache can return a build that was computed before you renamed the file, serving a deployment with no proxy at all.

The Fix

Step 1: Confirm the file is at the right path.

your-project/
├── app/
│   └── page.tsx
├── proxy.ts        ← correct
├── next.config.ts
└── package.json

Or if you use a src/ directory:

your-project/
├── src/
│   ├── app/
│   │   └── page.tsx
│   └── proxy.ts    ← correct

If you renamed the file with git mv but the build still ships old output, clear .next/ and node_modules/.cache/ before rebuilding.

Step 2: Use the correct proxy file shape.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

export const runtime = 'edge'

export async function proxy(request: NextRequest) {
  const session = request.cookies.get('session')?.value

  if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('next', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

The function is named proxy (not middleware). The runtime export is required. The config.matcher uses the object form, not a bare string.

Step 3: If you had custom regex matchers, rewrite them. The 15 pattern:

// Old – 15
export const config = {
  matcher: ['/((?!_next|api|static).*)']
}

Becomes in 16:

// New – 16
export const config = {
  matcher: [
    {
      source: '/((?!_next|api|static).*)',
    },
  ],
}

The array of strings still parses but loses the prefetch optimization. Using the object form with missing skips proxy execution for router prefetches, which cuts your edge function invocation count roughly in half.

Step 4: Flush stale caches. On Vercel, trigger a Redeploy with "Use existing Build Cache" unchecked. In a Turborepo monorepo, run:

pnpm turbo run build --force

Then confirm in the Vercel dashboard under Deployment → Functions that a function named proxy exists. If it does not, your file is still not being picked up, so re-check Step 1.

Step 5: Verify in production with a log line. Add a temporary log at the top of the proxy function:

export async function proxy(request: NextRequest) {
  console.log('[proxy]', request.nextUrl.pathname)
  // ...rest of the function
}

Deploy, hit a route that should match, and check Vercel → Logs → Function Logs filtered by [proxy]. If no line appears, the matcher is not matching, not the proxy missing entirely. If no log appears on any route, go back to the runtime export.

The Next.js proxy docs have the full matcher reference if you need has/missing conditions for multi-tenant routing.

The Lesson

The middleware.ts to proxy.ts rename is not just a codemod; it is a contract change. Correct file location, explicit runtime, and object-form matcher are the three things to verify before assuming anything else is broken. When you have ruled those out, the log-line trick will tell you whether the issue is "never runs" or "runs but does not match."

If you are in the middle of a 15 → 16 migration and running into edge cases, that is most of what I do — see my services. If your caching also went sideways after the upgrade, I wrote the Next.js 16 revalidateTag production fix last week.

Back to blogStart a project