NextJS DOC
Part 15 of 15Next.js Proxy Deep Dive: Edge-First Request Interception
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:
- Network Boundary: Proxy implies a network layer in front of the application
- Edge Deployment: Proxy defaults to Edge Runtime, running closer to clients
- Distinct from Express: Avoids confusion with Express.js middleware patterns
- 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*'],
}
Cookie Management
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.ts→proxy.tsexport 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
-
Edge-First Architecture: Proxy runs at the edge before routes, ideal for fast redirects, rewrites, and header manipulation.
-
Single File Convention: Only one
proxy.tsper project; organize logic via imports from modules. -
Use Matchers: Always configure matchers to avoid running Proxy on every request (static files, images, etc.).
-
Headers Flow: Use
NextResponse.next({ request: { headers } })for upstream headers,response.headers.set()for client-facing headers. -
Don't Fetch Slow Data: Proxy should be fast; use Route Handlers or Server Components for data fetching.
-
Not a Security Boundary: Always verify authentication/authorization in Server Functions and Route Handlers, not just Proxy.
-
waitUntil for Background Work: Use
event.waitUntil()for analytics, logging, or cache warming without blocking the response. -
Migration: Run
npx @next/codemod@canary middleware-to-proxy .to migrate frommiddleware.ts.
What did you think?
Related Posts
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min
Next.js Metadata and OG Images Deep Dive: SEO, Social Sharing, and Dynamic Generation
April 3, 202672 min