
Next.js Setting up Jest
Jest paired with React Testing Library is the long-established default for unit and snapshot testing in the React ecosystem, and Next.js has carried genuinely first-class, built-in support for it since version 12 — a next/jest transformer that handles most of the Next.js-specific configuration pain (stylesheet mocking, next/font, environment variables) automatically, rather than leaving you to hand-wire Jest against a framework it wasn't originally built to understand.
The one thing worth knowing before you start
Stated plainly, the way the docs themselves state it: async Server Components are new enough to the React ecosystem that Jest doesn't currently support unit-testing them. You can still unit test synchronous Server and Client Components without issue — this limitation is specifically about the async case. For those, the recommendation across this whole testing series is consistent: reach for End-to-End testing instead, via Playwright or Cypress, rather than fighting tooling that isn't there yet.
The fastest path
npx create-next-app@latest --example with-jest with-jest-app
Setting it up manually
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node @types/jest
Generate a starting config rather than hand-writing one from scratch:
npm init jest@latest
This walks you through a short series of prompts and produces a jest.config.ts (or .js). The genuinely important next step is wrapping that config with next/jest, which is what actually wires Jest up to understand your Next.js project correctly:
// jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
dir: "./",
});
const config: Config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
};
// exported this way specifically so next/jest can load next.config.js, which is async
export default createJestConfig(config);
That specific export pattern — wrapping your config object in createJestConfig(...) and exporting the result, rather than exporting your config object directly — isn't a style preference; it's structurally necessary because next/jest needs to load your next.config.js to configure itself correctly, and that loading is asynchronous.
next/jest is doing a genuinely substantial amount of configuration work under the hood that you'd otherwise have to assemble by hand: it sets up transform using the Next.js Compiler itself, auto-mocks stylesheets (.css, .module.css, and their Sass equivalents) along with image imports and next/font so tests don't choke on imports Jest has no native way to process, loads your .env files (and every environment-specific variant) into process.env, excludes node_modules and .next from test resolution and transforms, and reads next.config.js itself to pick up any flags affecting SWC transforms. None of this is optional wiring you'd want to write yourself — it's exactly the category of Next.js-specific plumbing that makes "just use plain Jest" a genuinely worse starting point than next/jest.
One environment-variable detail worth knowing since it's easy to assume automatically works and then be confused when it doesn't: if you need to test environment variables directly, they need to be loaded manually — either in a separate setup script or directly in jest.config.ts — rather than assuming next/jest's automatic .env loading alone covers whatever specific testing scenario you have in mind.
Handling module path aliases
If your project uses absolute imports and path aliases — @/components/* mapped in tsconfig.json, say — Jest needs the identical mapping explicitly, or it simply won't resolve those imports inside test files at all:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/components/*": ["components/*"]
}
}
}
// jest.config.js
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
}
Next.js's own build pipeline and Jest's module resolution are genuinely separate systems — the paths config in tsconfig.json only affects the former; moduleNameMapper is what Jest specifically needs to make sense of the same aliases.
Extending Jest with more readable matchers
@testing-library/jest-dom adds custom matchers — .toBeInTheDocument() being the one you'll use constantly — that make assertions read more like plain English than raw DOM-property checks:
// jest.config.ts
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"];
// jest.setup.ts
import "@testing-library/jest-dom";
Worth knowing if you're following an older tutorial or Stack Overflow answer: extend-expect was removed as of @testing-library/jest-dom v6 — if you're on a version before 6, you'd import @testing-library/jest-dom/extend-expect instead of the plain package import shown above. If you find yourself importing the older path in a fresh project, it's a strong signal you're following outdated instructions for whichever version you actually have installed.
The test script and your first test
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}
}
jest --watch re-runs affected tests automatically as files change — genuinely worth reaching for during active development rather than manually re-running jest after every edit.
A conventional __tests__ folder at your project root is the standard place for tests, though colocating test files directly next to what they test works too if you prefer that structure:
// __tests__/page.test.jsx
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import Page from "../app/page";
describe("Page", () => {
it("renders a heading", () => {
render(<Page />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
});
});
Note getByRole('heading', { level: 1 }) here rather than something like a CSS selector or test ID — React Testing Library's whole philosophy is querying by what a real user (or a screen reader) would actually perceive, which is precisely why role-based queries are the recommended default over implementation-detail selectors that would break the moment you refactor markup without changing behavior.
Snapshot testing, briefly
For tracking unintended changes to a component's rendered output over time:
// __tests__/snapshot.js
import { render } from "@testing-library/react";
import Page from "../app/page";
it("renders homepage unchanged", () => {
const { container } = render(<Page />);
expect(container).toMatchSnapshot();
});
The first run generates a baseline snapshot file; every subsequent run diffs the current output against it, flagging anything that changed. Worth a word of caution beyond the setup mechanics: snapshot tests are genuinely easy to let rot into noise if a team develops a habit of blindly running jest --updateSnapshot whenever a snapshot test fails, without actually reading the diff first — that reflex defeats the entire point of the test, since it stops catching real regressions the moment "update the snapshot" becomes the automatic response to any failure rather than a deliberate, reviewed decision.
Running your tests
npm run test
Key Takeaways
| Task | Command / config |
|---|---|
| Quickstart | npx create-next-app@latest --example with-jest |
| Wire Jest to Next.js | Wrap config with nextJest({ dir: './' }) from next/jest.js |
| Path aliases | Mirror tsconfig.json paths in moduleNameMapper |
| Readable matchers | @testing-library/jest-dom, imported via setupFilesAfterEnv |
| Async Server Components | Not supported — use E2E testing (Playwright/Cypress) instead |
| Run tests | npm run test or npm run test:watch |
next/jest is genuinely doing the hard, tedious part of this setup for you — stylesheet mocking, font handling, SWC config, environment variables — which is exactly why "use Jest with Next.js" is a substantially smaller lift today than it would have been hand-configuring a testing framework never originally designed around Next.js's specific build pipeline. The one thing worth carrying forward from this article specifically: know where the async-Server-Component gap sits, and route that slice of your test coverage to E2E rather than treating it as a Jest bug to work around.


