Next.js on the App Router is a heterogeneous environment: part of the code runs on the server, part in the browser, and a single test setup does not fit all of it. The official guide draws the line directly: Vitest unit tests suit Client Components and ordinary functions, while async Server Components are not reproduced by the unit runner as things stand - they are checked end-to-end. Understanding this boundary matters more than any config: it decides what is even worth testing at this level.
Practice begins with a separate config. The file is named vitest.config.mts - the mts extension is taken because a Next.js project's package.json usually has no type:module, and the test config needs ESM. Into the plugins go @vitejs/plugin-react for JSX and vite-tsconfig-paths, so that path aliases from tsconfig - the very @/components - work in tests too without manual duplication.
The test section repeats what we already know: environment 'jsdom' for components, setupFiles importing jest-dom, globals to taste. There is nothing Next-specific here - it is a plain Vite config that simply lives next to Next.js. That is exactly why Vitest fits Next so naturally: both stand on Vite-compatible transformation, and a second build track is unnecessary.
A Client Component is tested exactly like any React component from the previous chapter. The 'use client' directive is, to jsdom, just a string at the top of the file; the component itself is rendered via RTL, interacted with via userEvent, checked by role and accessible name. Everything said about forms and the query priority applies here unchanged.
Server logic - data-loading functions, transformations, route handlers - is extracted into ordinary modules and tested as code in a Node environment, a separate test project with environment 'node'. This returns us to the separation rule: heavy domain work is kept in pure functions not soldered into the React tree, and then it is checked by a unit test with no rendering at all.
An async Server Component - one that is itself async and fetches data - stays outside the unit. The runner does not raise the App Router's server lifecycle: the request context, streaming, cache, Suspense boundaries. The official stance is to check such components through Playwright in a real run; trying to render them in jsdom yields either a false green or mysterious failures out of nowhere.
Hence an honest split by level. Client components and pure server functions - unit on Vitest, fast and close to the code. End-to-end scenarios involving server rendering, routing and data - E2E. There is no need to drag into the unit what it cannot do, nor to push into an expensive E2E what a unit catches perfectly; each level takes its own part.
The typical failure is trying to render an async Server Component in jsdom and, hitting a vague error, smearing the test with mocks until it turns green. Such a test checks nothing real and breaks at any change. The second failure is a single config for the whole project: server functions run in jsdom needlessly, and client ones do not see the DOM. Split the projects by environment - and each kind of code rides in its own.
npm i -D @vitejs/plugin-react
// vitest.config.mts
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',
setupFiles: ['./test/setup.ts'], // import '@testing-library/jest-dom/vitest'
},
})test('the filter updates the product list', async () => {
const user = userEvent.setup()
render(<ProductFilter items={items} />) // a 'use client' component
await user.click(screen.getByRole('button', { name: /in stock/i }))
expect(screen.getAllByRole('listitem')).toHaveLength(2)
})