Back to Blog

Next.js Proxy Deep Dive: Edge-First Request Interception

Starting with Next.js 16, Middleware has been renamed to Proxy to better reflect its architectural role: an edge-deployed request interceptor that runs before routes are rendered. This guide covers the complete Proxy API, matching strategies, and production patterns.

Proxy Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                         Client Request                              │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    CDN / Edge Network                               │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                     proxy.ts                                 │   │
│  │  • Runs at the edge (closest to user)                        │   │
│  │  • Executes BEFORE any route rendering                       │   │
│  │  • Can redirect, rewrite, modify headers, respond directly   │   │
│  │  • Stateless - no shared modules/globals between requests    │   │
│  └─────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    Execution Order                                  │
│  1. headers (next.config.js)                                        │
│  2. redirects (next.config.js)                                      │
│  3. Proxy (rewrites, redirects, responses) ◄── YOU ARE HERE         │
│  4. beforeFiles rewrites (next.config.js)                           │
│  5. Filesystem routes (public/, _next/static/, pages/, app/)        │
│  6. afterFiles rewrites (next.config.js)                            │
│  7. Dynamic routes (/blog/[slug])                                   │
│  8. fallback rewrites (next.config.js)                              │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    Route Handler / Page                             │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  app/page.tsx | app/api/route.ts                             │   │
│  │  • Receives modified request headers from Proxy              │   │
│  │  • Original URL may be rewritten                             │   │
│  │  • Cookies set by Proxy are available                        │   │
│  └─────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘

Why "Proxy" Instead of "Middleware"

The rename from middleware.ts to proxy.ts reflects:

  1. Network Boundary: Proxy implies a network layer in front of the application
  2. Edge Deployment: Proxy defaults to Edge Runtime, running closer to clients
  3. Distinct from Express: Avoids confusion with Express.js middleware patterns
  4. Clear Purpose: Emphasizes request interception rather than general-purpose middleware

File Convention

Create proxy.ts (or proxy.js) at the project root or inside src/:

project-root/
├── proxy.ts          # ✓ Valid location
├── app/
│   └── ...
└── ...

# OR with src directory
project-root/
├── src/
│   ├── proxy.ts      # ✓ Valid location
│   └── app/
└── ...

Only one proxy.ts file is supported per project. Organize logic by importing from separate modules.

Basic Structure

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Named export (recommended)
export function proxy(request: NextRequest) {
  return NextResponse.next()
}

// OR default export
export default function proxy(request: NextRequest) {
  return NextResponse.next()
}

// Matcher configuration (optional)
export const config = {
  matcher: '/api/:path*',
}

NextProxy Type Helper

For cleaner type inference:

import type { NextProxy } from 'next/server'

export const proxy: NextProxy = (request, event) => {
  // request: NextRequest (inferred)
  // event: NextFetchEvent (inferred)

  event.waitUntil(logRequest(request))
  return Response.json({ pathname: request.nextUrl.pathname })
}

Matcher Configuration

The matcher determines which paths trigger the Proxy. Without a matcher, Proxy runs on every request.

Simple Matchers

// Single path
export const config = {
  matcher: '/about',
}

// Multiple paths
export const config = {
  matcher: ['/about', '/contact', '/pricing'],
}

// Wildcard patterns
export const config = {
  matcher: '/api/:path*',    // /api, /api/users, /api/users/123
}

Path Pattern Syntax

┌─────────────────────────────────────────────────────────────────────┐
│                    Path Pattern Syntax                              │
├─────────────────────────────────────────────────────────────────────┤
│  Pattern              │ Matches                                     │
├─────────────────────────────────────────────────────────────────────┤
│  /about               │ /about (exact)                              │
│  /about/:path         │ /about/team, /about/company (one segment)   │
│  /about/:path*        │ /about, /about/a, /about/a/b (zero or more) │
│  /about/:path+        │ /about/a, /about/a/b (one or more)          │
│  /about/:path?        │ /about, /about/team (zero or one)           │
│  /about/(.*)          │ /about/anything (regex)                     │
│  /(dashboard|admin)   │ /dashboard, /admin                          │
└─────────────────────────────────────────────────────────────────────┘

Negative Matching (Exclude Paths)

