If you learned Next.js middleware from a tutorial written before late 2025, about half of what you know is now wrong. In Next.js 16, the middleware.ts file convention was deprecated and renamed to proxy.ts. The old guides still rank on Google — and they teach you a filename your build now warns you about.
This is the 2026-correct guide to Next.js middleware: what proxy actually is, how to migrate from middleware.ts, the matcher config that controls everything, and six copy-paste patterns for auth guards, redirects, A/B tests, geo routing, security headers, and logging. Every behavior below is verified against the official Next.js 16.4 docs on October 12, 2026.
What Next.js middleware (proxy) actually is
A Next.js middleware — now called a proxy — is a single function that runs on the server before every matched request: before the cache, before rendering, before the route resolves. Depending on the incoming request, it can rewrite the path, redirect to a different URL, set request or response headers, or return a response directly.
The mental model that saves you from misusing it: proxy is a network boundary, not an Express-style middleware chain. It is not one function in a pipeline alongside your route logic. It is the gate in front of your entire app, and its job is to decide whether a request is even allowed to reach the app — and what it is allowed to carry in with it.
Next.js itself wants you to treat this feature as a last resort. The official migration page says the team is building better APIs with better ergonomics precisely so you reach for proxy less often. The jobs that genuinely belong here:
- Auth redirects — bouncing unauthenticated visitors to
/login - Locale detection and geo-based routing
- A/B test cookie bucketing
- Canonical-host redirects and legacy path rewrites
Notice what is not on the list: business logic, database writes, and anything slow. Proxy runs before every matched request, so every millisecond you add is paid on every single page view.
What changed: middleware.ts → proxy.ts
Next.js 16 (October 2025) renamed the convention. The change is mechanical, but the runtime change underneath it is the one that matters:
middleware.ts (≤ Next.js 15) |
proxy.ts (Next.js 16+) |
|
|---|---|---|
| File name | middleware.ts |
proxy.ts |
| Exported function | export function middleware() |
export function proxy() |
| Runtime | Edge runtime | Node.js only — the runtime config option no longer exists; setting it throws an error |
| Status | Deprecated — prints a warning on every build | Current convention |
The rename happened for two reasons, straight from the official migration page. First, the word “middleware” made people think of Express.js middleware — a function in a chain — which encouraged misuse. Second, middleware was so capable that developers reached for it everywhere, which is exactly what the team wants to discourage. The name “proxy” frames it as what it is: a network boundary in front of your app.
The practical fallout: on Node.js, your proxy can use Node’s built-in crypto, real database drivers, and any npm package that assumes Node — no more auditing packages for Edge compatibility. What you lose is Edge’s global low-latency execution for this boundary. For auth, redirects, and rewrites, that trade is invisible.
How to migrate
Next.js ships a codemod for the mechanical part. Run it from your project root:
npx @next/codemod@canary middleware-to-proxy .
It renames middleware.ts to proxy.ts and the exported function from middleware to proxy. After migrating, every build prints this if the old file is still around:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
Two gotchas worth knowing:
- Having both
middleware.tsandproxy.tsin a project is an error — Next.js tells you to keep only the proxy. - The old file still works for now, but it is deprecated and the official guidance is to migrate. After the rename, rerun your route and security tests — proxy changes are silent by nature.
Your first proxy.ts
Create proxy.ts at the project root — or inside src/ if you use one, at the same level as app/. The file must export a single function, either named proxy or as the default export. Multiple proxy functions from one file are not supported.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/about')) {
return NextResponse.rewrite(new URL('/about-2', request.url))
}
}
If the function returns nothing, the request continues normally — that is NextResponse.next() by implication. Next.js also calls your function with a second argument, event (a NextFetchEvent), which exposes waitUntil() for background work like logging — more on that in the patterns below.
The matcher: the single most important config
Here is the default behavior most people get wrong: without a matcher, proxy runs on every request — including _next/static, _next/image, and every asset in your public/ folder. Auth logic or redirects with no matcher can silently block your CSS, JavaScript, and images from loading.
Scope proxy to the routes that need it:
// proxy.ts
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Or exclude what it must never touch, using the negative-match pattern from the official docs:
export const config = {
matcher: [
// Exclude API routes, static files, image optimization, and .png files
'/((?!api|_next/static|_next/image|.*\\.png$).*)',
],
}
Matcher rules that matter:
- Matcher values must be constants so they can be statically analyzed at build time — dynamic values are silently ignored.
- Path patterns support named parameters with modifiers:
/about/:path*matches/about/a/b/c(*is zero or more,?is zero or one,+is one or more), plus full regex in parentheses. - For conditional matching you can use object form with
source,locale,has(header, query, or cookie conditions), andmissing— handy for “only run when this cookie is absent”. - Execution order:
next.config.jsheaders →next.config.jsredirects → proxy →beforeFilesrewrites → filesystem routes →afterFilesrewrites → dynamic routes →fallbackrewrites. Proxy sits near the front of that chain, which is exactly why it is powerful and dangerous.
One more subtlety with real security consequences: Server Actions are not separate routes. They are POST requests to the route they are defined on, so a matcher that excludes a path also skips proxy coverage for Server Action calls on that path. A matcher change can silently remove proxy-based auth from a mutation. The official docs say it plainly: always verify authentication and authorization inside each Server Action rather than relying on proxy alone — defense in depth, not blind trust.
Six practical proxy patterns
1. Auth guard with JWT verification
The right way to do auth in proxy: verify a self-contained JWT, not a database session lookup. Reading a cookie and checking a signature is microseconds; a round trip to your database on every matched request is the kind of latency tax that compounds. jose is the standard JWT library here — install it with npm install jose.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
function loginRedirect(request: NextRequest) {
const url = new URL('/login', request.url)
url.searchParams.set('callbackUrl', request.nextUrl.pathname)
return NextResponse.redirect(url)
}
export async function proxy(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) return loginRedirect(request)
try {
const { payload } = await jwtVerify(token, SECRET)
const response = NextResponse.next()
// Pass the user id downstream — proxy talks to your app via headers
response.headers.set('x-user-id', String(payload.sub))
return response
} catch {
return loginRedirect(request)
}
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
Notice the matcher only covers the protected routes, and the user id is handed to the app as a request header — the documented way to pass information from proxy to your application (headers, cookies, rewrites, redirects, or the URL).
2. Redirects and rewrites
Redirects that belong in proxy are the ones that need request-time logic — everything static should live in next.config.js redirects instead.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
// Force a single canonical host (308 preserves method + body)
const host = request.headers.get('host')
if (host && host !== 'example.com') {
const url = request.nextUrl.clone()
url.host = 'example.com'
return NextResponse.redirect(url, 308)
}
// Serve old blog URLs from the new location without changing the URL bar
if (request.nextUrl.pathname.startsWith('/blog/2024/')) {
return NextResponse.rewrite(
new URL(request.nextUrl.pathname.replace('/blog/2024/', '/archive/'), request.url)
)
}
}
Redirect changes what the visitor sees in the address bar; rewrite serves different content at the same URL. For SEO, the 308 on the canonical host is the one that matters.
3. A/B test cookie bucketing
Proxy runs before rendering, which makes it the natural place to assign visitors to experiment buckets before the page ever renders — no flicker, no client-side race.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const response = NextResponse.next()
if (!request.cookies.has('ab_bucket')) {
const bucket = Math.random() < 0.5 ? 'control' : 'variant'
response.cookies.set('ab_bucket', bucket, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
})
}
return response
}
export const config = {
matcher: ['/pricing', '/signup'],
}
Your pages then read the ab_bucket cookie and render the matching variant. Keep the matcher tight — bucketing logic has no business running on your API routes or static assets.
4. Geo-based locale routing
On Vercel, the platform injects geo headers into every request — x-vercel-ip-country carries the ISO country code. (On Cloudflare the equivalent is cf-ipcountry.) These headers are absent in local development, so always handle null.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
// Vercel-only header; null when running locally — bail out gracefully
const country = request.headers.get('x-vercel-ip-country')
if (!country) return NextResponse.next()
const { pathname } = request.nextUrl
if (country === 'DE' && !pathname.startsWith('/de')) {
return NextResponse.redirect(new URL(`/de${pathname}`, request.url))
}
return NextResponse.next()
}
The !pathname.startsWith('/de') guard is load-bearing: without it, the redirect targets a path that matches the same rule, and you get an infinite redirect loop. Every proxy redirect should be checked for exactly this.
5. Security headers on every response
A small, static set of headers is one of the few things that legitimately belongs on every response — and proxy guarantees it runs regardless of which page or route handler answers.
// proxy.ts
import { NextResponse } from 'next/server'
export function proxy() {
const response = NextResponse.next()
response.headers.set('x-frame-options', 'DENY')
response.headers.set('x-content-type-options', 'nosniff')
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin')
response.headers.set(
'permissions-policy',
'camera=(), microphone=(), geolocation=()'
)
return response
}
6. Request logging that doesn’t slow the response
The second proxy argument, event, gives you waitUntil(): it keeps the proxy invocation alive after the response is sent so background work like analytics can finish. Your logging never blocks the response.
// proxy.ts
import type { NextFetchEvent, NextRequest } from 'next/server'
export function proxy(request: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch('https://logs.example.com/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
path: request.nextUrl.pathname,
at: Date.now(),
}),
}).catch(() => {
// Logging must never break a request
})
)
}
This is the correct shape for any fire-and-forget work in proxy: send the response fast, let the platform finish the logging after.
What does not belong in proxy.ts
- Heavy imports and slow work. Proxy runs before every matched request — every millisecond of import time and compute is multiplied across your entire traffic. Keep the file lean; audit what you import.
- Your only auth layer. As covered above, Server Actions ride on POST requests to their route, so matcher gaps silently skip proxy-based auth. Verify auth inside each Server Action too — the official docs point you to the Data Security guide for the pattern.
- Shared mutable state or globals. In optimized deployments proxy can be pushed to the CDN for fast redirect/rewrite handling, so the docs explicitly warn against relying on shared modules or globals.
- Blocking the response on analytics. If your logging has to happen, use
waitUntil()— neverawaita fetch before returning the response.
If you are weighing whether something belongs in proxy or in a Server Component / Route Handler, the tiebreaker is the official guidance: proxy is the last resort. If another API answers the question, use that API.
Testing and debugging proxy locally
console.loginside proxy prints to yournext devterminal — the fastest way to see what matched and what your function returned.- Start with an intentionally narrow matcher and widen it. Debugging “why did proxy touch this asset” is much harder than debugging “why didn’t it run here”.
- If assets 404 or pages redirect-loop after you add proxy, the matcher is the first suspect — remember the default is every request.
- After migrating from
middleware.ts, grep for stale imports of the old filename and rerun your route and security tests. Proxy changes are silent by nature; your test suite is the only thing that notices.
The rename from middleware to proxy looks cosmetic until you internalize what it is telling you: this file is a network boundary, it runs on Node.js now, and you should reach for it last, not first. Write it lean, scope it with a matcher, and keep your auth checks in two places — proxy for the redirect, your Server Actions for the truth.
Further Reading & References
- Next.js docs: proxy.js file convention — the canonical reference (v16.4), covering the function signature, matcher, runtime, and execution order
- Next.js docs: Renaming Middleware to Proxy — the official rationale and the
middleware-to-proxycodemod - Next.js Middleware in 2026: Auth Guards, A/B Tests, and What Belongs at the Edge (dev.to) — practical take on what belongs in the boundary and the latency-budget framing
- Next.js proxy.ts Explained (with Cheat Sheet) (dev.to) — the network-boundary mental model and Node-runtime implications
- Next 16 renamed middleware to proxy.ts, and ours does three unrelated jobs (dev.to) — a real-world migration account, including the cookie-based geo pattern
- How does Next.js compute the built-in geo localization data (vercel/next.js discussion) — why geo data is platform-provided headers, not a Next.js feature
Related on HowToShipIt: How to Fix the Most Common Next.js Build Errors, How to Fix React Hydration Errors in Next.js, and How to Add a PWA to Your Next.js App.



