| | |

Next.js 16 ▲ Proxy — Rewrites, redirects & header modification before completion

⏱️ Estimated read: 12 min | |

In Next.js 16, the file convention formerly known as middleware.ts was renamed to proxy.ts. The functionality is the same: a single file at the project root runs code before a request completes, and that code can rewrite the URL, redirect the client, modify request or response headers, or return a response directly . The rename is not cosmetic. It reflects a deliberate narrowing of the feature’s role—the Next.js team wants developers to reach for proxy.ts as a last resort, not as a default place to put logic .

This chapter covers the proxy.ts convention, the three request-modification operations that matter most (rewrites, redirects, and header changes), the config.matcher that limits which paths the proxy runs on, and the runtime restriction that makes proxy.ts Node.js-only.

Key point: proxy.ts runs on every matching request before routing, which makes it the correct place for request-level control (auth redirects, header injection, URL rewriting) and the wrong place for data fetching, session management, or business logic .


Why the rename happened

The Express confusion problem. “Middleware” is overloaded. In the Express ecosystem, middleware is a chain of functions that process a request. In Next.js, the middleware file was a single entry point at the network boundary that ran before routing. The two concepts are different, and the shared name encouraged developers to treat the Next.js middleware file as a general-purpose request pipeline .

The last-resort problem. The Next.js documentation states that middleware was “highly capable” and that this capability “may encourage the usage; however, this feature is recommended to be used as a last resort” . The rename to “proxy” is intended to clarify the purpose: the file sits in front of the application, it handles network-level concerns, and it should not become a second application layer.

The runtime problem. proxy.ts runs on the Node.js runtime, not the Edge runtime. The runtime export and config.runtime are build errors in proxy.ts . A project that depends on the Edge runtime for its middleware must keep middleware.ts until it can migrate the logic. This is a real constraint, not a formality: a deployment target like Cloudflare Workers via OpenNext does not yet recognize proxy.ts, and renaming the file silently disables the proxy on that platform .


a. The proxy file and its export

The file is proxy.ts (or .js) at the project root, or inside src if the project uses a src directory. It must be at the same level as app or pages, not inside app/ . The exported function is named proxy, or it can be a default export .

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

export function proxy(request: NextRequest) {
  return NextResponse.next()
}

The NextResponse.next() return allows the request to continue without modification. Every other operation—rewrite, redirect, header change—is a variation on this base.

The config export with a matcher array controls which paths the proxy runs on. Without a matcher, it runs on every request, including static assets, which is almost never the intent .

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

The matcher accepts path patterns. A common pattern excludes the Next.js internals and static files: '/((?!api|_next/static|_next/image|favicon.ico).*)' . The proxy runs on every path that does not match those exclusions.


b. Rewrites

A rewrite changes where the request is routed internally without changing the URL in the browser. The client requests /api/public, the proxy rewrites it to /api/internal, and the browser still shows /api/public in the address bar .

export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname === '/api/public') {
    return NextResponse.rewrite(new URL('/api/internal', request.url))
  }
  return NextResponse.next()
}

The NextResponse.rewrite() call accepts a URL. The URL can be constructed from the request’s origin and a different path, or it can point to an entirely different domain. For a conditional rewrite based on a path prefix, the request.nextUrl.clone() pattern avoids reconstructing the URL from scratch.

if (request.nextUrl.pathname.startsWith('/uploads/')) {
  const newUrl = request.nextUrl.clone()
  newUrl.pathname = request.nextUrl.pathname.replace('/uploads/', '/api/uploads/')
  return NextResponse.rewrite(newUrl)
}

A rewrite in proxy.ts runs before routing, which matters when a dynamic route would otherwise catch the path. A [teamSlug] route at the root catches /uploads/image.png as teamSlug = "uploads" and produces a 404. A rewrite in the proxy runs before the dynamic route is matched, so the request reaches the intended handler . A rewrite in next.config.ts does not have this guarantee.


c. Redirects

A redirect sends the client to a different URL. The browser’s address bar updates, and the client makes a new request. The NextResponse.redirect() method returns a redirect response.

export function proxy(request: NextRequest) {
  const token = request.cookies.get('auth_token')
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  return NextResponse.next()
}

The auth check is the canonical example. The proxy reads a cookie, and if the cookie is absent and the path is protected, it redirects to the login page . This is an “optimistic check,” not full session management. The Next.js documentation is explicit: the proxy “should not be used as a full session management or authorization solution” . It can prevent an unauthenticated user from seeing the dashboard, but the actual authorization decision must still be enforced by the page or the data layer.

For simple redirects that do not depend on request properties, the redirects configuration in next.config.ts is the recommended mechanism. The proxy is for redirects that need access to cookies, headers, or other request data .