export const config = {
  matcher: [
    /*
     * Match all paths except:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico, sitemap.xml, robots.txt
     */
    '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}

Conditional Matching with has/missing

export const config = {
  matcher: [
    {
      source: '/api/:path*',
      // Only match if Authorization header is present
      has: [
        { type: 'header', key: 'Authorization' },
      ],
    },
    {
      source: '/dashboard/:path*',
      // Only match if session cookie is missing
      missing: [
        { type: 'cookie', key: 'session' },
      ],
    },
    {
      source: '/search',
      // Match specific query parameter
      has: [
        { type: 'query', key: 'q' },
      ],
    },
    {
      source: '/:path*',
      // Match specific header value
      has: [
        { type: 'header', key: 'x-api-version', value: 'v2' },
      ],
    },
  ],
}

Locale-Aware Matching

export const config = {
  matcher: [
    {
      source: '/dashboard/:path*',
      locale: false,  // Ignore locale prefix in matching
    },
  ],
}

NextResponse Methods

Continue Request (next)

Allow the request to proceed with optional modifications:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // Continue without modifications
  return NextResponse.next()

  // Continue with modified request headers (upstream only)
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', 'user_123')
  requestHeaders.set('x-request-id', crypto.randomUUID())

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

Redirect

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const url = request.nextUrl

  // Temporary redirect (307)
  if (url.pathname === '/old-page') {
    return NextResponse.redirect(new URL('/new-page', request.url))
  }

  // Permanent redirect (308)
  if (url.pathname === '/legacy') {
    return NextResponse.redirect(
      new URL('/modern', request.url),
      { status: 308 }
    )
  }

  // Redirect with preserved query params
  if (url.pathname === '/search-old') {
    const newUrl = new URL('/search', request.url)
    newUrl.search = url.search
    return NextResponse.redirect(newUrl)
  }

  // External redirect
  if (url.pathname === '/external') {
    return NextResponse.redirect('https://example.com')
  }

  return NextResponse.next()
}

Rewrite (Internal Proxy)

Browser URL stays the same, but server handles different path:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const url = request.nextUrl

  // Internal rewrite
  if (url.pathname === '/about') {
    return NextResponse.rewrite(new URL('/about-v2', request.url))
  }

  // A/B testing via rewrite
  const bucket = request.cookies.get('ab-bucket')?.value || 'control'
  if (url.pathname === '/pricing' && bucket === 'variant') {
    return NextResponse.rewrite(new URL('/pricing-new', request.url))
  }

  // Proxy to external service
  if (url.pathname.startsWith('/legacy-api')) {
    const legacyPath = url.pathname.replace('/legacy-api', '')
    return NextResponse.rewrite(
      new URL(legacyPath, 'https://legacy.example.com')
    )
  }

  return NextResponse.next()
}

Direct Response

Respond immediately without hitting origin:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // JSON response
  if (request.nextUrl.pathname === '/api/health') {
    return NextResponse.json({ status: 'healthy', timestamp: Date.now() })
  }

  // Block request
  if (request.nextUrl.pathname.includes('..')) {
    return new Response('Forbidden', { status: 403 })
  }

  // Authentication check
  const token = request.cookies.get('token')?.value
  if (!token && request.nextUrl.pathname.startsWith('/api/')) {
    return Response.json(
      { success: false, message: 'Authentication required' },
      { status: 401 }
    )
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/api/:path*', '/:path*'],
}
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // ═══════════════════════════════════════════════════════════════
  // Reading cookies from request
  // ═══════════════════════════════════════════════════════════════

  // Get single cookie
  const session = request.cookies.get('session')
  console.log(session) // { name: 'session', value: 'abc123', Path: '/' }

  // Get all cookies
  const allCookies = request.cookies.getAll()

  // Check cookie existence
  const hasToken = request.cookies.has('token')

  // ═══════════════════════════════════════════════════════════════
  // Setting cookies on response
  // ═══════════════════════════════════════════════════════════════

  const response = NextResponse.next()

  // Simple set
  response.cookies.set('theme', 'dark')

  // With options
  response.cookies.set('session', 'xyz789', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: '/',
  })

  // Object syntax
  response.cookies.set({
    name: 'analytics_id',
    value: crypto.randomUUID(),
    httpOnly: false,
    secure: true,
    sameSite: 'strict',
    expires: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
  })

  // Delete cookie
  response.cookies.delete('old_cookie')

  return response
}

