Dev Logs
/Next.js/ Chapter 17: Proxy (formerly Middleware)
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
    • Plain English Explanation
    • The rename
    • Why they renamed it
    • The basic shape
    • The matcher — get this right first
    • Conditional matching with has and missing
    • Two matcher gotchas
    • What you can do
    • Redirect
    • Rewrite
    • Set request headers (visible to your app)
    • Set response headers
    • Cookies
    • Respond directly
    • Background work with waitUntil
    • Proxy is not your security boundary
    • The correct layering
    • Practical patterns
    • Geolocation-based routing
    • A/B testing
    • Maintenance mode
    • CORS for API routes
    • Execution order
    • Testing it
    • Common Pitfalls
    • . No matcher
    • . Still calling it middleware
    • . Setting runtime: 'edge'
    • . Treating Proxy as authorization
    • . Confusing the two next() shapes
    • . Heavy work in Proxy
    • . Dynamic matcher values
    • . Redirect loops
    • . Relying on shared modules or globals
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the matcher
    • Problem 2: Migrate
    • Problem 3: Security review
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🚦 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 /dashboard without rendering it first
  • Rewrite acme.com and bob.acme.com to 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.ts is deprecated and renamed to proxy.ts. The exported function is renamed too.

bash
mv middleware.ts proxy.ts
ts
// ❌ Next.js 15
export function middleware(request: NextRequest) {}

// ✅ Next.js 16
export function proxy(request: NextRequest) {}

Config flags renamed as well:

OldNew
skipMiddlewareUrlNormalizeskipProxyUrlNormalize

Codemod:

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

The edge runtime is not supported in proxy. Proxy runs on Node.js, and the runtime config option throws if you set it. If you specifically need the edge runtime, keep using middleware for 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:

ts
// 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*',
}
js
// 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:

ts
// 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:

ts
// 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:

ts
export const config = { matcher: '/about' }                          // one path
export const config = { matcher: ['/about', '/contact'] }            // several
export const config = { matcher: '/dashboard/:path*' }               // a subtree

Pattern rules:

PatternMatches
/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

ts
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:

ts
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:

ts
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

ts
// 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*'],
}
js
// 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:

ts
// 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()
}
js
// 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:

ts
// 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 },
  })
}
ts
// 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

ts
// 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:

ts
// 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

ts
// 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

ts
// 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:

ts
// 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()
}
js
// 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:

  1. A matcher change silently removes coverage. Someone edits the regex six months from now and a route quietly becomes public.

  2. 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.

  3. 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.

  4. It can't do per-resource checks. "Is this user allowed to see this invoice?" needs the invoice.

The correct layering

ts
// 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()
}
ts
// 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

ts
// 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

ts
// 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

ts
// 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

ts
// 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:

ts
// 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

ts
// ❌ 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

ts
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

ts
// ❌ /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:

ts
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:

ts
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

ts
// 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

ts
// 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*' }
ts
// 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.com to /tenants/<sub>/*
  • Adds a per-request CSP nonce as a request header
  • Skips static assets and prefetch requests
  • Never loops on /login or /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

  1. middleware.ts is now proxy.ts, the function is proxy, skipMiddlewareUrlNormalize is skipProxyUrlNormalize, and the edge runtime is gone. Run npx @next/codemod@canary middleware-to-proxy .
  2. Always set a matcher. Without one, Proxy intercepts every asset request and your auth redirect will break your stylesheets.
  3. 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.
  4. Redirect changes the URL; rewrite doesn't. Rewrites are the mechanism behind multi-tenancy, A/B tests, and maintenance modes.
  5. 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.


PreviousChapter 16: Cache Components & Partial PrerenderingNextChapter 18: Authentication & Authorization

Open source, free forever. Built by iammhador.

Contribute on GitHub