# Nextjs Middleware Vitest Jsdom

> 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().

- Skill: `hubeiqiao/nextjs-middleware-vitest-jsdom` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hubeiqiao/nextjs-middleware-vitest-jsdom`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hubeiqiao/nextjs-middleware-vitest-jsdom/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: hubeiqiao (https://skillmd.com/u/hubeiqiao)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hubeiqiao/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.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()`.

```typescript
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

1. **`importOriginal`**: Keeps `NextRequest` real so you can test headers, cookies,
   and URL parsing without additional mocking.

2. **`cookies.set()` writes to headers**: The mock appends `set-cookie` headers so
   tests can verify cookie behavior via `res.headers.get('set-cookie')`.

3. **Default status 307 for redirects**: Matches Next.js's default redirect behavior.
   Pass an explicit status (e.g., 301) for permanent redirects.

### Testing Patterns

```typescript
// 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:
1. Pure function tests (exported from middleware) should work without any mocking
2. Full middleware integration tests should pass without "instanceof Headers" errors
3. 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

- Next.js middleware docs: https://nextjs.org/docs/app/building-your-application/routing/middleware
- Next.js `handleMiddlewareField` source (where the instanceof check lives):
  `node_modules/next/src/server/web/spec-extension/response.ts`
- Vitest environment configuration: https://vitest.dev/config/#environment