d. Header modification

The proxy can set request headers that are visible to the application, and response headers that are visible to the client. The two are not the same, and the distinction is a common source of confusion.

Setting request headers requires cloning the headers and passing them to NextResponse.next() under the request key.

export function proxy(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-hello-from-proxy', 'hello')

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

The request key is what makes the headers visible to the application. NextResponse.next({ headers: requestHeaders }) without the request key makes the headers visible to the client, not to the upstream application .

Setting response headers is direct. The response object is returned with the header set.

export function proxy() {
  const response = NextResponse.next()
  response.headers.set('x-app-env', 'production')
  return response
}

The response header is visible to the browser and to any downstream proxy. The documentation warns against setting large headers, because they can trigger a 431 Request Header Fields Too Large error depending on the backend server configuration .


Complete Example Session

// ============================================
// PART 1: THE BASE PROXY
// ============================================
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
// ============================================
// PART 2: AUTH REDIRECT
// ============================================
export function proxy(request: NextRequest) {
  const token = request.cookies.get('auth_token')
  const { pathname } = request.nextUrl

  if (!token && pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}
// ============================================
// PART 3: A CONDITIONAL REWRITE
// ============================================
export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname === '/api/public') {
    return NextResponse.rewrite(new URL('/api/internal', request.url))
  }
  return NextResponse.next()
}
// ============================================
// PART 4: A PATH PREFIX REWRITE
// ============================================
export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/uploads/')) {
    const newUrl = request.nextUrl.clone()
    newUrl.pathname = request.nextUrl.pathname.replace('/uploads/', '/api/uploads/')
    return NextResponse.rewrite(newUrl)
  }
  return NextResponse.next()
}
// ============================================
// PART 5: REQUEST HEADER INJECTION
// ============================================
export function proxy(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-tenant-id', 'acme')

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}
// ============================================
// PART 6: RESPONSE HEADER INJECTION
// ============================================
export function proxy() {
  const response = NextResponse.next()
  response.headers.set('x-app-env', 'production')
  return response
}
// ============================================
// PART 7: THE MATCHER
// ============================================
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

// The proxy runs only on these paths.
// Static assets and API routes are excluded.
// ============================================
// PART 8: THE EXCLUSION MATCHER
// ============================================
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

// Runs on everything except the excluded paths.
// ============================================
// PART 9: THE COOKIE SET
// ============================================
export function proxy() {
  const response = NextResponse.next()
  response.cookies.set('visited', 'true', {
    httpOnly: true,
    secure: true,
  })
  return response
}
// ============================================
// PART 10: THE CODEMOD
// ============================================
// npx @next/codemod@canary middleware-to-proxy .
//
// Renames middleware.ts → proxy.ts
// Renames middleware → proxy

The ten parts covered the base proxy, the auth redirect, the conditional rewrite, the prefix rewrite, the request header, the response header, the matcher, the exclusion matcher, the cookie set, and the codemod.


Quick Reference

The convention

ItemValue
File nameproxy.ts or proxy.js
LocationProject root or src/, same level as app
Exportexport function proxy(request) or default export
RuntimeNode.js only; runtime export is a build error
File countOne per project

Operations

OperationMethodEffect
ContinueNextResponse.next()Request proceeds unmodified
RewriteNextResponse.rewrite(url)Internal routing, URL unchanged
RedirectNextResponse.redirect(url)Client navigates, URL changes
Request headerNextResponse.next({ request: { headers } })Header visible upstream
Response headerresponse.headers.set()Header visible to client
Cookieresponse.cookies.set()Set-Cookie header

The matcher

PatternMatches
/dashboard/:path*Dashboard and subpaths
/((?!api|_next).*)Everything except excluded
OmittedEvery request

Migration

OldNew
middleware.tsproxy.ts
export function middlewareexport function proxy
config.matcherUnchanged
Edge runtimeNot supported in proxy.ts

Best Practices

✅ Do This:

// Use a matcher to limit the proxy's scope
export const config = { matcher: ['/dashboard/:path*'] }      // ✅
// Use the request key for upstream headers
NextResponse.next({ request: { headers: requestHeaders } })   // ✅
// Use next.config.ts redirects for simple cases
// Use proxy.ts only when request data is needed              // ✅

❌ Don’t Do This:

// Run the proxy on every path without a matcher
// Static assets and API routes are affected                  // ❌
// Set upstream headers without the request key
NextResponse.next({ headers: requestHeaders })                // ❌
// Use the proxy for data fetching or session management
// It is not intended for that                                       // ❌

Common Pitfalls