Header Manipulation

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // ═══════════════════════════════════════════════════════════════
  // Modify REQUEST headers (forwarded upstream to routes)
  // ═══════════════════════════════════════════════════════════════

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-request-id', crypto.randomUUID())
  requestHeaders.set('x-forwarded-host', request.headers.get('host') || '')
  requestHeaders.set('x-user-country', 'US') // From geo detection

  // Remove sensitive headers before forwarding
  requestHeaders.delete('x-internal-key')

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })

  // ═══════════════════════════════════════════════════════════════
  // Modify RESPONSE headers (sent back to client)
  // ═══════════════════════════════════════════════════════════════

  response.headers.set('x-proxy-version', '1.0')
  response.headers.set('x-response-time', `${Date.now()}`)

  // Security headers
  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')

  return response
}

Header Flow Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                    Header Flow in Proxy                             │
└─────────────────────────────────────────────────────────────────────┘

  Client Request
       │
       │ headers: { Authorization: "...", User-Agent: "..." }
       │
       ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                         proxy.ts                             │
  │                                                              │
  │  // Modify REQUEST headers → sent UPSTREAM to routes         │
  │  NextResponse.next({                                         │
  │    request: { headers: modifiedRequestHeaders }              │
  │  })                                                          │
  │                                                              │
  │  // Modify RESPONSE headers → sent DOWNSTREAM to client      │
  │  response.headers.set('X-Custom', 'value')                   │
  └─────────────────────────────────────────────────────────────┘
       │
       │ modifiedRequestHeaders go to Route/Page
       │
       ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                   Route Handler / Page                       │
  │  • Receives modifiedRequestHeaders                           │
  │  • Does NOT see response headers set by Proxy                │
  └─────────────────────────────────────────────────────────────┘
       │
       │ Response generated
       │
       ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                      Client Response                         │
  │  headers: { X-Custom: "value", ... } ← From Proxy            │
  └─────────────────────────────────────────────────────────────┘

⚠️  CAUTION: Never use NextResponse.next({ headers })
    This sends headers to the CLIENT, not upstream!
    Use NextResponse.next({ request: { headers } }) instead.

CORS Handling

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
]

const corsOptions = {
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
  'Access-Control-Max-Age': '86400', // 24 hours
}

export function proxy(request: NextRequest) {
  const origin = request.headers.get('origin') ?? ''
  const isAllowedOrigin = allowedOrigins.includes(origin)

  // Handle preflight requests
  if (request.method === 'OPTIONS') {
    const preflightHeaders = {
      ...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin }),
      ...corsOptions,
    }
    return NextResponse.json({}, { headers: preflightHeaders })
  }

  // Handle actual requests
  const response = NextResponse.next()

  if (isAllowedOrigin) {
    response.headers.set('Access-Control-Allow-Origin', origin)
    response.headers.set('Access-Control-Allow-Credentials', 'true')
  }

  Object.entries(corsOptions).forEach(([key, value]) => {
    response.headers.set(key, value)
  })

  return response
}

export const config = {
  matcher: '/api/:path*',
}

waitUntil for Background Work

The event.waitUntil() method extends the Proxy lifetime to complete background tasks after the response is sent:

import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'

export function proxy(request: NextRequest, event: NextFetchEvent) {
  // Schedule background work - doesn't block response
  event.waitUntil(
    (async () => {
      // Analytics logging
      await fetch('https://analytics.example.com/track', {
        method: 'POST',
        body: JSON.stringify({
          path: request.nextUrl.pathname,
          userAgent: request.headers.get('user-agent'),
          timestamp: Date.now(),
        }),
      })

      // Cache warming
      await fetch('https://api.example.com/warm-cache', {
        method: 'POST',
        body: JSON.stringify({ path: request.nextUrl.pathname }),
      })
    })()
  )

  // Response returns immediately
  return NextResponse.next()
}

Production Patterns

Authentication Gate

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyJWT } from '@/lib/auth'

