Next.js is not just React on top of a server. The project is built by the Next compiler, which understands things a plain Jest run has no idea about: path aliases from tsconfig, CSS modules, fonts via next/font, image imports treated as modules, environment variables from .env. A test, however, is executed not by the Next build but by Jest directly - without any of that context, and it trips on the first import.
The naive move is to describe all this infrastructure in jest.config by hand: declare transform, add a moduleNameMapper so CSS imports collapse into a stub, mock next/font, wire up the paths. This works right up to the first divergence. The moment the real build changes how it transforms code or starts handling a new import type, your test config is stuck in the past. Tests fail not on your code but on infrastructure you duplicated and forgot to keep in sync.
The right boundary has been drawn for you. The Next package exposes a nextJest factory: calling nextJest({ dir: './' }) reads the project next.config and returns a createJestConfig function. You hand it your minimal config, and it fills in everything it knows about the build: transform through the Next compiler (the same SWC used in production), automatic mocking of CSS and .module.css along with their scss variants, handling of image imports and next/font, loading .env into process.env. The result is a single source of truth - the real project configuration, not a copy of it.
What stays with you is only what belongs to a test as a test. The testEnvironment field picks the world the code runs in: jsdom - a browser emulation for components, node - for pure logic without a DOM. The setupFilesAfterEnv field wires in a file that runs before every test suite: that is where expect extensions go (the Testing Library matchers, for instance) and shared setup. Everything else createJestConfig supplies on its own.
App Router changes not so much the config as the very question of what is unit-testable at all. Code now comes in three kinds, and each is checked its own way. A Client Component - marked with the 'use client' directive - is ordinary React: it renders in jsdom and is checked through Testing Library from the user point of view, by roles and actions. A pure server function - computing a cart total, validating an order - knows nothing about React and is tested in the node environment as a plain module.
The third kind - an async Server Component - is not unit-testable, and that is not a whim of Jest but the nature of RSC. Such a component is an async function executed on the server: it awaits data, reaches server-only resources, emits a stream the framework assembles into markup. jsdom, by contrast, is a browser emulation, not a Next server. It does not reproduce the server lifecycle: there is no real render of an async tree, no streaming, no environment in which server-only modules make sense. Rendering such a component in jsdom means checking a fiction, not behavior.
Hence an honest division of labor. Push logic into pure functions and cover it in node - fast and deterministic. Check Client Components in jsdom by roles and actions. Close the link the server delivered data - the page showed the order end-to-end: Playwright or Cypress spin up a real Next and walk the scenario in a browser. The classic failure is dragging an async Server Component into jsdom, wrapping it in mocks of server-only APIs, and getting a green test that does not fail even on a blank page in production. The test exists, the guarantee does not.
import nextJest from 'next/jest.js'
import type { Config } from 'jest'
const createJestConfig = nextJest({ dir: './' })
const config: Config = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
}
export default createJestConfig(config)