PitfallWhy It HappensFix
Proxy runs on static assetsNo matcher configAdd a matcher or an exclusion pattern
Upstream headers are missingrequest key omittedUse NextResponse.next({ request: { headers } })
Build error on runtimeproxy.ts is Node.js-onlyRemove the runtime export; keep middleware.ts if Edge is required
Proxy not detectedFile inside app/Move it to the project root or src/
Cloudflare deployment breaksOpenNext does not recognize proxy.tsKeep middleware.ts until OpenNext adds support

Real-World Examples

1. Auth redirect

if (!token && pathname.startsWith('/dashboard')) {
  return NextResponse.redirect(new URL('/login', request.url))
}

2. Path rewrite

return NextResponse.rewrite(new URL('/api/internal', request.url))

3. Request header

requestHeaders.set('x-tenant-id', 'acme')

4. Response header

response.headers.set('x-app-env', 'production')

5. Cookie

response.cookies.set('visited', 'true', { httpOnly: true })

6. Matcher

export const config = { matcher: ['/dashboard/:path*'] }

7. Exclusion matcher

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

8. The codemod

npx @next/codemod@canary middleware-to-proxy .

9. The rename

middleware.ts → proxy.ts
export function middleware → export function proxy

10. The runtime restriction

proxy.ts runs on Node.js only.
The runtime export is a build error.

Visual

The request lifecycle

┌─────────────────────────────────────────────────────────────┐
│  Request enters                                              │
│       │                                                      │
│       ▼                                                      │
│  proxy.ts runs                                               │
│    ├── rewrite                                               │
│    ├── redirect                                              │
│    └── header modification                                   │
│       │                                                      │
│       ▼                                                      │
│  Routing, layouts, pages, route handlers                     │
│       │                                                      │
│       ▼                                                      │
│  Response returned to client                                 │
└─────────────────────────────────────────────────────────────┘

The rewrite vs redirect

┌─────────────────────────────────────────────────────────────┐
│  REWRITE                      REDIRECT                       │
│                                                              │
│  Browser URL unchanged        Browser URL changes            │
│  Internal routing changes     New request from client        │
│  NextResponse.rewrite()       NextResponse.redirect()        │
└─────────────────────────────────────────────────────────────┘

The header distinction

┌─────────────────────────────────────────────────────────────┐
│  UPSTREAM HEADER                                             │
│    NextResponse.next({ request: { headers } })               │
│    → visible to the application                              │
│                                                              │
│  CLIENT HEADER                                               │
│    response.headers.set()                                    │
│    → visible to the browser                                  │
└─────────────────────────────────────────────────────────────┘

The matcher

┌─────────────────────────────────────────────────────────────┐
│  No matcher:                                                 │
│    Proxy runs on every request, including static assets.     │
│                                                              │
│  With matcher:                                               │
│    Proxy runs only on the matched paths.                     │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
File conventionproxy.ts at the project root
Exportproxy function or default export
RuntimeNode.js only; Edge requires middleware.ts
RewriteNextResponse.rewrite()
RedirectNextResponse.redirect()
Request headerNextResponse.next({ request: { headers } })
Response headerresponse.headers.set()
Matcherexport const config = { matcher: [...] }
Migrationnpx @next/codemod@canary middleware-to-proxy .

Key takeaways:

  • middleware.ts is now proxy.ts in Next.js 16. The functionality is the same, but the rename clarifies the purpose: the file sits at the network boundary in front of the application, and it should be used as a last resort, not as a general request pipeline .
  • The proxy can rewrite, redirect, and modify headers, but not fetch data. It is designed for request-level control: auth redirects, header injection, URL rewriting. It is not designed for slow data fetching or full session management .
  • The matcher limits the proxy to specific paths. Without it, the proxy runs on every request, including static assets. A matcher with an exclusion pattern is the standard configuration .
  • Request headers and response headers are set differently. The request key in NextResponse.next() makes headers visible upstream. response.headers.set() makes them visible to the client. Using the wrong one means the header is invisible where it is needed .
  • proxy.ts is Node.js-only. The runtime export is a build error. A project that requires the Edge runtime must keep middleware.ts, and a project deploying to Cloudflare via OpenNext must keep middleware.ts until OpenNext adds support .
  • The codemod handles the rename. npx @next/codemod@canary middleware-to-proxy . renames the file and the function, and leaves the config object unchanged .

Remember: proxy.ts is the renamed middleware file in Next.js 16. It runs before routing, it can rewrite, redirect, and modify headers, and it should be scoped with a matcher. Use it for request-level control, not for data fetching or business logic. Set upstream headers with the request key, client headers with response.headers.set(), and keep middleware.ts if the project depends on the Edge runtime or OpenNext.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!