`cookies()`, `headers()`, `redirect()` — Patterns for Mocking Next.js App Router Server APIs in Vitest
When working with the Next.js App Router and writing test files, you may suddenly encounter an error like this:
Error: This module cannot be imported outside of a request scopeThe first time I saw this error, I was momentarily stumped. The app was running fine, but it was exploding only in the test environment. Next.js server APIs like cookies(), headers(), and redirect() are designed to work exclusively within an actual request context, so importing them in the Node.js or jsdom environment that Vitest runs in will immediately throw an error.
In this post, we'll walk through step by step: from the basic pattern of replacing server APIs with vi.mock(), to the common false-positive pitfall when verifying redirect(), and all the way to Route Handler integration testing. This is a summary from real hands-on experience, so you can apply it directly to your project. It's a perfect fit if you're already using the Next.js App Router and have Vitest installed.
Once you properly configure your unit test setup just once, you can focus purely on your logic from then on. Let's build that foundation together.
Core Concepts
Why Server APIs Die Immediately in Test Environments
cookies() and headers() are provided by the next/headers package. Internally, these functions depend on React's cache() and Node.js's AsyncLocalStorage to retrieve data bound to the current request scope. Since that scope doesn't exist in the Vitest environment, importing them throws an error on the spot.
redirect() and notFound() come from next/navigation. In the actual runtime, they work by throwing a signal called NEXT_REDIRECT to forcibly terminate the call stack. This behavior becomes an important point when writing mocks — if there's no throw in the mock, code after a redirect continues executing and tests pass falsely.
AsyncLocalStorage: A mechanism in Node.js for propagating asynchronous context. It provides independent storage per request so that data from the same request can be referenced across callbacks and Promise chains. It's the core of Next.js's internal structure for isolating cookies and headers per request.
What Changed in Next.js 15+
Starting with Next.js 15, cookies() and headers() were fully converted to async functions. They now return Promises. While synchronous-style mocks still compile, if you want to properly test a Next.js 15+ environment, it's better to update your mocks to return Promise.resolve() as well.
// Next.js 14 and below style (still works but doesn't accurately reflect the async path)
cookies: vi.fn(() => ({ get: vi.fn() }))
// Next.js 15+ style (correctly simulates async cookie access)
cookies: vi.fn(() => Promise.resolve({ get: vi.fn() }))The examples in this post are written for Next.js 15+.
The Overall Test Strategy Structure
There are three main approaches for testing code that includes server APIs.
| Approach | Target | Tool |
|---|---|---|
vi.mock() unit tests |
Server Actions, Service layer logic | Vitest |
| Integration tests | Route Handlers | next-test-api-route-handler |
| E2E tests | async Server Components, full flow | Playwright |
One thing to mention upfront: unit testing async Server Components by directly rendering them in Vitest is currently not officially supported. Covering that with Playwright is the currently recommended direction.
Practical Application
Now that we have the concepts, let's move to code. Starting with a single config file, we'll walk through scenarios in the order you're most likely to encounter them in practice.
Example 1: Global Mock Setup in vitest.setup.ts
This is the most fundamental pattern. Set it up once in your project and it applies automatically to all test files.
// vitest.setup.ts
import { vi } from 'vitest'
vi.mock('next/headers', () => ({
cookies: vi.fn(() =>
Promise.resolve({
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
})
),
headers: vi.fn(() =>
Promise.resolve({
get: vi.fn(),
set: vi.fn(),
entries: vi.fn(() => []),
})
),
}))
vi.mock('next/navigation', () => ({
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT: ${url}`)
}),
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
useRouter: vi.fn(),
usePathname: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}))// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
environment: 'happy-dom',
},
})| Item | Description |
|---|---|
vi.mock('next/headers', ...) |
Prevents errors at import time and replaces with an empty implementation |
throw in redirect |
Mimics the real behavior to block execution of subsequent code — without this, false positives occur |
setupFiles registration |
Applied automatically before every test file runs |
happy-dom: A faster DOM environment implementation compared to jsdom, widely used by the community instead of jsdom for Next.js testing. You can use it by setting
environmentto'happy-dom'invitest.config.ts.
Example 2: Verifying redirect() Behavior — The False Positive Trap
Honestly, this is the most common mistake. If you define redirect() with only vi.fn(), the function is called and simply returns undefined. In the actual runtime, a throw is raised so code after it doesn't execute — but without a throw in the mock, logic after a redirect keeps running and the test "passes." This is a false positive.
// auth.action.ts
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
export async function protectedAction() {
const cookieStore = await cookies()
const session = cookieStore.get('session')
if (!session) {
redirect('/login') // In reality, a throw occurs here and execution ends
}
return { data: 'sensitive information' }
}// auth.action.test.ts
import { protectedAction } from './auth.action'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { vi, expect, test, beforeEach } from 'vitest'
beforeEach(() => {
vi.clearAllMocks()
})
test('accessing without a session redirects to /login', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn(() => undefined),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
// Since redirect throws, we verify with rejects
await expect(protectedAction()).rejects.toThrow('NEXT_REDIRECT: /login')
expect(redirect).toHaveBeenCalledWith('/login')
})False positive: When a test passes but hasn't actually verified anything. If the
redirect()mock has no throw, subsequent code also executes and it appears to "pass," but it doesn't match the real behavior.
beforeEach(() => vi.clearAllMocks()) prevents state contamination between tests. Without it, mock return values set in a previous test carry over to the next, creating an unpleasant situation where results vary depending on execution order.
Example 3: Manipulating cookies() Return Values in Specific Tests
The global mock provides only default values; the pattern is to override them in individual tests with the scenario you need. We'll assume getAuthenticatedUser is a service function that looks up a user in the database using the session cookie value.
// user.service.test.ts
import { cookies } from 'next/headers'
import { getAuthenticatedUser } from './user.service'
import { vi, expect, test, describe, beforeEach } from 'vitest'
describe('getAuthenticatedUser', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('returns user info when session cookie is present', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn((name: string) => {
if (name === 'session') return { name: 'session', value: 'abc123' }
return undefined
}),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
// Returns the user's id retrieved from the DB with session value 'abc123'
const result = await getAuthenticatedUser()
expect(result).toBeDefined()
expect(result?.id).toBe('user-from-abc123')
})
test('returns null when session cookie is absent', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn(() => undefined),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
const result = await getAuthenticatedUser()
expect(result).toBeNull()
})
})There's a reason the as any type cast is needed. In TypeScript strict mode, the return type of cookies() is fixed as ReadonlyRequestCookies, so passing a mock object directly causes a type mismatch error. Working around it with as any is realistically practical enough compared to creating an object that fully implements the ReadonlyRequestCookies interface.
Example 4: Route Handler Integration Testing — next-test-api-route-handler
This approach calls Route Handlers through the actual Next.js internal resolver without mocks. It lets you test in an environment where cookies and headers behave as they do in reality.
// app/api/user/route.test.ts
import { testApiHandler } from 'next-test-api-route-handler'
import * as handler from '@/app/api/user/route'
import { test, expect } from 'vitest'
test('GET /api/user — fetch user info with a valid session cookie', async () => {
await testApiHandler({
appHandler: handler,
test: async ({ fetch }) => {
const res = await fetch({
method: 'GET',
headers: { cookie: 'session=abc123' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.id).toBeDefined()
},
})
})
test('GET /api/user — returns 401 without a session', async () => {
await testApiHandler({
appHandler: handler,
test: async ({ fetch }) => {
const res = await fetch({ method: 'GET' })
expect(res.status).toBe(401)
},
})
})Note: As of the current version,
next-test-api-route-handlermust be the first import in the test file. This is a constraint due to Next.js's internal structure — if other imports come first, it won't work correctly. This may change with version updates, so check the official documentation before use.
Example 5: Handling the server-only Package
Testing a module that imports server-only in Vitest will throw an environment error. Adding one line to the setup file resolves it.
// Add to vitest.setup.ts
vi.mock('server-only', () => ({}))Pros and Cons Analysis
Let's summarize in a table which approach fits which situation, and also look at the common pitfalls you're likely to encounter in practice.
Advantages
| Item | Description |
|---|---|
| Fast execution speed | Works without the actual Next.js runtime, resulting in short test cycles |
| Isolated unit tests | You can purely verify business logic without depending on cookies/sessions |
| Simple configuration | A single vitest.setup.ts file applies across the entire project at once |
| Jest compatibility | You can use existing Jest-style mock patterns almost as-is |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
redirect() false positives |
Using only vi.fn() means no throw, so subsequent code keeps executing — this is actually the most common pitfall |
Always include throw new Error(...) in the mock |
| Import-time errors | Importing next/headers without a mock immediately throws an error |
Pre-register vi.mock() in vitest.setup.ts |
| Cannot render async components | Vitest doesn't support directly rendering async Server Components | Cover with Playwright E2E |
server-only errors |
Importing a module containing that package in the Vitest environment throws an error | Add vi.mock('server-only', () => ({})) |
| Tool limited to Route Handlers | next-test-api-route-handler only applies to Route Handlers |
Server Components and Server Actions require separate strategies |
Most Common Mistakes in Practice
- Omitting
throwfrom theredirect()mock: Defining it with onlyvi.fn()means code after a redirect continues executing and tests pass. It's the most common cause of false positives, and I once lost an entire day to just this one issue. - Not registering
setupFiles: Creatingvitest.setup.tsbut forgetting to register it invitest.config.tsso the mock isn't applied. This is the number one cause of "I definitely set up the mock, why isn't it working?" - State contamination between tests: Omitting
beforeEach(() => vi.clearAllMocks())leaves mock return values from a previous test in the next one. This creates an unstable situation where results vary depending on test execution order.
Closing Thoughts
When replacing server APIs with vi.mock(), the two key points are: always include a throw in redirect(), and centrally manage global mocks in a single vitest.setup.ts. Getting just these two right resolves most of the confusion you'll encounter in a test environment.
Three steps you can start right now:
-
Create
vitest.setup.ts- Create the file at the project root and paste in the mock code for
next/headers,next/navigation, andnext/cache - If you're using
server-only, addvi.mock('server-only', () => ({}))as well
- Create the file at the project root and paste in the mock code for
-
Connect
vitest.config.ts- Add
setupFiles: ['./vitest.setup.ts']andenvironment: 'happy-dom' - This automatically applies the global mock to all tests
- Add
-
Review existing
redirect()tests- Change any tests that verify
redirect()to theexpect(...).rejects.toThrow('NEXT_REDIRECT: /path')pattern to check for false positives - If you need to test Route Handlers, install with
pnpm add -D next-test-api-route-handlerand separate them into integration tests
- Change any tests that verify
References
Official Documentation and Packages
- Next.js Official Vitest Guide | nextjs.org
- Next.js Official cookies() Function Reference | nextjs.org
- Vitest Official Mocking Guide | vitest.dev
- next-test-api-route-handler | npm
- next-test-api-route-handler | GitHub xunnamius
Community and Issue Trackers
- next/headers test failure discussion | GitHub vercel/next.js #44270
- How to test next/navigation redirect discussion | GitHub vercel/next.js #59061
- server-only + Vitest error issue | GitHub vercel/next.js #60038
- Testing Next.js App Router API routes | Arcjet Blog
- Mocking HTTP Headers in Next.js Server-Side Development Guide | Medium
- Next.js v16 + Storybook Component Testing (Module Mocks) | Mamezou