skills/hubeiqiao/skills/nextjs-middleware-vitest-jsdom

nextjs-middleware-vitest-jsdom

Installation
SKILL.md

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';

// 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

// 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

Weekly Installs
1
First Seen
7 days ago