| name | nextjs-middleware-vitest-jsdom |
| description | Fix "request.headers must be an instance of Headers" error when testing Next.js
middleware with Vitest in jsdom environment. Use when: (1) NextResponse.next()
throws "request.headers must be an instance of Headers" in vitest/jsdom,
(2) middleware integration tests fail despite NextRequest being constructed
correctly, (3) testing locale detection, redirect logic, or cookie handling
in Next.js middleware without edge runtime. Covers mocking NextResponse.next()
and NextResponse.redirect() to bypass the jsdom/edge-runtime Headers class
mismatch while preserving testable .status, .headers, and .cookies.set().
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-16T00:00:00.000Z" |
| tags | Next.js, middleware, vitest, jsdom, Headers, NextResponse, testing, i18n |
Next.js Middleware Testing in Vitest/jsdom
Problem
When testing Next.js middleware with Vitest using the jsdom environment,
NextResponse.next({ request: { headers: request.headers } }) throws:
Error: request.headers must be an instance of Headers
at handleMiddlewareField node_modules/next/src/server/web/spec-extension/response.ts:18:13
at Function.next node_modules/next/src/server/web/spec-extension/response.ts:150:5
Context / Trigger Conditions
- Testing
middleware.ts that calls NextResponse.next() or NextResponse.redirect()
- Using Vitest with
environment: 'jsdom' (common default for Next.js projects)
NextRequest constructs fine, but NextResponse.next() fails
- The error occurs at the
instanceof Headers check inside Next.js internals
Root Cause
Next.js middleware runs in the edge runtime, which uses its own Headers class
(from undici / Node.js built-in). When testing in jsdom, the Headers class
is jsdom's polyfill. Since these are different classes, the instanceof check in
NextResponse.next() fails — even though the headers object is functionally identical.
This is NOT a bug in your code. It's a runtime environment mismatch between jsdom
and Next.js's edge runtime internals.
Solution
Mock NextResponse (but keep NextRequest real) to bypass the internal Headers check.
The mock provides minimal response objects with .status, .headers, and .cookies.set().
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal<typeof import('next/server')>();
function createMockResponse(init?: { status?: number; headers?: Record<string, string> }) {
const headers = new Headers(init?.headers);
const status = init?.status ?? 200;
return {
status,
headers,
cookies: {
set(
nameOrObj: string | { name: string; value: string; [k: string]: unknown },
value?: string,
) {
if (typeof nameOrObj === 'string') {
headers.append('set-cookie', `${nameOrObj}=${value}`);
} else {
headers.append('set-cookie', `${nameOrObj.name}=${nameOrObj.value}`);
}
},
},
};
}
const MockNextResponse = Object.assign(
function MockNextResponse() {},
{
next: () => createMockResponse(),
redirect: (url: URL | string, status?: number) => {
const location = url instanceof URL ? url.toString() : url;
return createMockResponse({ status: status ?? 307, headers: { location } });
},
},
);
return {
...actual,
NextResponse: MockNextResponse,
};
});
import { middleware } from '@/middleware';
Key Design Decisions
-
importOriginal: Keeps NextRequest real so you can test headers, cookies,
and URL parsing without additional mocking.
-
cookies.set() writes to headers: The mock appends set-cookie headers so
tests can verify cookie behavior via res.headers.get('set-cookie').
-
Default status 307 for redirects: Matches Next.js's default redirect behavior.
Pass an explicit status (e.g., 301) for permanent redirects.
Testing Patterns
const res = await middleware(req);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toContain('/zh-CN');
expect(res.headers.get('set-cookie')).toContain('NEXT_LOCALE=zh-CN');
const res = await middleware(req);
expect(res.status).toBe(200);
expect(res.headers.get('location')).toBeNull();
Verification
After applying the mock:
- Pure function tests (exported from middleware) should work without any mocking
- Full middleware integration tests should pass without "instanceof Headers" errors
- Redirect status codes, location headers, and cookie behavior are all testable
Notes
- If your middleware uses
response.cookies.get() or response.cookies.delete(),
extend the mock's cookies object accordingly.
- If you also mock
@supabase/ssr (common for auth middleware), place that mock
alongside the next/server mock, before the middleware import.
- This mock is intentionally minimal — it only covers what middleware tests typically
assert. Extend as needed for your specific middleware logic.
- An alternative approach is to use
@edge-runtime/jest-environment or
vitest-environment-edge-runtime, but these require additional setup and may
conflict with jsdom-dependent tests elsewhere in the suite.
References