nextjs-middleware-vitest-jsdom
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.tsthat callsNextResponse.next()orNextResponse.redirect() - Using Vitest with
environment: 'jsdom'(common default for Next.js projects) NextRequestconstructs fine, butNextResponse.next()fails- The error occurs at the
instanceof Headerscheck 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';
// Mock NextResponse to bypass edge-runtime Headers mismatch in jsdom
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 AFTER mocking
import { middleware } from '@/middleware';
Key Design Decisions
-
importOriginal: KeepsNextRequestreal so you can test headers, cookies, and URL parsing without additional mocking. -
cookies.set()writes to headers: The mock appendsset-cookieheaders so tests can verify cookie behavior viares.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
// Redirect assertions
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');
// Passthrough assertions
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()orresponse.cookies.delete(), extend the mock'scookiesobject accordingly. - If you also mock
@supabase/ssr(common for auth middleware), place that mock alongside thenext/servermock, 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-environmentorvitest-environment-edge-runtime, but these require additional setup and may conflict with jsdom-dependent tests elsewhere in the suite.
References
- Next.js middleware docs: https://nextjs.org/docs/app/building-your-application/routing/middleware
- Next.js
handleMiddlewareFieldsource (where the instanceof check lives):node_modules/next/src/server/web/spec-extension/response.ts - Vitest environment configuration: https://vitest.dev/config/#environment