CONTACT US
TABLE OF CONTENTS

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

Rafał Dąbrowski
By Rafał DąbrowskiPublication: Update:
Dark graphic with the text “NEXT JS MIDDLEWARE: WHAT IS IT AND WHEN TO USE IT” in bold white and red letters, featuring abstract outlines of screens and rectangles in the background to highlight next js middleware concepts.

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.

Flowchart showing how a request moves through Next.js 16, with steps for next.config, proxy.ts, and routes, including headers, redirects, rewrites, and authorisation checks.
Proxy runs after next.config headers and redirects and before any route renders.

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:

Whatmiddleware.ts (before 16)proxy.ts (16 and later)
File and exportmiddleware.ts, export function middlewareproxy.ts, export function proxy or a default export
Default runtimeEdge; Node.js stable from 15.5Node.js
Edge runtimeyesno: setting runtime in a proxy file throws an error
Statusdeprecated, to be removed in a future versioncurrent

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:

  1. headers from next.config.js
  2. redirects from next.config.js
  3. Proxy (rewrites, redirects, headers, responses)
  4. beforeFiles rewrites from next.config.js
  5. Filesystem routes (public/, _next/static/, pages/, app/)
  6. afterFiles rewrites
  7. Dynamic routes such as /blog/[slug]
  8. fallback rewrites

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 needWhere it belongsExample below
Redirects from old URLs, headers that are the same on every responseredirects and headers in next.confignone needed
Headers that depend on the request, such as a CORS origin from an allow-listproxyCORS for API Routes
Redirects or rewrites by cookie, header, locale or A/B bucketproxyA/B Test Rewrite, Locale Redirect
Sending signed-out visitors to the login pageproxy, as an optimistic checkOptimistic Authentication Redirect
Protecting static pages that share data between users (a paywall)proxy, as the only check those pages get; see the authentication sectionOptimistic Authentication Redirect
A 503 for every page during a planned outageproxyMaintenance Mode
Deciding whether this user may read or change this recordthe Data Access Layer, Route Handler or Server Actionsee the authentication section
Fetching data, slow lookupsthe route itself; fetch caching options have no effect in proxynone

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-subrequest header 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: /about matches /about/team but not /blog/about.
  • An object matcher adds conditions: has and missing check headers, query parameters or cookies, for example to skip prefetch requests.
  • Proxy still runs for _next/data routes 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

  1. From the project root, run npx @next/codemod@canary middleware-to-proxy . to rename the file and the exported function.
  2. Rename config flags that carry the old name, for example skipMiddlewareUrlNormalize to skipProxyUrlNormalize.
  3. Remove any runtime export from the file. Proxy always runs on Node.js and throws if one is set.
  4. Check that your auth and session libraries support the Node.js runtime; the authentication guide asks for the same check.
  5. Keep middleware.ts only 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

What is the difference between middleware.ts and proxy.ts in Next.js?

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.

Can Next.js middleware be async?

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.

Does Next.js middleware run on the Edge or on Node.js?

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.

Can I have multiple middleware files in Next.js?

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.

How do I create middleware in Next.js?

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.

What is the difference between rewrite and redirect in Next.js middleware?

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.

Is Next.js middleware enough to protect a route?

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.

What should I check when Next.js middleware is not working?

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

Sources

Rafał Dąbrowski

Joined Pagepro in March 2021. Before that, Full Stack Developer for over a year. Current main project is a 100,000+ page medical platform rebuilt on Next.js and Sanity, including AI features. Has worked on multiple other projects on the Next.js + Sanity stack. Focus areas are AI-assisted development and building agents.

Article link copied

Close button

Leave a Reply

* Required informations.