const publicPaths = [
  '/login',
  '/register',
  '/forgot-password',
  '/api/auth/login',
  '/api/auth/register',
]

const apiPaths = ['/api/']

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Allow public paths
  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next()
  }

  // Check for session token
  const token = request.cookies.get('session')?.value

  if (!token) {
    // API routes return 401
    if (apiPaths.some(path => pathname.startsWith(path))) {
      return Response.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Pages redirect to login
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Verify token
  try {
    const payload = await verifyJWT(token)

    // Forward user info to routes via headers
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-user-id', payload.sub)
    requestHeaders.set('x-user-role', payload.role)

    return NextResponse.next({
      request: { headers: requestHeaders },
    })
  } catch (error) {
    // Invalid token - clear it and redirect
    const response = NextResponse.redirect(new URL('/login', request.url))
    response.cookies.delete('session')
    return response
  }
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
}

A/B Testing

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const EXPERIMENTS = {
  'homepage-hero': {
    variants: ['control', 'variant-a', 'variant-b'],
    paths: ['/'],
  },
  'pricing-layout': {
    variants: ['control', 'new-layout'],
    paths: ['/pricing'],
  },
}

function getVariant(experimentId: string, userId: string): string {
  const experiment = EXPERIMENTS[experimentId]
  if (!experiment) return 'control'

  // Consistent hashing based on user ID
  const hash = hashString(`${experimentId}:${userId}`)
  const index = hash % experiment.variants.length
  return experiment.variants[index]
}

function hashString(str: string): number {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = ((hash << 5) - hash) + char
    hash = hash & hash
  }
  return Math.abs(hash)
}

export function proxy(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  // Get or create user ID
  let userId = request.cookies.get('user_id')?.value
  const response = NextResponse.next()

  if (!userId) {
    userId = crypto.randomUUID()
    response.cookies.set('user_id', userId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 365, // 1 year
    })
  }

  // Find matching experiments
  const activeExperiments: Record<string, string> = {}

  for (const [experimentId, config] of Object.entries(EXPERIMENTS)) {
    if (config.paths.some(path => pathname.startsWith(path))) {
      const variant = getVariant(experimentId, userId)
      activeExperiments[experimentId] = variant

      // Set cookie for client-side access
      response.cookies.set(`exp_${experimentId}`, variant, {
        httpOnly: false,
        maxAge: 60 * 60 * 24 * 30, // 30 days
      })
    }
  }

  // Forward experiment assignments to routes
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-experiments', JSON.stringify(activeExperiments))

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}

Geolocation-Based Routing

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const COUNTRY_REDIRECTS: Record<string, string> = {
  DE: '/de',
  FR: '/fr',
  JP: '/ja',
  CN: '/zh',
}

const BLOCKED_COUNTRIES = ['XX', 'YY'] // Sanctioned regions

export function proxy(request: NextRequest) {
  // Note: geo is no longer available on NextRequest in Next.js 15+
  // Use headers set by your CDN instead
  const country = request.headers.get('x-vercel-ip-country') ||
                  request.headers.get('cf-ipcountry') || // Cloudflare
                  'US'

  const pathname = request.nextUrl.pathname

  // Block certain regions
  if (BLOCKED_COUNTRIES.includes(country)) {
    return new Response('Service not available in your region', {
      status: 451,
    })
  }

  // Redirect to localized version if not already there
  const redirect = COUNTRY_REDIRECTS[country]
  if (redirect && !pathname.startsWith(redirect) && pathname === '/') {
    return NextResponse.redirect(new URL(redirect, request.url))
  }

  // Forward geo info to routes
  const response = NextResponse.next()
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-country', country)

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}

Rate Limiting at Edge

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Simple in-memory rate limiter (use Redis for production)
const rateLimit = new Map<string, { count: number; resetAt: number }>()

const WINDOW_MS = 60 * 1000 // 1 minute
const MAX_REQUESTS = 100

function checkRateLimit(ip: string): { allowed: boolean; remaining: number } {
  const now = Date.now()
  const record = rateLimit.get(ip)

  if (!record || now > record.resetAt) {
    rateLimit.set(ip, { count: 1, resetAt: now + WINDOW_MS })
    return { allowed: true, remaining: MAX_REQUESTS - 1 }
  }

  if (record.count >= MAX_REQUESTS) {
    return { allowed: false, remaining: 0 }
  }

  record.count++
  return { allowed: true, remaining: MAX_REQUESTS - record.count }
}

