I am a...
Learn more
How it worksPricingFAQ
Account
May 17, 2026 · 10 min read · Cadence Editorial

How to write Vitest tests for Next.js apps

vitest tests nextjs — How to write Vitest tests for Next.js apps
Photo by [Bibek ghosh](https://www.pexels.com/@bibekghosh) on [Pexels](https://www.pexels.com/photo/code-on-computer-screen-14553730/)

slug: vitest-tests-nextjs title: How to write Vitest tests for Next.js apps metaDescription: Set up Vitest for Next.js in 5 minutes, test the 5 layers that matter, skip async Server Components, and ship a stable suite without slowing CI. excerpt: A senior-engineer playbook for testing Next.js 16 with Vitest: minimal config, the five layers worth covering, the mocks that matter, and what to push to Playwright.

How to write Vitest tests for Next.js apps

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.

The minimum viable Vitest setup for Next.js 16

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.

The five layers worth testing in a Next.js app

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.

1. Client components

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.

2. Hooks

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.

3. Route handlers

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).

4. Server Actions

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.

5. Integration boundaries

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.

What to skip (and why)

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.

The mocks that actually matter

Mocking is where most Next.js test suites die. The right defaults:

next/navigation

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.

fetch (use MSW, not vi.fn())

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())

Database (Drizzle, Prisma, Supabase)

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.

next/image

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} />,
}))

Testing a route handler end-to-end

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+.

Vitest vs Jest for Next.js: when each wins

The honest comparison. Vitest does not win every fight, even in 2026.

ToolBest forStartup speedAsync RSC supportSetup time
VitestNew Next.js 14/15/16 apps, ESM-native projects~200-400msNo~5 min
JestLegacy Next.js + CRA, suites with custom transformers~2-8sNo~15 min
PlaywrightAsync Server Components, full user flows, real auth~5-30s per testYes (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.

Common pitfalls (and the symptom you'll see)

Five failure modes we see repeatedly when teams roll out Vitest:

  • Forgetting setupFiles. Symptom: TypeError: expect(...).toBeInTheDocument is not a function. Fix: add setupFiles: ['./vitest.setup.ts'] and import @testing-library/jest-dom/vitest there.
  • Mocking 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.
  • Using getByText for repeated content. Symptom: "Found multiple elements with the text". Fix: prefer getByRole with the accessible name, or getAllByText.
  • No cleanup between tests. Symptom: flaky tests that pass alone and fail in suite, usually around state. Fix: afterEach(() => cleanup()) in your setup file.
  • Trying to render an async Server Component. Symptom: test hangs until timeout, no useful stack. Fix: do not. Push to Playwright.

When you can skip Vitest entirely

Best practices have ROI curves, and tests are no exception. Skip the suite if:

  • You are pre-revenue, two-week prototype, one founder. Ship the demo. Add tests when you have paying customers.
  • You are building an internal tool used by 3 people. TypeScript and ESLint cover 80% of the value.
  • Your entire app is async Server Components talking to one API. A single Playwright spec on the critical path is enough.

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.

What to do next

Three concrete moves, in order:

  1. Drop the vitest.config.mts and vitest.setup.ts above into your repo. Run npm test. Confirm it boots in under a second.
  2. Pick one client component, one hook, and one route handler. Write three tests, one per layer. This shakes out 90% of the mocking issues you will hit.
  3. Set up Playwright in parallel for the async Server Components and the auth-protected flows. Use Vitest for fast iteration, Playwright for the user-journey safety net.

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.

FAQ

Can Vitest test async Server Components in Next.js?

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.

Should I use jsdom or happy-dom with Vitest?

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.

How do I test a Next.js route handler with Vitest?

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.

Do I need to mock 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.

How long does it take to migrate from Jest to Vitest in a Next.js app?

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.

All posts