Configuring Vitest for Next.js App Router

The Next.js App Router enforces strict server/client component boundaries and an ESM-only module graph, and a default Vitest config breaks the moment a test imports next/navigation, next/dynamic, or next/font. This guide is for frontend and full-stack developers running Next.js 14 or 15 (App Router) who want fast, deterministic component tests under Vitest 2 with React 18 or 19, without spinning up a full browser. It builds on the baseline in Vitest configuration and setup and focuses on the exact resolver and mocking adjustments the App Router demands.

Root Cause Analysis

Three forces collide when Vitest meets the App Router. First, Vite pre-bundles dependencies as CommonJS for speed, but the App Router ships ESM-only internals; pre-bundling them produces SyntaxError: Cannot use import statement outside a module. Second, App Router routing hooks (useRouter, usePathname, useSearchParams) read from a runtime context that Next.js injects only inside a real request — in a test there is no provider, so the hooks return undefined and throw Cannot read properties of undefined. Third, server components and client components live in the same tree but execute in different runtimes; jsdom can only render the client half, so a naive render of a server component or a missing use client boundary surfaces as a hydration text mismatch. Each symptom traces back to a single missing instruction: tell Vite to transform Next internals instead of pre-bundling them, and supply the routing context the runtime would otherwise provide.

It helps to understand why Next.js leans on ESM-only internals. The App Router compiler emits modules that use top-level import/export and conditional exports keyed on the react-server condition. When Node or Vite resolves next/navigation, the resolver picks an export branch based on the active conditions; outside a Next build, the default branch is the client implementation, but the file still arrives as untransformed ESM. Vite’s dependency optimizer (esbuild-based) normally rewrites such files into a single pre-bundled CommonJS chunk, and that rewrite assumes the file is plain JavaScript. Next internals frequently include syntax the optimizer chokes on — JSX in .js files, the server-only poison-pill import, and re-exports of compiled directories — so the safe move is to skip pre-bundling entirely and let Vite’s transform pipeline (with the React plugin’s Babel/SWC step) process them on demand. That is precisely what server.deps.inline toggles.

The routing failure has a subtler cause than “no provider.” Unlike the Pages Router, where useRouter came from next/router and could be wrapped in a RouterContext.Provider, the App Router hooks read from internal symbols (AppRouterContext, PathnameContext, SearchParamsContext) that Next.js does not export publicly. There is no supported provider you can mount in a test to feed them real values. That is why mocking the module wholesale — rather than wrapping the tree in a context — is the canonical pattern: you replace the hooks at the import boundary so the component receives controllable return values, and you assert on what the component does with those values (navigates, renders an active state) rather than on the router’s internal plumbing. Recognizing that these three failures share one root — the test runtime is not the Next runtime — keeps you from chasing each symptom with a different, fragile workaround.

Reproducible Setup

Install the test toolchain. The React plugin is mandatory because it transforms JSX and supplies the automatic runtime.

npm install -D vitest @vitejs/plugin-react jsdom \
  @testing-library/react @testing-library/jest-dom

Create the config. The three load-bearing lines are the react() plugin, environment: 'jsdom', and inlining next so Vite transforms it.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: false,
    setupFiles: ['./src/setup.ts'],
    server: {
      deps: {
        // Force Vite to transform Next internals instead of pre-bundling them
        inline: [/^next\//, 'next'],
      },
    },
    alias: {
      '@/': fileURLToPath(new URL('./src/', import.meta.url)),
    },
  },
});

Add the setup file. It registers DOM matchers and polyfills the browser APIs that App Router components touch but jsdom omits.

// src/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';

global.ResizeObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn(),
}));

// App Router layouts and `next/image` frequently read matchMedia;
// jsdom does not implement it, so stub a non-matching default.
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  }),
});

afterEach(() => cleanup());

A quick diagram of how a single test request flows through the layers makes the configuration concrete — the box you control is the resolver, and everything downstream depends on it routing Next imports to transformed code or to your stubs:

Vitest resolution path for a Next.js App Router test A test file imports a component; Vitest routes Next internals through the inline transform and routing hooks through manual mocks before rendering in jsdom. Test file .test.tsx Vitest resolver server.deps.inline Next internals transformed (ESM) next/navigation vi.mock stub jsdom render

Implementation

Step 1 — Stub the routing hooks. Place a shared mock at the project root so every test resolves the same stub. Vitest matches __mocks__/next/navigation.ts when you call vi.mock('next/navigation').

// __mocks__/next/navigation.ts
import { vi } from 'vitest';

export const useRouter = vi.fn(() => ({
  push: vi.fn(),
  replace: vi.fn(),
  back: vi.fn(),
  prefetch: vi.fn(),
}));
export const usePathname = vi.fn(() => '/');
export const useSearchParams = vi.fn(() => new URLSearchParams());

