🚦 Chapter 17: Proxy (formerly Middleware)
Code that runs before a request reaches your app — renamed in Next.js 16, and deliberately harder to over-use.
📖 Plain English Explanation
Sometimes you need to do something to a request before Next.js decides what to render.
- Redirect logged-out users away from
/dashboardwithout rendering it first - Rewrite
acme.comandbob.acme.comto different content behind one app - Detect a visitor's country and send them to a localized route
- Add a security header to every response
- Route 10% of traffic to a variant for an A/B test
That's what Proxy does. One file at your project root, one exported function, and it runs on every matching request before routing happens.
If you've used Next.js before, you know this as middleware.ts. It was renamed in Next.js 16, and the reasoning behind the rename is worth taking seriously.
🏷️ The rename
⚠️ Changed in Next.js 16
middleware.tsis deprecated and renamed toproxy.ts. The exported function is renamed too.bashmv middleware.ts proxy.tsts// ❌ Next.js 15 export function middleware(request: NextRequest) {} // ✅ Next.js 16 export function proxy(request: NextRequest) {}Config flags renamed as well:
Old New skipMiddlewareUrlNormalizeskipProxyUrlNormalizeCodemod:
bashnpx @next/codemod@canary middleware-to-proxy .The
edgeruntime is not supported inproxy. Proxy runs on Node.js, and theruntimeconfig option throws if you set it. If you specifically need the edge runtime, keep usingmiddlewarefor now.
Why they renamed it
Two reasons, and the second one is the important one.
First, "middleware" made people think of Express middleware — a chain of handlers where you do request processing, database lookups, and business logic. Next.js's version is nothing like that. It runs at a network boundary, potentially on a CDN, outside your app's main runtime. "Proxy" describes that accurately.
Second, and more bluntly: the Next.js team wants you to use it less. From the official docs:
We recommend users avoid relying on Middleware unless no other options exist.
Proxy runs on every matching request, including prefetches. It's a shared bottleneck, it can't access your database easily, and it's a common source of subtle bugs. Most things people reach for it are better done elsewhere — auth checks in the Data Access Layer, redirects in next.config.ts, headers in next.config.ts.
Treat it as a last resort, not a first tool.
📄 The basic shape
Create proxy.ts at your project root — the same level as app/, or inside src/ if you use it:
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
export const config = {
matcher: '/about/:path*',
}
// proxy.js
import { NextResponse } from 'next/server'
export function proxy(request) {
return NextResponse.redirect(new URL('/home', request.url))
}
export const config = {
matcher: '/about/:path*',
}
The file exports one function — either a default export or a named proxy export. Multiple proxies in one file aren't supported. It can be async.
There's also a shorthand type:
// proxy.ts
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request, event) => {
return NextResponse.next()
}
🎯 The matcher — get this right first
Without a matcher, Proxy runs on every single request — including _next/static, _next/image, and everything in public/. Auth logic without a matcher will happily redirect your CSS file to the login page.
Always set one. The standard pattern is a negative lookahead:
// proxy.ts
export const config = {
matcher: [
/*
* Match everything except:
* - api routes
* - _next/static (build output)
* - _next/image (optimized images)
* - favicon.ico, sitemap.xml, robots.txt
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}
Simpler forms:
export const config = { matcher: '/about' } // one path
export const config = { matcher: ['/about', '/contact'] } // several
export const config = { matcher: '/dashboard/:path*' } // a subtree
Pattern rules:
| Pattern | Matches |
|---|---|
/about | /about and /about/team (anchored at the start) |
/about/:path | /about/a, but not /about/a/b |
/about/:path* | zero or more segments — /about, /about/a/b/c |
/about/:path+ | one or more segments |
/about/:path? | zero or one segment |
/about/(.*) | same as :path* |
Conditional matching with has and missing
export const config = {
matcher: [
{
source: '/api/:path*',
has: [{ type: 'header', key: 'Authorization' }],
missing: [{ type: 'cookie', key: 'session' }],
},
],
}
A common use — skip Proxy for prefetch requests:
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' },
],
},
],
}
Two matcher gotchas
Matchers must be static. They're analyzed at build time, so a variable is silently ignored:
const paths = ['/dashboard']
export const config = { matcher: paths } // ❌ ignored
_next/data always runs Proxy, even if your negative matcher excludes it. That's deliberate — it stops you protecting a page but forgetting its data route.
🔧 What you can do
Redirect
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const session = request.cookies.get('session')
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = new URL('/login', request.url)
url.searchParams.set('from', request.nextUrl.pathname) // return here after login
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// proxy.js
import { NextResponse } from 'next/server'
export function proxy(request) {
const session = request.cookies.get('session')
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = new URL('/login', request.url)
url.searchParams.set('from', request.nextUrl.pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Rewrite
The URL in the address bar stays; the content comes from elsewhere. This is how multi-tenancy works:
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const hostname = request.headers.get('host') ?? ''
const subdomain = hostname.split('.')[0]
if (subdomain && subdomain !== 'www' && !hostname.startsWith('localhost')) {
// bob.acme.com/pricing → renders /tenants/bob/pricing
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
)
}
return NextResponse.next()
}
// proxy.js
import { NextResponse } from 'next/server'
export function proxy(request) {
const hostname = request.headers.get('host') ?? ''
const subdomain = hostname.split('.')[0]
if (subdomain && subdomain !== 'www' && !hostname.startsWith('localhost')) {
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
)
}
return NextResponse.next()
}
Set request headers (visible to your app)
The syntax here is easy to get wrong:
// proxy.ts
export function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', crypto.randomUUID())
// ✅ makes the header available UPSTREAM to your pages/handlers
return NextResponse.next({
request: { headers: requestHeaders },
})
}
// app/page.tsx
import { headers } from 'next/headers'
export default async function Page() {
const id = (await headers()).get('x-request-id')
return <p>Request: {id}</p>
}
NextResponse.next({ request: { headers } })sends headers to your app.NextResponse.next({ headers })sends them to the browser. These are different things and the one-word difference is easy to miss.
Set response headers
// proxy.ts
export function proxy(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
For static security headers, prefer next.config.ts — it doesn't cost you a Proxy invocation:
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
],
},
]
},
}
Use Proxy only when the header value depends on the request — a per-request CSP nonce, for example.
Cookies
// proxy.ts
export function proxy(request: NextRequest) {
// read from the request
const theme = request.cookies.get('theme')?.value
const all = request.cookies.getAll()
request.cookies.has('session')
// write to the response
const response = NextResponse.next()
response.cookies.set('last-seen', new Date().toISOString(), {
httpOnly: true,
sameSite: 'lax',
path: '/',
})
return response
}
Respond directly
// proxy.ts
import type { NextRequest } from 'next/server'
import { isAuthenticated } from '@/lib/auth'
export const config = { matcher: '/api/:path*' }
export function proxy(request: NextRequest) {
if (!isAuthenticated(request)) {
return Response.json({ error: 'authentication failed' }, { status: 401 })
}
}
Returning nothing (undefined) is the same as NextResponse.next() — continue as normal.
Background work with waitUntil
The second parameter is a NextFetchEvent with waitUntil(), which keeps the invocation alive past the response:
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'
export function proxy(request: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch('https://analytics.example.com/hit', {
method: 'POST',
body: JSON.stringify({ path: request.nextUrl.pathname }),
})
)
return NextResponse.next()
}
// proxy.js
import { NextResponse } from 'next/server'
export function proxy(request, event) {
event.waitUntil(
fetch('https://analytics.example.com/hit', {
method: 'POST',
body: JSON.stringify({ path: request.nextUrl.pathname }),
})
)
return NextResponse.next()
}
🔐 Proxy is not your security boundary
This is the most important thing in the chapter.
An auth check in proxy.ts is an optimistic check — good for UX, cheap, catches the common case early. It is not authorization.
Reasons it can't be:
-
A matcher change silently removes coverage. Someone edits the regex six months from now and a route quietly becomes public.
-
Server Actions aren't separate routes. They're POSTs to the route that uses them. Move a Server Action to a different route, or exclude that path in your matcher, and the action loses Proxy coverage — with no error.
-
Proxy runs at a network boundary. It can't easily reach your database, so it can typically verify only that a cookie exists — not that the session is valid, unrevoked, and permitted for this resource.
-
It can't do per-resource checks. "Is this user allowed to see this invoice?" needs the invoice.
The correct layering
// proxy.ts — layer 1: cheap, optimistic
export function proxy(request: NextRequest) {
const hasSession = request.cookies.has('session')
if (!hasSession && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// lib/dal.ts — layer 2: the real check, next to the data
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
if (!token) return null
const session = await decrypt(token) // verify signature
if (!session || session.expiresAt < Date.now()) return null
if (await isRevoked(session.id)) return null // check revocation
return session
})
export const getInvoice = cache(async (id: string) => {
const session = await verifySession()
if (!session) return null
// per-resource authorization — impossible in Proxy
return db.invoice.findFirst({
where: { id, organizationId: session.orgId },
})
})
Proxy makes the app feel fast. The DAL makes it correct. You need both, and only one of them is optional.
🌍 Practical patterns
Geolocation-based routing
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const LOCALES = ['en', 'fr', 'de', 'ja'] as const
const DEFAULT = 'en'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = LOCALES.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
)
if (hasLocale) return NextResponse.next()
const preferred = request.cookies.get('locale')?.value
const header = request.headers.get('accept-language') ?? ''
const fromHeader = header.split(',')[0]?.split('-')[0]
const locale =
(LOCALES as readonly string[]).includes(preferred ?? '') ? preferred
: (LOCALES as readonly string[]).includes(fromHeader) ? fromHeader
: DEFAULT
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
A/B testing
// proxy.ts
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname !== '/pricing') return NextResponse.next()
let variant = request.cookies.get('ab-pricing')?.value
if (!variant) {
variant = Math.random() < 0.5 ? 'a' : 'b'
}
const response = NextResponse.rewrite(
new URL(`/pricing-${variant}`, request.url)
)
response.cookies.set('ab-pricing', variant, {
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
return response
}
Setting the cookie is what makes the assignment sticky — without it, users get a different variant on every page load.
Maintenance mode
// proxy.ts
export function proxy(request: NextRequest) {
if (
process.env.MAINTENANCE_MODE === 'true' &&
request.nextUrl.pathname !== '/maintenance' &&
!request.cookies.has('bypass-maintenance')
) {
return NextResponse.rewrite(new URL('/maintenance', request.url))
}
return NextResponse.next()
}
CORS for API routes
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const allowedOrigins = ['https://acme.com', 'https://partner.org']
const corsOptions = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export function proxy(request: NextRequest) {
const origin = request.headers.get('origin') ?? ''
const isAllowed = allowedOrigins.includes(origin)
if (request.method === 'OPTIONS') {
return NextResponse.json(
{},
{ headers: { ...(isAllowed && { 'Access-Control-Allow-Origin': origin }), ...corsOptions } }
)
}
const response = NextResponse.next()
if (isAllowed) response.headers.set('Access-Control-Allow-Origin', origin)
Object.entries(corsOptions).forEach(([k, v]) => response.headers.set(k, v))
return response
}
export const config = { matcher: '/api/:path*' }
📋 Execution order
Worth knowing when a redirect doesn't fire where you expected:
1. headers (next.config.ts)
2. redirects (next.config.ts)
3. ⭐ Proxy
4. beforeFiles rewrites (next.config.ts)
5. Filesystem routes (public/, _next/static/, app/)
6. afterFiles rewrites (next.config.ts)
7. Dynamic routes (/blog/[slug])
8. fallback rewrites (next.config.ts)
next.config.ts redirects run before Proxy. If a config redirect already fired, your Proxy never sees the request.
🧪 Testing it
Next.js ships experimental test utilities:
// proxy.test.ts
import { unstable_doesProxyMatch, isRewrite, getRewrittenUrl } from 'next/experimental/testing/server'
import { NextRequest } from 'next/server'
import { proxy, config } from './proxy'
import nextConfig from './next.config'
test('does not run on static files', () => {
expect(
unstable_doesProxyMatch({ config, nextConfig, url: '/_next/static/chunk.js' })
).toEqual(false)
})
test('rewrites tenant subdomains', async () => {
const request = new NextRequest('https://bob.acme.com/pricing')
const response = await proxy(request)
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toContain('/tenants/bob/pricing')
})
Testing your matcher is genuinely worth it. A matcher regex that accidentally excludes /dashboard is a security incident that no type checker will catch.
⚠️ Common Pitfalls
1. No matcher
Proxy runs on every asset request. Your CSS gets redirected to /login. Always set a matcher.
2. Still calling it middleware
// ❌ Next.js 16 — deprecated, and the file must be renamed too
export function middleware(request) {}
Run the codemod.
3. Setting runtime: 'edge'
Throws in Next.js 16. Proxy is Node.js-only. If you need edge, stay on middleware.
4. Treating Proxy as authorization
Covered above. The Server Action gap alone makes it unsafe on its own.
5. Confusing the two next() shapes
NextResponse.next({ request: { headers } }) // → to your app
NextResponse.next({ headers }) // → to the browser
6. Heavy work in Proxy
It runs on every matching request, including prefetches. A database call or a JWT verification against a remote service multiplies latency across your entire site.
7. Dynamic matcher values
Silently ignored. Matchers are analyzed at build time and must be literals.
8. Redirect loops
// ❌ /login has no session either → infinite loop
export function proxy(request: NextRequest) {
if (!request.cookies.has('session')) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
Always exclude the destination:
const isPublic = ['/login', '/register', '/forgot-password'].includes(
request.nextUrl.pathname
)
if (!request.cookies.has('session') && !isPublic) {
return NextResponse.redirect(new URL('/login', request.url))
}
9. Relying on shared modules or globals
Proxy may be deployed separately from your app, potentially to a CDN. Don't assume it shares module state with your server code.
🎯 When & Why to Use
Use Proxy when:
✅ You need a decision BEFORE routing (redirect, rewrite)
✅ Multi-tenant subdomain or path rewriting
✅ Locale detection and redirect
✅ A/B test bucketing
✅ Per-request headers (CSP nonce)
✅ An optimistic auth redirect for UX
Use something else when:
❌ Static redirects → next.config.ts redirects
❌ Static headers → next.config.ts headers
❌ Real authorization → the Data Access Layer
❌ Data fetching → Server Components
❌ Mutations → Server Actions
❌ API logic → Route Handlers
The Next.js team's own guidance is to avoid Proxy unless no other option exists. Before writing one, ask what would happen if you did the same thing in a layout, a DAL function, or next.config.ts — usually one of those is the better answer.
🏋️ Mini Practice Problems
Problem 1: Fix the matcher
This Proxy breaks the site — images don't load and the login page infinitely redirects:
export function proxy(request: NextRequest) {
if (!request.cookies.has('session')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
Fix both bugs.
Problem 2: Migrate
// middleware.ts
import { NextResponse } from 'next/server'
export const config = {
matcher: '/api/:path*',
runtime: 'edge',
}
export function middleware(request) {
return NextResponse.next()
}
Bring it to Next.js 16. What breaks, and what's the fix?
Problem 3: Security review
// proxy.ts
export function proxy(request: NextRequest) {
const token = request.cookies.get('token')?.value
if (!token) return NextResponse.redirect(new URL('/login', request.url))
return NextResponse.next()
}
export const config = { matcher: '/admin/:path*' }
// app/admin/actions.ts
'use server'
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } })
}
A reviewer says "admin is protected." Explain at least three ways an attacker gets through, and fix it.
Problem 4: Build it
Write a proxy.ts that:
- Redirects unauthenticated users from
/app/*to/login?from=<path> - Rewrites
*.acme.comto/tenants/<sub>/* - Adds a per-request CSP nonce as a request header
- Skips static assets and prefetch requests
- Never loops on
/loginor/register
💼 Interview Notes
Common Questions
Q: What is Proxy in Next.js 16?
A proxy.ts file at the project root exporting a proxy function that runs before a request is routed. It can redirect, rewrite, set request or response headers, manipulate cookies, or respond directly. It was called middleware before Next.js 16.
Q: Why was it renamed? "Middleware" implied Express-style request processing, which encouraged putting business logic there. "Proxy" describes what it actually is: a network boundary in front of the app, potentially deployed to a CDN. The rename is also a signal that the team wants it used less.
Q: What runtime does Proxy use?
Node.js, and it's not configurable — setting runtime throws. Next.js 15 middleware could run on the edge; Proxy cannot.
Q: Why is a Proxy auth check insufficient? It's optimistic. A matcher edit silently drops coverage; Server Actions are POSTs to their host route so matcher changes can un-protect them; Proxy runs at a network boundary with limited data access, so it typically only checks that a cookie exists, not that the session is valid or that this user may access this resource.
Q: What happens without a matcher?
Proxy runs on every request, including _next/static, _next/image, and public/. Auth logic will redirect your assets.
Q: Difference between a redirect and a rewrite? A redirect sends a 3xx and changes the browser's URL. A rewrite keeps the URL and serves content from a different route — invisible to the user. Rewrites are how multi-tenancy and A/B tests work.
Q: How do you pass data from Proxy to your app?
Request headers via NextResponse.next({ request: { headers } }), cookies, or the URL. Don't rely on shared modules or globals — Proxy may run in a separate environment.
🏢 Asked at Companies
- Vercel: "Implement subdomain-based multi-tenancy. Where does the routing decision live?"
- Stripe: "A team put their entire authorization system in middleware. What do you tell them?"
- Shopify: "Design locale detection and redirection. What are the caching implications?"
- Cloudflare: "Why did Next.js move Proxy to the Node.js runtime, and what did that cost?"
📊 Visual Memory Aid
REQUEST LIFECYCLE
browser
│
▼
next.config headers
│
▼
next.config redirects
│
▼
⭐ proxy.ts ──► redirect / rewrite / headers / respond
│
▼
filesystem routes (public/, _next/, app/)
│
▼
dynamic routes (/blog/[slug])
│
▼
your page renders
REDIRECT vs REWRITE
redirect: /old ──302──► browser goes to /new
URL bar: /new 👀 user sees it
rewrite: /pricing ──► serves /pricing-b
URL bar: /pricing 🙈 invisible
SECURITY LAYERS
┌─ proxy.ts ─────────────────────────┐
│ "does a session cookie exist?" │ optimistic, fast
│ → redirect to /login │ ⚠️ NOT a boundary
└────────────────┬───────────────────┘
│
┌────────────────▼───────────────────┐
│ Data Access Layer │ ✅ THE boundary
│ verify signature │
│ check expiry + revocation │
│ check THIS user ↔ THIS resource │
└────────────────────────────────────┘
Server Actions bypass matchers.
Route Handlers bypass layouts.
Only the DAL sees everything.
MATCHER ESSENTIALS
no matcher ──► runs on EVERY request (including CSS!)
'/((?!api|_next/static|_next/image|favicon.ico).*)'
└── the standard exclusion list, memorize it
matchers must be LITERAL — variables are ignored
_next/data always runs proxy, even if excluded
🎯 Key Takeaways
middleware.tsis nowproxy.ts, the function isproxy,skipMiddlewareUrlNormalizeisskipProxyUrlNormalize, and the edge runtime is gone. Runnpx @next/codemod@canary middleware-to-proxy .- Always set a matcher. Without one, Proxy intercepts every asset request and your auth redirect will break your stylesheets.
- Proxy is not a security boundary. Matcher edits silently drop coverage and Server Actions can escape it entirely. Authorization belongs in the Data Access Layer.
- Redirect changes the URL; rewrite doesn't. Rewrites are the mechanism behind multi-tenancy, A/B tests, and maintenance modes.
- Use it as a last resort. Static redirects and headers belong in
next.config.ts; data belongs in Server Components; mutations belong in Server Actions. That's the framework's own recommendation.
Next Chapter: Authentication & Authorization →
Practice: Write a proxy.ts handling subdomain rewrites, an auth redirect, and locale detection. Then write tests with unstable_doesProxyMatch proving it never runs on _next/static and never loops on /login.