export function proxy(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
             request.headers.get('x-real-ip') ||
             'unknown'

  const { allowed, remaining } = checkRateLimit(ip)

  if (!allowed) {
    return new Response(
      JSON.stringify({ error: 'Rate limit exceeded' }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': '60',
          'X-RateLimit-Limit': String(MAX_REQUESTS),
          'X-RateLimit-Remaining': '0',
        },
      }
    )
  }

  const response = NextResponse.next()
  response.headers.set('X-RateLimit-Limit', String(MAX_REQUESTS))
  response.headers.set('X-RateLimit-Remaining', String(remaining))

  return response
}

export const config = {
  matcher: '/api/:path*',
}

Feature Flags

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

interface FeatureFlags {
  newCheckout: boolean
  darkMode: boolean
  betaFeatures: boolean
}

async function getFeatureFlags(userId: string): Promise<FeatureFlags> {
  // In production, fetch from LaunchDarkly, Split, etc.
  // Using edge-compatible fetch
  const response = await fetch(
    `https://features.example.com/api/flags?user=${userId}`,
    { next: { revalidate: 60 } }
  )
  return response.json()
}

export async function proxy(request: NextRequest) {
  const userId = request.cookies.get('user_id')?.value || 'anonymous'

  try {
    const flags = await getFeatureFlags(userId)

    // Forward flags to routes via headers
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-feature-flags', JSON.stringify(flags))

    // Also set cookie for client-side access
    const response = NextResponse.next({
      request: { headers: requestHeaders },
    })

    response.cookies.set('feature_flags', JSON.stringify(flags), {
      httpOnly: false,
      maxAge: 60 * 5, // 5 minutes
    })

    return response
  } catch (error) {
    // Fallback to defaults on error
    const defaultFlags: FeatureFlags = {
      newCheckout: false,
      darkMode: false,
      betaFeatures: false,
    }

    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-feature-flags', JSON.stringify(defaultFlags))

    return NextResponse.next({
      request: { headers: requestHeaders },
    })
  }
}

Bot Detection

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const BOT_PATTERNS = [
  /googlebot/i,
  /bingbot/i,
  /slurp/i,
  /duckduckbot/i,
  /baiduspider/i,
  /yandexbot/i,
  /facebookexternalhit/i,
  /twitterbot/i,
  /linkedinbot/i,
]

const MALICIOUS_BOT_PATTERNS = [
  /scrapy/i,
  /curl/i,
  /wget/i,
  /python-requests/i,
  /go-http-client/i,
]

function classifyBot(userAgent: string): 'search' | 'social' | 'malicious' | 'human' {
  if (MALICIOUS_BOT_PATTERNS.some(p => p.test(userAgent))) {
    return 'malicious'
  }

  if (/facebookexternalhit|twitterbot|linkedinbot/i.test(userAgent)) {
    return 'social'
  }

  if (BOT_PATTERNS.some(p => p.test(userAgent))) {
    return 'search'
  }

  return 'human'
}

export function proxy(request: NextRequest) {
  const userAgent = request.headers.get('user-agent') || ''
  const botType = classifyBot(userAgent)

  // Block malicious bots
  if (botType === 'malicious') {
    return new Response('Forbidden', { status: 403 })
  }

  // Forward bot classification to routes
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-bot-type', botType)

  // Search bots might get pre-rendered content
  // Social bots might get OG-optimized pages

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}

Advanced Configuration

skipTrailingSlashRedirect

// next.config.js
module.exports = {
  skipTrailingSlashRedirect: true,
}
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const legacyPaths = ['/docs', '/blog']

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Keep trailing slashes for legacy paths
  if (legacyPaths.some(p => pathname.startsWith(p))) {
    return NextResponse.next()
  }

  // Enforce trailing slashes for other paths
  if (!pathname.endsWith('/') && !pathname.includes('.')) {
    const url = request.nextUrl.clone()
    url.pathname = `${pathname}/`
    return NextResponse.redirect(url)
  }

  return NextResponse.next()
}

