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-Cookie directives are dropped unless explicitly forwarded via x-middleware-override-headers or NextResponse.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 consumed Response object or mutating headers after NextResponse.next() is called. Fix: Clone headers at pipeline entry, never reuse response instances across handlers.
  • Response already consumed: Triggered by calling res.json() or res.text() in multiple handlers. Fix: Return NextResponse only on terminal conditions; pass void otherwise.
  • Missing Headers in Route Handlers: Edge cache strips custom headers. Fix: Explicitly forward via NextResponse.next({ headers: res.headers }) or set x-middleware-override-headers to 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:

  1. Duplicate Set-Cookie Headers: Edge runtimes merge cookies incorrectly if multiple handlers call res.cookies.set(). Fix: Use a single cookie handler at the end of the pipeline, or explicitly merge cookies into a single Set-Cookie header before returning.
  2. Async Race Conditions: Parallel await calls in a single handler exceed the 50ms budget. Fix: Use Promise.allSettled only for non-critical telemetry; block on auth/routing.
  3. Cache-Control Conflicts: Middleware Cache-Control headers override page-level directives. Fix: Only set Cache-Control in middleware for static redirects; defer dynamic caching to route handlers or generateMetadata.

Production Deployment Checklist:

  • Bundle size verified under 1MB (next build
  • All NextRequest/NextResponse
  • matcher excludes static assets and _next
  • No Node.js built-ins (fs, crypto native, process.env
  • Custom headers explicitly forwarded via