Step 2 — Hoist the mock in the test. vi.mock is hoisted above imports, so declare it before importing the component that consumes the router.

// src/components/NavButton.test.tsx
import { vi, describe, it, expect } from 'vitest';

vi.mock('next/navigation'); // resolves the __mocks__ stub above

import { render, screen } from '@testing-library/react';
import { NavButton } from '@/components/NavButton';

describe('NavButton', () => {
  it('renders its label', () => {
    render(<NavButton href="/dashboard">Open</NavButton>);
    expect(screen.getByRole('link', { name: 'Open' })).toBeInTheDocument();
  });
});

Step 3 — Stub next/link when it interferes. If the real component pulls in router internals, replace it with a plain anchor.

vi.mock('next/link', () => ({
  default: ({ children, href }: { children: React.ReactNode; href: string }) => (
    <a href={href}>{children}</a>
  ),
}));

Step 4 — Assert on navigation behavior, not router internals. Because the router is now a stub, you can capture the push spy and verify a component triggers the navigation it should. This is the highest-value pattern for App Router tests: it covers the user-observable consequence (a click navigates) without depending on Next’s runtime.

// src/components/LogoutButton.test.tsx
import { vi, describe, it, expect, beforeEach } from 'vitest';

const push = vi.fn();
vi.mock('next/navigation', () => ({
  useRouter: () => ({ push, replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }),
  usePathname: () => '/dashboard',
  useSearchParams: () => new URLSearchParams('?ref=nav'),
}));

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LogoutButton } from '@/components/LogoutButton';

describe('LogoutButton', () => {
  beforeEach(() => push.mockClear());

  it('redirects to /login after logging out', async () => {
    render(<LogoutButton />);
    await userEvent.click(screen.getByRole('button', { name: /log out/i }));
    expect(push).toHaveBeenCalledWith('/login');
  });
});

Note the inline factory form of vi.mock here: when you need a shared spy (push) that the test body asserts on, define it in module scope and reference it inside the factory. The factory is hoisted with the call, but variable references are resolved lazily at render time, so the spy is wired correctly.

Step 5 — Drive usePathname and useSearchParams per test. Components that highlight an active link or read a query param need those hooks to return different values across tests. Re-mock them with controllable implementations and override per case.

import { vi, it, expect } from 'vitest';
import { usePathname } from 'next/navigation';
import { render, screen } from '@testing-library/react';
import { NavLink } from '@/components/NavLink';

vi.mock('next/navigation');

it('marks the link active on a matching path', () => {
  vi.mocked(usePathname).mockReturnValue('/settings');
  render(<NavLink href="/settings">Settings</NavLink>);
  expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute(
    'aria-current',
    'page',
  );
});

Step 6 — Test only client components directly. Server components are async and read request context; render their client children instead, or extract the client logic into a use client component you can mount. For async data in a client component, wrap the render in act:

import { render, screen, waitFor } from '@testing-library/react';
import { act } from 'react';
import { Profile } from '@/components/Profile';

it('shows fetched data without a hydration mismatch', async () => {
  await act(async () => {
    render(<Profile userId="1" />);
  });
  await waitFor(() => expect(screen.getByRole('heading')).toBeInTheDocument());
});

For mocking the network these components call, prefer request interception over per-call stubs — the same approach used across the rest of the suite and consistent with Testing Library best practices of asserting on user-visible output. A typical setup boots an MSW server in the same src/setup.ts so every component’s fetch is intercepted with a deterministic response, using the v2 resolver signature:

// src/setup.ts (excerpt — MSW v2)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { afterAll, afterEach, beforeAll } from 'vitest';

