Next.js Middleware (Now Proxy): What It Is and When to Use It

TL;DR
- Next.js 16 (October 2025) renamed middleware to proxy: the file is proxy.ts and the exported function is proxy. middleware.ts still works but is deprecated.
- Proxy runs before a route renders and can redirect, rewrite, set request or response headers, or return a response directly.
- It runs on the Node.js runtime, and the runtime cannot be changed in a proxy file. The Edge runtime is only available through the deprecated middleware.ts.
- Static redirects, rewrites and headers belong in next.config. Use proxy when the decision depends on the request: cookies, headers, location or an experiment bucket.
- Use proxy for optimistic auth checks, such as redirecting signed-out visitors. Six middleware/proxy bypass advisories since March 2025 are why authorization has to happen again next to the data.
- One proxy file per project, scoped with a matcher so it skips static assets.
- Next.js 15 reaches end of support on October 21, 2026. Apps still on 15.x should plan the move to 16, where middleware becomes proxy.
What Is Next.js Middleware (Now Proxy)?
Next.js middleware is a function that runs on the server before a route renders. Based on the incoming request, it can redirect, rewrite to another route, change request or response headers, or answer directly. Since Next.js 16 it lives in proxy.ts and is called proxy; the behaviour is the same.

The Next.js docs explain the new name: “middleware” was too easily confused with Express middleware, a chain of handlers inside the app. Proxy sits in front of the app, at its network boundary, and every matching request passes through it before the app sees it.
Middleware Is Now Proxy: What Changed in Next.js 16
Next.js 16 deprecated the middleware file convention and introduced proxy in its place. The logic you write stays the same. What changed around it:
| What | middleware.ts (before 16) | proxy.ts (16 and later) |
| File and export | middleware.ts, export function middleware | proxy.ts, export function proxy or a default export |
| Default runtime | Edge; Node.js stable from 15.5 | Node.js |
| Edge runtime | yes | no: setting runtime in a proxy file throws an error |
| Status | deprecated, to be removed in a future version | current |
The runtime change matters most. On Node.js, proxy can use Node.js APIs and packages that the Edge runtime did not support. If a team needs Edge execution, middleware.ts is the only route left, and it is on its way out.
The docs also changed their stance. They now recommend avoiding middleware “unless no other options exist”, and the rename is part of moving features out of it into dedicated APIs.
How Next.js Proxy Works
Proxy runs once per matching request, after the headers and redirects in next.config and before any route is resolved. The full order, from the proxy.js reference:
headersfromnext.config.jsredirectsfromnext.config.js- Proxy (rewrites, redirects, headers, responses)
beforeFilesrewrites fromnext.config.js- Filesystem routes (
public/,_next/static/,pages/,app/) afterFilesrewrites- Dynamic routes such as
/blog/[slug] fallbackrewrites
Proxy runs separately from your render code, so it cannot share module state or globals with your pages. To pass something on, set a request header, a cookie, a rewrite or the URL. The basic shape:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}NextResponse.next() lets the request continue. NextResponse.redirect() sends the browser to a new URL (an HTTP 3xx). NextResponse.rewrite() serves a different route while the address bar stays the same. The function can be async.
When to Use Proxy, and When next.config Is Enough
Use proxy when the decision depends on something in the request: a cookie, a header, the visitor’s country or an experiment bucket. If the rule is the same for every visitor, next.config does it with less code and without running a function on each request. If the decision needs data or a permission check, it belongs where the data is read. For location, NextRequest lost its geo property in Next.js 15: the hosting provider supplies it, on Vercel through geolocation() from @vercel/functions.
| What you need | Where it belongs | Example below |
| Redirects from old URLs, headers that are the same on every response | redirects and headers in next.config | none needed |
| Headers that depend on the request, such as a CORS origin from an allow-list | proxy | CORS for API Routes |
| Redirects or rewrites by cookie, header, locale or A/B bucket | proxy | A/B Test Rewrite, Locale Redirect |
| Sending signed-out visitors to the login page | proxy, as an optimistic check | Optimistic Authentication Redirect |
| Protecting static pages that share data between users (a paywall) | proxy, as the only check those pages get; see the authentication section | Optimistic Authentication Redirect |
| A 503 for every page during a planned outage | proxy | Maintenance Mode |
| Deciding whether this user may read or change this record | the Data Access Layer, Route Handler or Server Action | see the authentication section |
| Fetching data, slow lookups | the route itself; fetch caching options have no effect in proxy | none |
The Proxy guide lists the same cases: modifying headers for all pages or a subset of them, rewrites for A/B tests and programmatic redirects based on the request. It says proxy “should not be used as a full session management or authorization solution”.
Redirect Status Codes and SEO
NextResponse.redirect() sends a 307 unless you pass another status, so a redirect written in proxy is temporary by default. For a page that has moved for good, use redirects in next.config with permanent: true, which sends a 308, or pass the status yourself: NextResponse.redirect(url, 308). Google treats a 308 like a 301: it uses the redirect as a signal that the target should be the canonical URL. A 307, like a 302, is followed without that signal. Next.js uses 307 and 308 instead of 302 and 301 because they keep the request method. Login and locale redirects should stay temporary. Redirects in next.config pass the query string through to the destination; a redirect built in proxy keeps only what you put into its URL, so clone request.nextUrl when the query matters. Renaming middleware.ts to proxy.ts changes none of this: the same code returns the same status codes.
Migrating to Next.js 16 or reviewing your auth setup?
Next.js Proxy and Authentication: Proxy Is for Optimistic Checks
Proxy can redirect signed-out users, but it must not be the only thing protecting your data. The Next.js authentication guide splits checks in two:
- Optimistic check: reads the session from the cookie and redirects. This is the proxy’s job.
- Secure check: verifies the session and the permission next to the data, in a Data Access Layer, a Route Handler or a Server Action.
There is one exception. Static pages that share data between users, such as content behind a paywall, get their data at build time, so proxy is the only check they get, and the bypasses below would expose them. Use that setup only for content where an occasional leak is acceptable. Content that must stay private should render at request time and be checked in the Data Access Layer.
Six advisories since March 2025 show why proxy cannot be the only check. In each one a crafted request got past an auth check placed in middleware or proxy. Five skipped it entirely; in the sixth, proxy ran but checked a different path from the one the page rendered:
- CVE-2025-29927 (March 2025, critical, CVSS 9.1): an internal
x-middleware-subrequestheader let an attacker skip middleware. Fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5. Vercel’s postmortem concludes: “We do not recommend Middleware to be the sole method of protecting routes”. - Four high-severity bypasses published in May 2026: through App Router segment-prefetch routes (CVE-2026-44575, plus a follow-up for an incomplete fix, CVE-2026-45109), Pages Router i18n data routes (CVE-2026-44573), and dynamic route parameter injection (CVE-2026-44574), where crafted query parameters changed the route value the page saw while proxy checked the unchanged visible path. Three were fixed in 15.5.16 and 16.2.5, the follow-up in 15.5.18 and 16.2.6.
- CVE-2026-64642 (July 2026, high): App Router apps on 16.x built with Turbopack and a single entry in
i18n.locales. Fixed in 16.2.11; 15.x was not affected.
None of them touched a check inside the route itself, so an app that also checked permissions there kept that protection. The 2026 advisories give the same workaround for teams that cannot upgrade: enforce authorization in the route or the page’s server-side data path. CVE-2026-44573’s affected range starts at 12.2.0 and its fixes ship only for 15.5 and 16.2, so an app on 14.x has to upgrade or move authorization out of proxy. Two practical rules follow:
- Re-check the session and the permission inside every Server Action and Route Handler. Server Functions are POST requests to the page that uses them, so a matcher that excludes a path also skips proxy for the actions on it, and moving an action to another route can silently remove its proxy check.
- Stay on a patched release line. Since July 2026 Next.js preannounces its security releases, roughly once a month; as of October 2026 the current lines are 16.3.8 (Active LTS) and 15.5.27 (Maintenance LTS). Under the support policy, 15.x leaves Maintenance LTS on October 21, 2026, two years after its release, so a 15.x app should plan its move to 16.
We built the front end of Proofed’s order-management platform on Next.js, in a partnership running since 2020, for a company whose customers are large enterprises. Its security layer is a set of server-side Next.js API routes that isolates the enterprise backend from the client side. The current docs put authorization in the same place, on the server next to the data, where a request cannot route around it.
Matcher: Run Proxy Only Where It Should Run
Without a matcher, proxy runs on every request, including _next/static files, image optimization and everything in public/. An auth redirect without a matcher can then block CSS, JavaScript or images from loading. Exclude them with a negative lookahead:
export const config = {
matcher: [
// everything except API routes, static files, image optimization and metadata files
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}Four details the docs spell out:
- Matcher values must be constants. Next.js reads them at build time, and a value built from a variable is ignored.
- Each pattern must start with
/and is anchored to the start of the path:/aboutmatches/about/teambut not/blog/about. - An object matcher adds conditions:
hasandmissingcheck headers, query parameters or cookies, for example to skip prefetch requests. - Proxy still runs for
_next/dataroutes even when the pattern excludes them. Next.js does this on purpose, so that protecting a page does not leave its data route open. CVE-2026-44573, listed in the authentication section above, was a bug in exactly this behaviour for Pages Router apps with i18n.
Next.js Proxy Code Examples
Each example below is a complete proxy.ts for Next.js 16 on its own; to run several, combine them as shown in Best Practices. On 15.x, rename the file to middleware.ts and the function to middleware.
Optimistic Authentication Redirect
Signed-out visitors to /dashboard or /profile go to /login, with the original path kept so they can return after signing in. The check only looks for the session cookie; the pages and actions behind it still verify the session themselves. On the login page, accept from only when it resolves to your own origin, for example when new URL(from, 'https://your-site.com').origin equals 'https://your-site.com', or the parameter becomes an open redirect. A check for a leading / is not enough, because browsers read /\evil.com as another host.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const protectedRoutes = ['/dashboard', '/profile']
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = protectedRoutes.some(
(route) => pathname === route || pathname.startsWith(route + '/'),
)
if (isProtected && !request.cookies.has('session')) {
const login = new URL('/login', request.url)
login.searchParams.set('from', pathname + request.nextUrl.search)
return NextResponse.redirect(login)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}A/B Test Rewrite
Visitors to the home page are split into two buckets, kept in a cookie for 30 days, and served /home-a or /home-b without the URL changing. Only the two known values are accepted from the cookie. The variant routes are also reachable at their own URLs. Keep them out of the sitemap and point their canonical URL at /; do not mark them noindex, because the rewrite serves their output, robots tag included, as the home page.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const saved = request.cookies.get('ab-home')?.value
const bucket = saved === 'a' || saved === 'b' ? saved : Math.random() < 0.5 ? 'a' : 'b'
const response = NextResponse.rewrite(new URL('/home-' + bucket, request.url))
response.cookies.set('ab-home', bucket, { maxAge: 60 * 60 * 24 * 30 })
return response
}
export const config = {
matcher: '/',
}Locale Redirect
A request without a locale prefix is redirected to the first language in the Accept-Language header that the site supports, matched without regard to case and by language alone when needed (pl finds pl-PL), with en-GB as the default. Cloning request.nextUrl keeps the query string, so UTM parameters survive the redirect. A prefix in the wrong case, such as /en-gb/x, is redirected to /en-GB/x, so each page has one URL and the auth step below compares against one path shape. The example assumes the App Router pages live under app/[locale]. Its matcher skips every path containing a dot, which covers files such as robots.txt; if your page slugs contain dots, exclude file extensions explicitly instead.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const supportedLocales: readonly string[] = ['en-GB', 'pl-PL', 'fr-FR']
const defaultLocale = 'en-GB'
function preferredLocale(request: NextRequest): string {
const header = request.headers.get('accept-language') ?? ''
// browsers list languages in order of preference: take the first one we support
for (const part of header.split(',')) {
const tag = (part.split(';')[0] ?? '').trim().toLowerCase()
if (!tag) continue
const match = supportedLocales.find(
(locale) => locale.toLowerCase() === tag || locale.toLowerCase().startsWith(tag + '-'),
)
if (match) return match
}
return defaultLocale
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const first = pathname.split('/')[1] ?? ''
const match = supportedLocales.find(
(locale) => locale.toLowerCase() === first.toLowerCase(),
)
if (match === first) return NextResponse.next()
// clone keeps the query string (UTM tags, ?from=) on the redirect
const url = request.nextUrl.clone()
url.pathname = match
? '/' + match + pathname.slice(first.length + 1) // fix the casing: /en-gb/x to /en-GB/x
: '/' + preferredLocale(request) + pathname
return NextResponse.redirect(url)
}
export const config = {
// skip API routes, Next.js assets and any path with a file extension (robots.txt, images)
matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
}Request Logging
event.waitUntil() keeps the proxy invocation alive until the log request settles, so the response goes out without waiting for it. NextRequest no longer has an ip property (removed in Next.js 15); on Vercel, ipAddress() from @vercel/functions returns it, and on other hosts it usually arrives in x-forwarded-for, which a client can also set, so trust it only if your host overwrites it.
import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'
export function proxy(request: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch('https://logs.example.com/requests', {
method: 'POST',
body: JSON.stringify({
method: request.method,
path: request.nextUrl.pathname,
ip: request.headers.get('x-forwarded-for'),
}),
}),
)
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}CORS for API Routes
Requests from allowed origins get CORS headers on the response, and preflight OPTIONS requests are answered directly. Vary: Origin tells caches that the response depends on the requesting origin. For a single endpoint, the same headers can live in its Route Handler instead. To pass a computed value to the route rather than the browser, set a request header with NextResponse.next({ request: { headers } }).
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com']
const corsHeaders = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
Vary: 'Origin',
}
export function proxy(request: NextRequest) {
const origin = request.headers.get('origin') ?? ''
const allowOrigin: Record<string, string> = allowedOrigins.includes(origin)
? { 'Access-Control-Allow-Origin': origin }
: {}
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 204,
headers: { ...allowOrigin, ...corsHeaders },
})
}
const response = NextResponse.next()
for (const [key, value] of Object.entries({ ...allowOrigin, ...corsHeaders })) {
response.headers.set(key, value)
}
return response
}
export const config = {
matcher: '/api/:path*',
}Maintenance Mode
While MAINTENANCE_MODE is on, every page request gets a short HTML response with status 503 and a Retry-After header, so crawlers treat the outage as temporary. API routes, including a health check, and Next.js assets are excluded by the matcher and keep working. On Vercel, a changed environment variable applies only to new deployments, so switching maintenance on means redeploying. To switch it on without a redeploy, store the flag in Vercel Global Config (formerly Edge Config) and read it in proxy. Vercel documents most reads at under 15 ms at P99, so the check stays within the Keep It Fast advice below; its Maintenance Page template shows the setup.
import { NextResponse } from 'next/server'
export function proxy() {
if (process.env.MAINTENANCE_MODE !== 'on') return NextResponse.next()
return new NextResponse(
'<!doctype html><title>Maintenance</title><h1>We will be back shortly</h1>',
{
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Retry-After': '3600' },
},
)
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Next.js Proxy Best Practices
Keep One Entry File and Split the Logic
Next.js supports one proxy.ts per project. Put each concern (auth redirect, locale, experiments) in its own module, import them into proxy.ts and run them in a fixed order, returning as soon as one of them produces a response.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { authRedirect } from './lib/proxy/auth'
import { localeRedirect } from './lib/proxy/locale'
type ProxyStep = (request: NextRequest) => NextResponse | undefined
// order matters: the first step that returns a response wins
const steps: ProxyStep[] = [authRedirect, localeRedirect]
export function proxy(request: NextRequest) {
for (const step of steps) {
const response = step(request)
if (response) return response
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}Each step is a plain function that returns a redirect or undefined; undefined passes the request to the next step. The standalone examples above end with NextResponse.next(), which would end the chain, so as steps they return undefined instead. Steps also have to agree on path shape. Once the locale step has sent a visitor to /en-GB/dashboard, an auth step that compares against /dashboard no longer matches, so this one strips the locale prefix first:
// lib/proxy/auth.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const locales = ['en-GB', 'pl-PL', 'fr-FR']
const protectedRoutes = ['/dashboard', '/profile']
function withoutLocale(pathname: string): string {
const [, first, ...rest] = pathname.split('/')
const isLocale = locales.some((locale) => locale.toLowerCase() === first?.toLowerCase())
return isLocale ? '/' + rest.join('/') : pathname
}
export function authRedirect(request: NextRequest): NextResponse | undefined {
const path = withoutLocale(request.nextUrl.pathname)
const isProtected = protectedRoutes.some(
(route) => path === route || path.startsWith(route + '/'),
)
if (!isProtected || request.cookies.has('session')) return undefined
const login = new URL('/login', request.url)
login.searchParams.set('from', request.nextUrl.pathname + request.nextUrl.search)
return NextResponse.redirect(login)
}The matcher in proxy.ts has to cover every path any step needs, so it no longer skips paths with a dot: /dashboard/export.csv must still reach the auth step. The narrower scoping moves inside the steps, so the locale step has to skip file paths such as /logo.png itself, for example by returning undefined when pathname.split('/').pop()?.includes('.') is true. The auth step compares locale prefixes without regard to case, like the locale step does.
Keep It Fast
Proxy runs on every matching request, including prefetches. Read cookies and headers; do not query a database or call a slow API. The authentication guide gives the same advice for session checks: read the session from the cookie and leave database checks to the route. On Vercel, proxy runs as Routing Middleware, which is billed on the Fluid compute model by the compute it uses, so a tight matcher keeps your bill down as well as your response times.
Test Which Paths It Runs On
next/experimental/testing/server includes unstable_doesMiddlewareMatch, which tells you whether proxy will run for a given URL, headers and cookies. The docs call it unstable_doesProxyMatch, but the package (checked in 15.5.27 and 16.3.8) still exports the old name. A unit test on it catches a matcher change that silently stops covering a route.
Make Every Failed Check Return a Response
When a check fails, return an explicit redirect or a JSON response with the right status (401 or 403). Returning nothing lets the request continue, the same as NextResponse.next(). A redirect that matches its own target, such as /login redirecting to /login, loops; exclude the target in the matcher or in code.
How to Migrate from middleware.ts to proxy.ts
- From the project root, run
npx @next/codemod@canary middleware-to-proxy .to rename the file and the exported function. - Rename config flags that carry the old name, for example
skipMiddlewareUrlNormalizetoskipProxyUrlNormalize. - Remove any
runtimeexport from the file. Proxy always runs on Node.js and throws if one is set. - Check that your auth and session libraries support the Node.js runtime; the authentication guide asks for the same check.
- Keep
middleware.tsonly if Edge execution is a hard requirement, and plan for its removal in a future major version.
Need help moving your app to Next.js 16?
Frequently Asked Questions
proxy.ts is the Next.js 16 name for middleware.ts. The logic is the same, but the exported function is called proxy and it runs on the Node.js runtime, which cannot be changed. middleware.ts still works for Edge runtime use cases, but it is deprecated and will be removed in a future version.
Yes. The proxy function can be async and await calls before returning NextResponse.next(), a redirect, a rewrite or a Response. Keep the awaited work short, because proxy runs on every matching request; fetch caching options have no effect in proxy.
In Next.js 16, proxy runs on the Node.js runtime and the runtime option cannot be changed. Middleware could use the Node.js runtime from 15.5. The Edge runtime is now only available through the deprecated middleware.ts file.
No. Next.js supports one proxy.ts (or middleware.ts) per project, in the project root or in src. Split the logic into modules, import them into that one file and use the matcher or conditions to decide which runs.
Create proxy.ts in the project root, or in src next to the app or pages folder. Export a function named proxy, or a default export, that takes a NextRequest and returns a NextResponse. Optionally export config with a matcher to choose the paths it runs on.
A redirect sends an HTTP 3xx response and the browser requests the new URL, so the address bar changes; use a permanent one (308, from next.config with permanent: true) for moved pages and a temporary one (307, the NextResponse.redirect default) for login flows. A rewrite serves a different route while the URL stays the same; use it for A/B tests, locale variants or vanity URLs.
No. Use proxy for optimistic checks, such as redirecting visitors without a session cookie, and verify the session and permissions again in the Data Access Layer, Route Handlers and Server Actions. Six advisories since March 2025, starting with CVE-2025-29927, let crafted requests get past checks placed in middleware or proxy.
Check that the file is proxy.ts (or middleware.ts on 15.x) in the root or src, and that it exports proxy or a default function. Check that the matcher is a constant and matches the path. Remove any runtime export. Make sure each failed check returns a redirect or a response, and look for redirects or rewrites in next.config that run first.
Read More
- Pros and Cons of Next.js
- Common Next.js Mistakes That Hurt Core Web Vitals
- Node.js vs Next.js
- Next.js as a Static Site Generator
- Next.js Performance Optimization in 9 Simple Steps
- Benefits of Using Next JS for Building Websites and Apps
Sources
- Next.js Documentation: proxy.js
- Next.js Documentation: Proxy
- Next.js 16 release notes
- Next.js Documentation: How to implement authentication
- Next.js Documentation: How to think about data security
- Next.js Documentation: How to upgrade to version 15
- Vercel: Postmortem on Next.js Middleware bypass (CVE-2025-29927)
- Next.js: July 2026 Security Release
- GitHub Advisory: Middleware / Proxy bypass in Pages Router applications using i18n (CVE-2026-44573)
- GitHub Advisory: Middleware / Proxy bypass via segment-prefetch routes (CVE-2026-44575)
- Next.js Documentation: redirects
- Google Search Central: Redirects and Google Search
- Next.js support policy
- GitHub Advisory: incomplete fix follow-up for segment-prefetch bypass (CVE-2026-45109)
- GitHub Advisory: Middleware / Proxy bypass through dynamic route parameter injection (CVE-2026-44574)
- GitHub Advisory: Middleware / Proxy bypass with Turbopack and a single locale (CVE-2026-64642)
- Vercel: Environment variables
- Vercel: Routing Middleware
- Vercel: Global Config
