
To write Vitest tests for a Next.js app, install vitest, @vitejs/plugin-react, jsdom, and @testing-library/react, add a vitest.config.mts with the React plugin and a jsdom environment, then cover the five layers that actually matter: client components, hooks, route handlers, Server Actions, and integration boundaries. Skip async Server Components (Vitest does not support them) and push those flows to Playwright.
That answer fits in a paragraph. The interesting question is the one nobody writes about: what do you actually test in a real Next.js app, what do you skip, and how do you keep the suite under 30 seconds as it grows? This post is the playbook.
Install the test dependencies:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event vite-tsconfig-paths
Create vitest.config.mts at the repo root:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
})
Create vitest.setup.ts:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => cleanup())
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
redirect: vi.fn(),
notFound: vi.fn(),
}))
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
That is the whole rig. About 40 lines of config. The thing you get back: Vitest boots in roughly 200-400ms instead of Jest's 2-8 seconds, because it uses Vite's transform pipeline and skips Jest's ts-jest / babel-jest setup tax. Across thousands of test runs in CI, that delta is real money.
If you hit ESM/CJS errors on a dependency (a common one is anything that ships only require()-style modules), switch environment to 'happy-dom'. happy-dom runs roughly 2-3x faster than jsdom and ships a smaller install, but covers fewer browser APIs. Default to jsdom; switch when you have a reason.
Most blog posts on this topic stop at "render a page component, assert on a heading." That is the demo. Real Next.js apps have five layers worth covering, and each one needs a different test shape.
Anything with 'use client' at the top, state, or event handlers. Render with @testing-library/react, drive with userEvent, assert on the visible output. This is the highest-volume tier in most apps because it catches the regressions users actually see.
Custom hooks are pure logic. Test them with renderHook from @testing-library/react. These run in single-digit milliseconds and tend to have the highest ROI per line of test code.
App Router handlers in app/api/*/route.ts are just functions that take a Request and return a Response. You don't need to spin up a Next.js server. Import the handler, call it, assert on the response (see the full example below).
Server Actions are async functions exported with 'use server'. In Vitest you call them directly, mock the database access at the module boundary, and assert on the returned shape. Treat them like any other backend function.
Stripe, Supabase, Resend, your DB layer. Mock at the module boundary, not the internals. We cover the mock patterns in two sections.
Vitest handles all five layers in a single runner with a single config, which is the underrated win over Jest + Playwright + Storybook spread across three configs.
This is the part the other 9 results miss. Testing the wrong things is how you get a 10-minute CI run and engineers who don't trust the suite.
Async Server Components. The Next.js docs (as of the 2026-05-13 release) state explicitly: "Since async Server Components are new to the React ecosystem, Vitest currently does not support them." If your component is async function Page() {} and fetches data, do not try to render it in Vitest. It will hang or throw confusing module-resolution errors. Push it to Playwright.
Middleware. Edge runtime is hard to fake. Test the logic inside middleware (rate limiters, header parsing) as plain functions; cover the actual middleware execution path in Playwright with real requests.
Layout files. app/layout.tsx usually wraps providers and renders children. A snapshot test of it gives almost no signal. Skip it.
Full auth flows. If a flow touches NextAuth callbacks, cookies, database sessions, and a redirect, the seam count is high enough that mocking it in Vitest produces a test that proves nothing. Playwright with a real browser is the right tool. We make the same call in our guide on how to set up E2E testing for a SaaS.
The rule we use: if mocking takes longer than the assertion is worth, push the test up a layer.
Mocking is where most Next.js test suites die. The right defaults:
Already in your vitest.setup.ts above. Every test gets working mocks for useRouter, usePathname, useSearchParams, redirect, and notFound. Without these, any client component that uses navigation throws on render.
For HTTP calls, install MSW (Mock Service Worker) and intercept at the network layer. This means your tests exercise the same code path as production: request goes out, response comes back, parsing logic runs. Compare that with vi.fn().mockResolvedValue({ json: () => data }), which silently skips your entire fetch wrapper and any error handling.
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/posts', () => HttpResponse.json([{ id: 1, title: 'Hello' }]))
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Mock the data-access module, not the ORM client. If you have lib/db/posts.ts with getPostBySlug(slug), mock that module. Do not try to mock the entire Drizzle query builder; that path produces tests that break every time you refactor the query.
vi.mock('@/lib/db/posts', () => ({
getPostBySlug: vi.fn(async (slug: string) => ({ id: 1, slug, title: 'Test' })),
}))
This pattern also keeps a clean line for your data layer, which connects to our broader take on how to manage technical debt in a startup: the mock boundary is the seam where future refactors land cleanly.
A two-line mock that just renders an img. Without it, Next's image optimization will throw in jsdom.
vi.mock('next/image', () => ({
default: (props: any) => <img {...props} alt={props.alt} />,
}))
This is the test shape most articles skip. Here is app/api/posts/route.ts:
import { NextResponse } from 'next/server'
import { getAllPosts } from '@/lib/db/posts'
export async function GET() {
const posts = await getAllPosts()
return NextResponse.json(posts)
}
And app/api/posts/route.test.ts:
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/lib/db/posts', () => ({
getAllPosts: vi.fn(async () => [{ id: 1, title: 'Hello' }]),
}))
import { GET } from './route'
describe('GET /api/posts', () => {
it('returns posts as JSON', async () => {
const res = await GET()
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual([{ id: 1, title: 'Hello' }])
})
})
That is the full pattern. No Next.js server boots, no port binds, the test runs in single-digit milliseconds. You can extend it to POST handlers with new Request('http://localhost/api/posts', { method: 'POST', body: JSON.stringify(...) }) and pass that to the handler.
If you want this pattern wired into your CI from day one, running integration tests in CI covers the GitHub Actions setup, service containers for Postgres, and the matrix shapes that scale to teams of 10+.
The honest comparison. Vitest does not win every fight, even in 2026.
| Tool | Best for | Startup speed | Async RSC support | Setup time |
|---|---|---|---|---|
| Vitest | New Next.js 14/15/16 apps, ESM-native projects | ~200-400ms | No | ~5 min |
| Jest | Legacy Next.js + CRA, suites with custom transformers | ~2-8s | No | ~15 min |
| Playwright | Async Server Components, full user flows, real auth | ~5-30s per test | Yes (real browser) | ~20 min |
Vitest wins on speed (10-20x faster startup, per the community benchmarks), native ESM and TypeScript without a transformer chain, and a Jest-compatible API so the migration takes about 30 minutes for most apps.
Jest still wins when you have 200+ existing tests with custom Jest transformers, when you depend on a Jest-only plugin (some snapshot serializers, some custom resolvers), or when you are on Create React App which bundles Jest by default. The migration math gets worse the more Jest-specific code you have.
Playwright is the only one of the three that can render an async Server Component, because it uses a real browser against a real Next.js server. For anything async-RSC-heavy, Playwright is not optional.
Five failure modes we see repeatedly when teams roll out Vitest:
setupFiles. Symptom: TypeError: expect(...).toBeInTheDocument is not a function. Fix: add setupFiles: ['./vitest.setup.ts'] and import @testing-library/jest-dom/vitest there.next/navigation only in one test file. Symptom: that test passes alone, the suite fails. Fix: move the mock to vitest.setup.ts so every test gets it.getByText for repeated content. Symptom: "Found multiple elements with the text". Fix: prefer getByRole with the accessible name, or getAllByText.afterEach(() => cleanup()) in your setup file.Best practices have ROI curves, and tests are no exception. Skip the suite if:
If you are past those gates and the suite is going to outlive the original author, write the tests. Past about 5 engineers on the codebase, an untested Next.js app becomes the source of every Friday incident.
If you are at that inflection point and don't have anyone on the team who has set this up before, this is exactly the kind of two-week scope a senior engineer on Cadence (every engineer is AI-native, vetted on Cursor / Claude Code / Copilot fluency before they unlock bookings) tends to land in one week, including a wired-up CI pipeline and the team handoff doc.
Three concrete moves, in order:
vitest.config.mts and vitest.setup.ts above into your repo. Run npm test. Confirm it boots in under a second.If you would rather not do the rollout yourself, the senior tier on Cadence ($1,500/week) typically owns this kind of test-infrastructure work end to end: setup, CI integration, and a writeup of which paths to test in your specific app. 48-hour free trial, weekly billing, cancel any week.
The technical-debt pattern here is real. Skipping tests on a Next.js app that will live more than 6 months is a loan you pay back in incident response. Writing them with Vitest in 2026 is cheap enough that the math almost always favors writing them.
No. The Next.js documentation states explicitly that Vitest does not currently support async Server Components. Render them via Playwright against a running dev or production server instead. Synchronous Server and Client components work fine.
Default to jsdom. It covers more browser APIs and is the documented Next.js choice. Switch to happy-dom only if you hit ESM/CJS errors on a dependency or need the ~2-3x runtime speedup on a very large suite. happy-dom also ships a smaller install.
Import the GET or POST function from your app/api/.../route.ts file, call it with a new Request(...) object (or no args for a GET with no params), and assert on the returned Response. No Next.js server is required, which is why these tests run in single-digit milliseconds.
next/navigation?Yes, globally in vitest.setup.ts. useRouter, usePathname, useSearchParams, redirect, and notFound throw or return undefined outside the App Router context, so a default mock keeps every client component test honest. Override per test only when you need to assert on router.push calls.
About 30 minutes for most apps. The test API (describe, it, expect, vi.fn) is intentionally Jest-compatible. The longer cases: custom Jest transformers, Jest-only plugins (some snapshot serializers), or a 500+ test suite with heavy module mocking. Budget half a day for those and a couple of hours for everything else.