const server = setupServer(
  http.get('/api/profile/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, name: 'Ada Lovelace' }),
  ),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Setting onUnhandledRequest: 'error' is deliberate: an App Router component that fetches an unmocked endpoint should fail loudly rather than hit the network or return undefined, which would otherwise surface much later as a confusing render assertion failure.

Verification

Run the suite and confirm a clean exit:

npx vitest run --reporter=verbose

A correctly wired run prints the jsdom environment, resolves next/navigation to your stub (no undefined router errors), and produces zero Cannot use import statement outside a module traces. Expected output for the example above:

 ✓ src/components/NavButton.test.tsx (1)
   ✓ NavButton > renders its label
 ✓ src/components/Profile.test.tsx (1)
   ✓ Profile > shows fetched data without a hydration mismatch

 Test Files  2 passed (2)
      Tests  2 passed (2)

If you see those two files pass, the resolver, the jsdom environment, and the routing stub are all functioning. Target benchmarks once stable: under 50 ms per component test and zero unhandled promise rejections in CI logs.

To prove the routing stub is genuinely controlling behavior rather than passing by accident, assert on the spy directly. A test that clicks a button and confirms expect(push).toHaveBeenCalledWith('/login') fails immediately if the mock regresses — far more informative than a silent undefined router. Pair that with a coverage run (npx vitest run --coverage) and confirm the client components that own navigation logic actually report covered lines; an App Router suite that reports zero coverage on its use client files usually means the components never mounted because an import threw during collection. Finally, run the suite once with --no-threads if you suspect a leak between files: if a test passes in isolation but fails in the full run, shared module state (a stale vi.mock return value, an unreset MSW handler) is the likely cause, and the next section covers the fixes.

Troubleshooting

SyntaxError: Unexpected token 'export' from a Next subpath. Vite is still pre-bundling a Next internal. Widen the inline pattern to inline: [/^next\//] and, if a compiled directory is the culprit, externalize it via server.deps.external: [/node_modules\/next\/dist\/compiled\//].

TypeError: Cannot read properties of undefined (reading 'push'). The router stub was not applied before the component imported it. Move vi.mock('next/navigation') to the very top of the file, above the component import — hoisting only works when the call is in the module scope.

Warning: Text content did not match. A server-only value (a date, a random id, request headers) rendered differently in the test than the component expects. Make the value deterministic, mock the data source, or move the dynamic piece behind a use client boundary. Debugging these mismatches end to end is covered in debugging hydration mismatches in Next.js tests.

Error: cannot access 'push' before initialization inside a vi.mock factory. The factory references a const that Vitest’s hoisting moves above its declaration. Wrap the spy in vi.hoisted so it is created during the hoist phase: const push = vi.hoisted(() => vi.fn()). This is the supported way to share a spy between an inline factory and the test body.

server-only import throws during collection. A file you imported (often a layout or a data helper) carries the import 'server-only' poison pill, which is designed to crash outside a server build. Either avoid importing that module from a client test — import the extracted client component instead — or alias server-only to an empty module via test.alias: { 'server-only': new URL('./test/empty.ts', import.meta.url).pathname }.

Tests pass alone but fail together. A mock or MSW handler is leaking across files. Confirm afterEach(() => cleanup()), server.resetHandlers(), and (if you stub return values per test) vi.restoreAllMocks() are all wired in setup. Mocked module return values set with mockReturnValue persist across tests in the same file unless cleared, so reset them in beforeEach.

next/font import fails to resolve. Font loaders run at build time and have no runtime equivalent. Mock the specific loader you use — for example vi.mock('next/font/google', () => ({ Inter: () => ({ className: 'inter', variable: '--inter' }) })) — so any component reading font.className still renders a stable class.

FAQ

Does this configuration work with Jest as well as Vitest?

The concepts transfer, but the config does not. Jest needs next/jest (the official createJestConfig wrapper) plus testEnvironment: 'jsdom' and a moduleNameMapper for aliases, whereas Vitest relies on Vite’s transform pipeline and server.deps.inline. The mocking calls differ only by name (jest.mock versus vi.mock), so test bodies are nearly identical; the runner configuration is what you rewrite.

Can I render an async server component directly in a test?

Not reliably under jsdom. Async server components depend on the App Router’s server runtime and request context, which Vitest does not reproduce. Extract the rendering logic into a client component and pass the already-resolved data as props, then assert on that client component — this keeps the test fast and deterministic while still covering the markup users see.

Why inline next instead of adding it to optimizeDeps.exclude?

optimizeDeps controls Vite’s dev-server pre-bundling, but Vitest uses its own server.deps graph for test transforms. Inlining via test.server.deps.inline is the option Vitest actually reads, so it is the one that prevents the ESM SyntaxError. Editing optimizeDeps alone has no effect inside the test runner.

How do I keep node-only tests fast when most files need jsdom?

Tag pure-logic tests with a // @vitest-environment node docblock at the top of the file. Vitest reads it per file and runs that test in the lighter node environment while the rest of the suite stays on jsdom, so reducers and utilities do not pay the DOM startup cost.

How do I test a Route Handler (app/api/route.ts) under Vitest?

Route Handlers are plain functions that take a Request and return a Response, so you can call them directly without jsdom: import the GET or POST export, construct a new Request(url, init), await the handler, and assert on the returned Response. Run these in the node environment via a docblock, mock any data layer the handler calls, and avoid NextRequest-specific helpers unless your handler genuinely depends on them, since a standard Request keeps the test free of Next runtime coupling.

Should I share one config or split Next tests into a Vitest project?

For a single app, one config with environment: 'jsdom' plus per-file node docblocks is simplest. In a monorepo where the Next app sits alongside pure libraries, splitting into separate configs pays off, and you can centralize the shared options as described in sharing a Vitest config across a Turborepo so the App Router-specific inline rules live in one place every package extends.