Vitest with React Testing Library can't render an async Server Component, and the Next.js docs still recommend end-to-end tests for them. So the useful question is which parts of a Server Component tree belong in Vitest and which belong in Playwright. My answer is that anything needing a real request goes to Playwright. Everything else gets pulled out of the async component and tested quickly in Vitest.
What the Next.js docs actually say
The official Vitest guide (last updated August 2026) says it directly: because async Server Components are new to the React ecosystem, Vitest doesn't support them yet. You can unit-test synchronous Server and Client Components, but the docs recommend E2E tests for async components.
The setup the guide recommends is standard: vitest, @vitejs/plugin-react, jsdom, @testing-library/react, @testing-library/dom, and vite-tsconfig-paths if you use TypeScript, all in a vitest.config.mts:
1// vitest.config.mts
2import { defineConfig } from 'vitest/config'
3import react from '@vitejs/plugin-react'
4import tsconfigPaths from 'vite-tsconfig-paths'
5
6export default defineConfig({
7 plugins: [tsconfigPaths(), react()],
8 test: {
9 environment: 'jsdom',
10 },
11})The rendering limitation isn't really the main problem. An async Server Component in a real app does things that only make sense inside a Next.js request: it reads cookies() or headers(), hits the data cache, calls notFound() or redirect(), and streams through Suspense boundaries. Even if a test runner could render the component, you'd have to mock all of that, and the test would end up checking your mocks.
The render(await Page()) workaround and why I avoid it
An async function component is still a function that returns a promise of JSX, so some teams write this:
1render(await Page({ params: Promise.resolve({ id: '1' }) }))This works for leaf components that only fetch data and return markup. It stops working once the component calls request-scoped APIs like cookies(), renders an async child (the nested child goes back to React, which jsdom can't resolve as a Server Component), or depends on Suspense streaming order. You also aren't testing what React does with the component. You're testing what a plain function returns when it's called outside React.
My rule: if you need this trick to test a component, the component has too much logic in it. Move the logic out (next section) and leave the async component as a thin shell that Playwright covers.
Layer 1: pull logic out of async components and unit-test it
Treat the async Server Component as wiring. It fetches, calls plain functions to shape the data, and passes the result to a synchronous component. Every piece except the fetch can be tested in Vitest without any Next.js runtime.
1// lib/notes.ts
2export type Note = { id: string; title: string; archived: boolean; updatedAt: Date }
3
4export function visibleNotes(notes: Note[], now: Date): Note[] {
5 const cutoff = now.getTime() - 1000 * 60 * 60 * 24 * 90
6 return notes
7 .filter((n) => !n.archived && n.updatedAt.getTime() >= cutoff)
8 .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
9}
10
11// app/notes/note-list.tsx — synchronous, no 'use client' needed
12import type { Note } from '@/lib/notes'
13import { ArchiveButton } from './archive-button'
14import { archiveNote } from './actions'
15
16export function NoteList({ notes }: { notes: Note[] }) {
17 if (notes.length === 0) return <p>No recent notes.</p>
18 return (
19 <ul>
20 {notes.map((n) => (
21 <li key={n.id}>
22 {n.title}
23 <ArchiveButton id={n.id} action={archiveNote} />
24 </li>
25 ))}
26 </ul>
27 )
28}
29
30// app/notes/page.tsx — the async shell
31import { db } from '@/lib/db'
32import { visibleNotes } from '@/lib/notes'
33import { NoteList } from './note-list'
34
35export default async function NotesPage() {
36 const notes = await db.note.findMany()
37 return <NoteList notes={visibleNotes(notes, new Date())} />
38}visibleNotes is a pure function, so its test is fast and stable, and that's where edge cases like the 90-day cutoff, archived items and sort order belong. NoteList is a synchronous Server Component, which the Next.js docs explicitly say you can unit-test. Rendering it with RTL lets you check the empty state and list markup without a server.
1// lib/notes.test.ts
2import { describe, expect, it } from 'vitest'
3import { visibleNotes, type Note } from './notes'
4
5const now = new Date('2026-11-01T00:00:00Z')
6const note = (over: Partial<Note>): Note => ({
7 id: '1',
8 title: 'Note',
9 archived: false,
10 updatedAt: now,
11 ...over,
12})
13
14describe('visibleNotes', () => {
15 it('drops archived notes', () => {
16 expect(visibleNotes([note({ archived: true })], now)).toEqual([])
17 })
18
19 it('drops notes older than 90 days', () => {
20 const old = note({ updatedAt: new Date('2026-07-01T00:00:00Z') })
21 expect(visibleNotes([old], now)).toEqual([])
22 })
23
24 it('sorts newest first', () => {
25 const a = note({ id: 'a', updatedAt: new Date('2026-10-01T00:00:00Z') })
26 const b = note({ id: 'b', updatedAt: new Date('2026-10-20T00:00:00Z') })
27 expect(visibleNotes([a, b], now).map((n) => n.id)).toEqual(['b', 'a'])
28 })
29})This is the most valuable testing layer in an App Router codebase, and teams often skip it because the logic sits inline inside page.tsx. The cost is one refactor per page.
Layer 2: component-test client islands with RTL
Client Components are ordinary React components, and RTL handles them well. The one thing that changes is how they receive Server Actions. If the island takes the action as a prop rather than importing it, the test can pass in a vi.fn() and assert on the UI states: pending, error and success.
1// app/notes/archive-button.tsx
2'use client'
3import { useActionState } from 'react'
4
5export type ArchiveState = { error?: string; ok?: boolean }
6type Action = (prev: ArchiveState, formData: FormData) => Promise<ArchiveState>
7
8export function ArchiveButton({ id, action }: { id: string; action: Action }) {
9 const [state, formAction, pending] = useActionState(action, {})
10 return (
11 <form action={formAction}>
12 <input type="hidden" name="id" value={id} />
13 <button type="submit" disabled={pending}>
14 {pending ? 'Archiving…' : 'Archive'}
15 </button>
16 {state.error && <p role="alert">{state.error}</p>}
17 </form>
18 )
19}1// app/notes/archive-button.test.tsx
2import { expect, it, vi } from 'vitest'
3import { render, screen } from '@testing-library/react'
4import userEvent from '@testing-library/user-event'
5import { ArchiveButton, type ArchiveState } from './archive-button'
6
7it('shows the error returned by the action', async () => {
8 const action = vi.fn(
9 async (_prev: ArchiveState, _fd: FormData): Promise<ArchiveState> => ({
10 error: 'You cannot archive this note',
11 }),
12 )
13 render(<ArchiveButton id="42" action={action} />)
14
15 await userEvent.click(screen.getByRole('button', { name: 'Archive' }))
16
17 expect(await screen.findByRole('alert')).toHaveProperty(
18 'textContent',
19 'You cannot archive this note',
20 )
21 const formData = action.mock.calls[0][1]
22 expect(formData.get('id')).toBe('42')
23})The Next.js guide doesn't list @testing-library/user-event, so you'll need to install it separately. The user-event docs explain why it's preferable to fireEvent for clicks and typing.
Advertisement
This test proves the island displays whatever the action returns and sends the right form data. It doesn't prove the action works or that the list refreshes afterward. Those are separate claims, and they belong in separate layers.
Server Actions: unit-test the function, E2E-test the round trip
A Server Action is an async function exported from a 'use server' file. In Vitest, the directive is just a string literal at the top of the module, so you can import the action and call it like any other function after mocking its dependencies. For revalidatePath, the useful assertion is that it was called with the right path.
1// app/notes/actions.test.ts
2import { beforeEach, expect, it, vi } from 'vitest'
3
4vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
5vi.mock('@/lib/db', () => ({
6 db: { note: { update: vi.fn() } },
7}))
8
9import { revalidatePath } from 'next/cache'
10import { db } from '@/lib/db'
11import { archiveNote } from './actions'
12
13beforeEach(() => vi.clearAllMocks())
14
15it('archives the note and revalidates the list', async () => {
16 const fd = new FormData()
17 fd.set('id', '42')
18
19 const result = await archiveNote({}, fd)
20
21 expect(db.note.update).toHaveBeenCalledWith({
22 where: { id: '42' },
23 data: { archived: true },
24 })
25 expect(revalidatePath).toHaveBeenCalledWith('/notes')
26 expect(result).toEqual({ ok: true })
27})
28
29it('rejects a missing id without touching the database', async () => {
30 const result = await archiveNote({}, new FormData())
31 expect(result.error).toBeDefined()
32 expect(db.note.update).not.toHaveBeenCalled()
33})Write these when the action contains real branching, such as validation, permission checks or error mapping. If the action is three lines that forward to the database, skip the unit test. A mock-heavy test of a pass-through function only repeats the implementation, and the E2E test below already covers it more honestly.
What this layer can't check is serialization across the client–server boundary, the action ID wiring, whether revalidatePath actually re-renders the async page, and cookie or session behavior. Only a running Next.js server can verify those.
Layer 3: Playwright owns the async tree and the round trip
The Next.js docs point async components to E2E, and the Playwright guide covers setup. Point Playwright's webServer option at a production build. next dev compiles routes on demand and behaves differently from next start around caching, so assertions about revalidation are only meaningful against a build.
1// playwright.config.ts
2import { defineConfig, devices } from '@playwright/test'
3
4export default defineConfig({
5 testDir: './e2e',
6 use: { baseURL: 'http://localhost:3000' },
7 projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
8 webServer: {
9 command: 'npm run build && npm run start',
10 url: 'http://localhost:3000',
11 reuseExistingServer: !process.env.CI,
12 },
13})1// e2e/notes.spec.ts
2import { expect, test } from '@playwright/test'
3
4test('archiving a note removes it from the server-rendered list', async ({ page }) => {
5 await page.goto('/notes')
6
7 const row = page.getByRole('listitem').filter({ hasText: 'Quarterly plan' })
8 await expect(row).toBeVisible()
9
10 await row.getByRole('button', { name: 'Archive' }).click()
11
12 await expect(row).toHaveCount(0)
13})That single test covers the async page fetching data, the Server Component handing a Server Action to a client island, the action running on the server, and revalidatePath triggering a fresh server render. Playwright's web-first assertions like toHaveCount retry until the condition holds, so you don't need manual waits for the re-render.
Keep these tests few and focused on user journeys. One spec for each critical flow (load, mutate, see the result) beats twenty specs that each re-check a branch the unit tests already cover. Give E2E a dedicated, seeded database. Shared data between parallel workers is the most common source of flaky App Router E2E suites.
The new option: vitest-plugin-rsc
There is now a third approach. Storybook's vitest-plugin-rsc runs server code inside Vitest Browser Mode, in the same runtime as your assertions. For a Next.js app, it routes requests through Next's own route resolution, request handler, renderer and router. Because the server runs inside the test process, a database your test seeds is the same module instance your Server Components read. That's a real advantage over Playwright, where seeding happens out of process.
The requirements are strict. It needs the App Router, next@16.4 or later, and @next/routing at the same version as next. The plugin calls Next.js internals, which is why its CI tests against 16.4.0, latest and canary.
That reliance on internals is why I wouldn't build a test strategy around it yet. Any Next.js minor release could break your suite until the plugin catches up. If your team has a lot of conditional rendering logic inside async components that you can't easily extract, try it on one route and pin your next version. For most teams, extracting the logic and running a small Playwright suite is still the safer default.
Which layer gets which test
- Pure logic pulled out of pages and actions (filtering, formatting, permission rules, validation schemas): Vitest unit tests. Most of your test count should be here.
- Synchronous Server Components and Client Components: Vitest plus RTL. Pass Server Actions in as props so you can replace them with vi.fn().
- Server Actions with real branching: call them directly in Vitest with next/cache and the data layer mocked. Skip pass-through actions.
- Async Server Components, request APIs (cookies, headers), redirect/notFound, revalidation, streaming: Playwright against a production build.
- Heavy RSC logic you can't extract, on next@16.4+, with tolerance for churn: evaluate vitest-plugin-rsc on a single route first.
The line between Vitest and Playwright is the request. If the code under test needs a live Next.js request to mean anything, testing it in jsdom produces a test that passes for the wrong reasons. Put it in Playwright, and keep pages thin enough that only a small number of E2E specs are needed.






