How to Chain Multiple Middlewares in Next.js App Router
Introduction & Problem Definition
Attempting to export multiple functions or chain sequential NextResponse.next() calls in middleware.ts consistently results in silent logic drops, duplicated header mutations, or ERR_INVALID_STATE crashes. The Next.js App Router enforces a strict single-export architecture for middleware.ts. When developers bypass this constraint with naive sequential execution, downstream handlers receive frozen request objects, causing authentication checks to fail, routing guards to duplicate, and response streams to terminate prematurely. Understanding how to safely orchestrate request lifecycle boundaries is critical for maintaining deterministic routing behavior across distributed deployments (Middleware Chain Architecture & Request Flow).
Root Cause Analysis: Edge Limits & Header Immutability
The failure stems from Vercel Edge Runtime constraints, not framework bugs. NextRequest and NextResponse are intentionally frozen at the V8 isolate level to guarantee predictable cold-start performance. Direct mutation of these objects violates the Edge specification, triggering ERR_INVALID_STATE when the runtime attempts to serialize an already-consumed response stream.
Key runtime boundaries:
- Header Immutability: Request headers are read-only snapshots. Mutating them without explicit cloning breaks downstream propagation.
- Cache Stripping: The Edge Cache aggressively sanitizes responses. Non-standard headers or
Set-Cookiedirectives are dropped unless explicitly forwarded viax-middleware-override-headersorNextResponse.next({ headers }). - Execution Budget: Cold starts are capped at 50ms. Synchronous blocking operations or unbounded async chains exceed the isolate timeout, causing silent request drops before the response reaches the client.
Next.js intentionally omits a native pipeline to prevent uncontrolled memory growth and ensure sub-50ms cold starts. Composable patterns must respect these boundaries explicitly.
Implementation: Composable Middleware Factory Pattern
The production-ready solution uses a factory function that iterates through an array of async handlers, enforcing early-exit guards and immutable header cloning. This pattern scales cleanly as routing complexity grows (Building a Custom Middleware Chain).
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Define handler signature: returns NextResponse to terminate, void to continue
type MiddlewareHandler = (
request: NextRequest,
response: NextResponse
) => Promise<NextResponse | void>;
/**
* Composes multiple Edge-compatible middleware handlers into a single pipeline.
* Enforces early-exit on redirects/rewrites and clones headers to bypass Edge immutability.
*/
export function composeMiddleware(handlers: MiddlewareHandler[]) {
return async (request: NextRequest): Promise<NextResponse> => {
// Clone headers immediately to preserve downstream context and bypass Edge immutability
const response = NextResponse.next({
request: { headers: new Headers(request.headers) },
});
for (const handler of handlers) {
const result = await handler(request, response);
// Early-exit guard: terminate chain on redirect, rewrite, or explicit response
if (result) {
// Prevent double-response errors by halting execution immediately
return result;
}
}
return response;
};
}
// Individual handlers (Edge-compatible, no Node.js built-ins)
const authCheck: MiddlewareHandler = async (req, res) => {
const token = req.cookies.get('session_token')?.value;
if (!token && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
// Forward auth context to downstream handlers
res.headers.set('x-user-authenticated', token ? 'true' : 'false');
};
const rateLimit: MiddlewareHandler = async (req, res) => {
const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'unknown';
// Implement lightweight token bucket or KV lookup here
// Return NextResponse.json({ error: 'Rate Limited' }, { status: 429 }) if exceeded
};
const geoRouting: MiddlewareHandler = async (req, res) => {
const country = req.geo?.country;
if (country === 'EU' && req.nextUrl.pathname.startsWith('/api')) {
return NextResponse.rewrite(new URL('/api/eu-proxy', req.nextUrl));
}
};
// Export the composed handler as the SOLE default export
export default composeMiddleware([authCheck, rateLimit, geoRouting]);
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)',
],
};
Configuration & Matcher Optimization
The matcher array dictates which routes trigger Edge invocation. Over-matching /.* forces the runtime to spin up isolates for static assets, inflating cold-start latency and consuming the 1MB bundle budget unnecessarily.
Use precise negative lookahead regex to exclude non-essential paths:
export const config = {
matcher: [
// Match all routes except static assets, Next.js internals, and public files
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|webp)).*)',
],
};
Route prioritization is handled by the array order in composeMiddleware. Place high-frequency, low-latency checks (e.g., auth tokens, rate limits) first. Heavy operations (e.g., external KV lookups, complex rewrites) should run last or be deferred to route handlers to preserve the 50ms cold-start window.
Local vs Production Runtime Divergence
next dev and production Edge deployments operate on fundamentally different runtimes. Code that passes locally will frequently crash in production if constraints are ignored.
| Constraint | next dev (Node.js) |
Production (Edge/Cloudflare Workers) |
|---|---|---|
| Runtime Engine | V8 + Node.js APIs | V8 Isolate (No fs, net, child_process) |
| Header Mutation | Tolerates synchronous in-place edits | Strictly immutable; requires new Headers() cloning |
| Bundle Limit | None (disk-bound) | 1MB strict (gzip) |
| Execution Timeout | ~30s (Node default) | 50ms cold start, 10s warm |
| Header Sanitization | Pass-through | Aggressive stripping of non-standard headers |
Debugging Production Failures:
ERR_INVALID_STATE: Caused by returning a consumedResponseobject or mutating headers afterNextResponse.next()is called. Fix: Clone headers at pipeline entry, never reuse response instances across handlers.Response already consumed: Triggered by callingres.json()orres.text()in multiple handlers. Fix: ReturnNextResponseonly on terminal conditions; passvoidotherwise.- Missing Headers in Route Handlers: Edge cache strips custom headers. Fix: Explicitly forward via
NextResponse.next({ headers: res.headers })or setx-middleware-override-headersto a comma-separated list of allowed keys.
Validation & Edge Case Handling
Test middleware pipelines using Vitest with @next/test or node:vm mocks. Avoid integration tests that spin up full Next.js servers; unit-test the composeMiddleware factory directly.
// __tests__/middleware.test.ts
import { composeMiddleware } from '../middleware';
import { NextRequest, NextResponse } from 'next/server';
describe('composeMiddleware', () => {
it('halts execution on redirect', async () => {
const redirectHandler = async () => NextResponse.redirect('https://example.com');
const pipeline = composeMiddleware([redirectHandler, async () => { throw new Error('Should not run'); }]);
const req = new NextRequest('https://app.com/protected');
const res = await pipeline(req);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe('https://example.com');
});
});
Common Pitfalls & Resolutions:
- Duplicate
Set-CookieHeaders: Edge runtimes merge cookies incorrectly if multiple handlers callres.cookies.set(). Fix: Use a single cookie handler at the end of the pipeline, or explicitly merge cookies into a singleSet-Cookieheader before returning. - Async Race Conditions: Parallel
awaitcalls in a single handler exceed the 50ms budget. Fix: UsePromise.allSettledonly for non-critical telemetry; block on auth/routing. - Cache-Control Conflicts: Middleware
Cache-Controlheaders override page-level directives. Fix: Only setCache-Controlin middleware for static redirects; defer dynamic caching to route handlers orgenerateMetadata.
Production Deployment Checklist:
- Bundle size verified under 1MB (
next build - All
NextRequest/NextResponse -
matcherexcludes static assets and_next - No Node.js built-ins (
fs,cryptonative,process.env - Custom headers explicitly forwarded via