skipProxyUrlNormalize

// next.config.js
module.exports = {
  skipProxyUrlNormalize: true,
}
// proxy.ts - Now sees raw URLs including _next/data
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl

  // With flag: /_next/data/build-id/hello.json
  // Without flag: /hello (normalized)
  console.log(pathname)

  return NextResponse.next()
}

Unit Testing (Experimental)

// proxy.test.ts
import { unstable_doesProxyMatch, isRewrite, getRewrittenUrl } from 'next/experimental/testing/server'
import { proxy, config } from './proxy'
import { NextRequest } from 'next/server'

describe('Proxy', () => {
  describe('matcher', () => {
    it('matches API routes', () => {
      expect(
        unstable_doesProxyMatch({
          config,
          url: '/api/users',
        })
      ).toBe(true)
    })

    it('does not match static files', () => {
      expect(
        unstable_doesProxyMatch({
          config,
          url: '/_next/static/chunks/main.js',
        })
      ).toBe(false)
    })
  })

  describe('rewrites', () => {
    it('rewrites /about to /about-v2', async () => {
      const request = new NextRequest('https://example.com/about')
      const response = await proxy(request)

      expect(isRewrite(response)).toBe(true)
      expect(getRewrittenUrl(response)).toBe('https://example.com/about-v2')
    })
  })
})

Migration from middleware.ts

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

Changes made:

  • middleware.tsproxy.ts
  • export function middleware()export function proxy()
  • export default function middleware()export default function proxy()

Common Pitfalls

1. Using Proxy for Data Fetching

// ❌ Wrong - Proxy should be fast, not for slow operations
export async function proxy(request: NextRequest) {
  const data = await fetch('https://slow-api.com/data') // Bad!
  // ...
}

// ✓ Correct - Use Route Handlers or Server Components for data fetching
// Proxy should only do quick checks/redirects
export function proxy(request: NextRequest) {
  const token = request.cookies.get('token')
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  return NextResponse.next()
}

2. Relying on Proxy for Authorization

// ❌ Wrong - Proxy can be bypassed, always verify in Server Functions
export function proxy(request: NextRequest) {
  if (!isAdmin(request)) {
    return new Response('Forbidden', { status: 403 })
  }
  return NextResponse.next()
}

// Server Action
async function deleteUser(userId: string) {
  // ✓ Correct - Verify authorization here too
  const session = await getSession()
  if (!session?.user?.isAdmin) {
    throw new Error('Unauthorized')
  }
  await db.user.delete({ where: { id: userId } })
}

3. Sharing State Between Requests

// ❌ Wrong - Proxy is stateless, no shared globals
let requestCount = 0 // This won't work as expected

export function proxy(request: NextRequest) {
  requestCount++ // May reset between invocations
  return NextResponse.next()
}

// ✓ Correct - Use external storage for state
export async function proxy(request: NextRequest) {
  await incrementCounter('requests') // Redis, KV, etc.
  return NextResponse.next()
}

4. Sending Response Headers Incorrectly

// ❌ Wrong - This sends headers to CLIENT, not upstream
return NextResponse.next({ headers: requestHeaders })

// ✓ Correct - This forwards headers to routes/pages
return NextResponse.next({
  request: { headers: requestHeaders }
})

Key Takeaways

  1. Edge-First Architecture: Proxy runs at the edge before routes, ideal for fast redirects, rewrites, and header manipulation.

  2. Single File Convention: Only one proxy.ts per project; organize logic via imports from modules.

  3. Use Matchers: Always configure matchers to avoid running Proxy on every request (static files, images, etc.).

  4. Headers Flow: Use NextResponse.next({ request: { headers } }) for upstream headers, response.headers.set() for client-facing headers.

  5. Don't Fetch Slow Data: Proxy should be fast; use Route Handlers or Server Components for data fetching.

  6. Not a Security Boundary: Always verify authentication/authorization in Server Functions and Route Handlers, not just Proxy.

  7. waitUntil for Background Work: Use event.waitUntil() for analytics, logging, or cache warming without blocking the response.

  8. Migration: Run npx @next/codemod@canary middleware-to-proxy . to migrate from middleware.ts.

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.