How to Chain Multiple Middlewares in Next.js App Router

This guide is part of Building a Custom Middleware Chain, within the wider Middleware Chain Architecture & Request Flow overview.

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.

Composed middleware pipeline in middleware.ts A single default export receives the request, clones headers, then runs auth, rate-limit, and geo handlers in order; any handler that returns a response exits the chain immediately, otherwise the cloned response continues. NextRequest clone headers composeMiddleware (single export) authCheck rateLimit geoRouting returns response? exit early redirect / rewrite / 4xx Route / handler
One default export drives the chain: each handler either returns a response and exits early, or yields control so the cloned response reaches the route handler.

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

Frequently Asked Questions

Can I export more than one function from middleware.ts?

No. The Next.js App Router requires a single default export from middleware.ts. To run multiple stages, compose them inside one exported function with a factory like composeMiddleware and let each handler decide whether to terminate the chain.

Why do my custom headers disappear in the route handler?

The Edge Cache strips non-standard headers between middleware and the route handler. Forward them explicitly with NextResponse.next({ request: { headers } }), or list the allowed keys in x-middleware-override-headers.

What causes ERR_INVALID_STATE in chained middleware?

It is thrown when you return an already-consumed Response or mutate headers after NextResponse.next() has been created. Clone headers once at pipeline entry and never reuse a response instance across handlers.

How do I keep the chain inside the cold-start budget?

Order the cheapest, highest-failure checks first so most requests exit early, scope the matcher to skip static assets, and defer external KV or database lookups to route handlers. Aim to keep total chain work under roughly half the 50ms cold-start window.

How should I test a composed middleware pipeline?

Unit-test the composeMiddleware factory directly with Vitest and mock NextRequest objects. Assert that a redirecting handler halts the chain and that later handlers never run. Avoid integration tests that boot a full Next.js server.