
Testing Next.js Applications with Vitest
Vitest has become the default test runner for a lot of new JavaScript projects. It's fast, it understands TypeScript and ESM natively, its API is close enough to Jest's that the learning curve is short, and its watch mode reruns only the tests affected by a change. Next.js doesn't use Vite to build your app, but that doesn't matter for unit tests: Vitest uses Vite only to transform test files and their imports, and the official Next.js docs include a Vitest setup guide.
This post goes beyond the basic setup. I'll configure Vitest for an App Router project, then test the main kinds of code you have in a Next.js app: Client Components, custom hooks, Route Handlers, and Server Actions. Along the way you'll see how to mock Next.js modules (next/navigation, next/cache, next/headers, server-only), run different files in different environments, use fake timers, and collect coverage.
Installing Vitest
Install Vitest, the React plugin, jsdom, Testing Library, and the tsconfig paths plugin:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event vite-tsconfig-paths
@vitejs/plugin-reactcompiles JSX in your components and tests.jsdomprovides a simulated DOM for component tests.vite-tsconfig-pathsmakes Vitest understand the@/alias from yourtsconfig.json, so you don't have to duplicate it.@testing-library/jest-domadds matchers liketoBeInTheDocument. Despite the name, it works with Vitest.
Configuring Vitest
Create vitest.config.mts in the project root. The .mts extension makes the file an ES module regardless of your package.json settings.
// 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: ["./vitest.setup.ts"],
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "e2e"],
clearMocks: true,
},
});
What these options do:
environment: "jsdom"is the default environment. You'll override it for server-side tests below.setupFilesruns before every test file.includeandexcludekeep Vitest away from build output and from Playwright specs ine2e/, which use a different runner.clearMocks: trueclears the recorded calls of every mock before each test, so an assertion likenot.toHaveBeenCalled()isn't affected by an earlier test. Mock implementations are kept, and you set return values inbeforeEachwhere needed.
Now the setup file:
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
The /vitest entry point of jest-dom registers its matchers with Vitest's expect and adds the TypeScript types. The explicit cleanup call unmounts rendered components after each test. Testing Library does this automatically only when test globals like afterEach are available, and this config doesn't enable globals, so it's added by hand.
Add scripts to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
vitest starts in watch mode during development. vitest run runs once and exits, which is what you want in CI.
Globals or Imports?
Jest makes describe, it, and expect global. Vitest doesn't by default; you import them from vitest. I prefer explicit imports since they're clear and need no type configuration. If you're migrating a large Jest suite and don't want to touch every file, set globals: true in the config and add "vitest/globals" to compilerOptions.types in tsconfig.json.
Testing a Client Component
The component testing API is the same Testing Library you might know from Jest. Here's a quick example to confirm the setup works:
// components/like-button.tsx
"use client";
import { useState } from "react";
export function LikeButton({ initialLikes = 0 }: { initialLikes?: number }) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
function toggle() {
setLiked((l) => !l);
setLikes((n) => (liked ? n - 1 : n + 1));
}
return (
<button aria-pressed={liked} onClick={toggle}>
{liked ? "Unlike" : "Like"} ({likes})
</button>
);
}
// components/like-button.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LikeButton } from "./like-button";
describe("LikeButton", () => {
it("toggles the like state and count", async () => {
const user = userEvent.setup();
render(<LikeButton initialLikes={4} />);
const button = screen.getByRole("button", { name: "Like (4)" });
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
expect(button).toHaveTextContent("Unlike (5)");
await user.click(button);
expect(button).toHaveTextContent("Like (4)");
});
});
Run npm test, and Vitest picks up the file and keeps watching it.
Mocking with vi
Vitest's mocking API lives on the vi object and mirrors Jest's: vi.fn() for mock functions, vi.spyOn() for spies, vi.mock() for modules, and vi.mocked() for typing. Like jest.mock, vi.mock calls are hoisted to the top of the file, before imports.
Mocking next/navigation
Here's a component that navigates after a selection:
// components/locale-select.tsx
"use client";
import { usePathname, useRouter } from "next/navigation";
const LOCALES = ["en", "de", "fr"] as const;
export function LocaleSelect({ current }: { current: string }) {
const router = useRouter();
const pathname = usePathname();
return (
<select
aria-label="Language"
value={current}
onChange={(e) => {
const rest = pathname.split("/").slice(2).join("/");
router.push(`/${e.target.value}/${rest}`);
}}
>
{LOCALES.map((l) => (
<option key={l} value={l}>
{l.toUpperCase()}
</option>
))}
</select>
);
}
// components/locale-select.test.tsx
import { beforeEach, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
import { LocaleSelect } from "./locale-select";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
usePathname: vi.fn(),
}));
const push = vi.fn();
beforeEach(() => {
vi.mocked(useRouter).mockReturnValue({
push,
} as unknown as ReturnType<typeof useRouter>);
vi.mocked(usePathname).mockReturnValue("/en/blog/hello-world");
});
it("switches locale and keeps the rest of the path", async () => {
const user = userEvent.setup();
render(<LocaleSelect current="en" />);
await user.selectOptions(screen.getByLabelText("Language"), "de");
expect(push).toHaveBeenCalledWith("/de/blog/hello-world");
});
The factory function passed to vi.mock returns the module's replacement. vi.mocked() adds mock types to the imported functions so mockReturnValue type-checks.
If you only want to replace one export and keep the rest real, use importOriginal:
vi.mock("next/navigation", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/navigation")>();
return { ...actual, useRouter: vi.fn() };
});
Fake Timers for Debounced Inputs
Components with debouncing, polling, or timeouts are slow and flaky to test with real time. Vitest's fake timers let you control the clock:
// components/debounced-search.tsx
"use client";
import { useEffect, useState } from "react";
export function DebouncedSearch({
onSearch,
delay = 300,
}: {
onSearch: (q: string) => void;
delay?: number;
}) {
const [value, setValue] = useState("");
useEffect(() => {
const id = setTimeout(() => onSearch(value), delay);
return () => clearTimeout(id);
}, [value, delay, onSearch]);
return (
<input
aria-label="Search"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
// components/debounced-search.test.tsx
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DebouncedSearch } from "./debounced-search";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onSearch once, after the user stops typing", async () => {
const onSearch = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<DebouncedSearch onSearch={onSearch} />);
// Initial empty search fires after the delay
act(() => {
vi.advanceTimersByTime(300);
});
onSearch.mockClear();
await user.type(screen.getByLabelText("Search"), "next");
expect(onSearch).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(300);
});
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toHaveBeenCalledWith("next");
});
The important detail is userEvent.setup({ advanceTimers: vi.advanceTimersByTime }). user-event uses small internal delays between keystrokes; with fake timers enabled, it would wait forever unless you tell it how to advance the clock. Timer advances that trigger React state updates are wrapped in act so React flushes them before the assertions.
Testing Custom Hooks
renderHook renders a hook inside a throwaway test component:
// hooks/use-toggle.ts
"use client";
import { useCallback, useState } from "react";
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return { on, toggle, setOn };
}
// hooks/use-toggle.test.ts
import { expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useToggle } from "./use-toggle";
it("toggles the value", () => {
const { result } = renderHook(() => useToggle());
expect(result.current.on).toBe(false);
act(() => result.current.toggle());
expect(result.current.on).toBe(true);
});
Always read from result.current after an update. Destructuring it once at the top captures a stale value.
Testing Route Handlers
Route Handlers are functions that take a Request and return a Response. That makes them easy to test directly, without starting a server. They need the Node.js environment rather than jsdom, and you can switch environments per file with a comment on the first line.
// app/api/posts/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { getPosts } from "@/lib/posts";
export async function GET(request: NextRequest) {
const limit = Number(request.nextUrl.searchParams.get("limit") ?? 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return NextResponse.json({ error: "limit must be 1-50" }, { status: 400 });
}
const posts = await getPosts({ limit });
return NextResponse.json({ posts });
}
// @vitest-environment node
// app/api/posts/route.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
import { getPosts } from "@/lib/posts";
vi.mock("@/lib/posts", () => ({
getPosts: vi.fn(),
}));
describe("GET /api/posts", () => {
beforeEach(() => {
vi.mocked(getPosts).mockResolvedValue([{ id: "1", title: "Hello" }]);
});
it("returns posts with the requested limit", async () => {
const res = await GET(
new NextRequest("http://localhost/api/posts?limit=5"),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ posts: [{ id: "1", title: "Hello" }] });
expect(getPosts).toHaveBeenCalledWith({ limit: 5 });
});
it("rejects an invalid limit", async () => {
const res = await GET(
new NextRequest("http://localhost/api/posts?limit=500"),
);
expect(res.status).toBe(400);
expect(getPosts).not.toHaveBeenCalled();
});
});
The // @vitest-environment node comment must be the first line of the file. Constructing a NextRequest gives you nextUrl and cookies just like in production. Mocking the data layer keeps the test focused on the handler's own logic: input parsing, status codes, and response shape. For more on writing handlers, see Route Handlers in Next.js.
Testing Server Actions
A Server Action is an async function, so you can call it directly in a test. The complication is that actions often use Next.js APIs that only work inside a request: revalidatePath, cookies(), and redirect(). Mock those modules.
// app/posts/actions.ts
"use server";
import "server-only";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const cookieStore = await cookies();
const userId = cookieStore.get("user_id")?.value;
if (!userId) return { error: "Not signed in" };
const title = String(formData.get("title") ?? "").trim();
if (title.length < 3) return { error: "Title is too short" };
const post = await db.post.create({ data: { title, authorId: userId } });
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}
// @vitest-environment node
// app/posts/actions.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { createPost } from "./actions";
vi.mock("server-only", () => ({}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/headers", () => ({ cookies: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: { post: { create: vi.fn() } },
}));
function mockCookies(values: Record<string, string>) {
vi.mocked(cookies).mockResolvedValue({
get: (name: string) =>
name in values ? { name, value: values[name] } : undefined,
} as unknown as Awaited<ReturnType<typeof cookies>>);
}
function form(data: Record<string, string>) {
const fd = new FormData();
for (const [k, v] of Object.entries(data)) fd.set(k, v);
return fd;
}
describe("createPost", () => {
beforeEach(() => {
vi.mocked(db.post.create).mockResolvedValue({ id: "42" } as never);
});
it("rejects anonymous users", async () => {
mockCookies({});
const result = await createPost(form({ title: "Hello world" }));
expect(result).toEqual({ error: "Not signed in" });
expect(db.post.create).not.toHaveBeenCalled();
});
it("validates the title", async () => {
mockCookies({ user_id: "u1" });
const result = await createPost(form({ title: "Hi" }));
expect(result).toEqual({ error: "Title is too short" });
});
it("creates the post, revalidates, and redirects", async () => {
mockCookies({ user_id: "u1" });
await createPost(form({ title: "Hello world" }));
expect(db.post.create).toHaveBeenCalledWith({
data: { title: "Hello world", authorId: "u1" },
});
expect(revalidatePath).toHaveBeenCalledWith("/posts");
expect(redirect).toHaveBeenCalledWith("/posts/42");
});
});
Points worth noting:
server-onlymust be mocked. That package deliberately throws when it's imported outside a React Server environment, and Vitest isn't one. An empty mock lets the import through.cookies()is async in Next.js 16, so the mock usesmockResolvedValue.redirect()normally throws a special error that Next.js catches to perform the redirect. Mocking it withvi.fn()turns it into a recordable call. If your code relies onredirectstopping execution, make the mock throw instead (vi.fn(() => { throw new Error("NEXT_REDIRECT"); })) and assert withawait expect(...).rejects.toThrow("NEXT_REDIRECT").dbis your own database client module, mocked so no real database is needed.
These tests verify the action's logic (auth check, validation, side effects). Whether the form in the browser actually calls the action is a job for an end-to-end test.
Splitting Environments with Projects
Per-file environment comments work, but if you have many server-side tests, Vitest's projects option lets you define groups with their own settings:
// 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: {
setupFiles: ["./vitest.setup.ts"],
clearMocks: true,
projects: [
{
extends: true,
test: {
name: "client",
environment: "jsdom",
include: ["**/*.test.tsx", "hooks/**/*.test.ts"],
exclude: ["node_modules", ".next", "e2e"],
},
},
{
extends: true,
test: {
name: "server",
environment: "node",
include: [
"app/**/route.test.ts",
"app/**/actions.test.ts",
"lib/**/*.test.ts",
],
},
},
],
},
});
extends: true makes each project inherit the root config (plugins and shared test options). Run a single group with vitest --project server. The jest-dom import in the shared setup file is harmless in the Node environment, but if you'd rather keep it out, give each project its own setupFiles.
What About Server Components?
The Next.js docs note that Vitest doesn't currently support async Server Components. Synchronous Server Components render fine with Testing Library, as long as their imports are mockable. For async ones, the most effective strategy is to keep the component thin: move logic into plain functions you can test in the Node environment, and cover the rendered page with Playwright, as described in End-to-End Testing a Next.js App with Playwright.
Coverage
Install the V8 coverage provider:
npm install -D @vitest/coverage-v8
Then configure what to measure:
// vitest.config.mts (inside test)
coverage: {
provider: "v8",
include: ["app/**", "components/**", "hooks/**", "lib/**"],
exclude: ["**/*.test.*", "**/*.d.ts"],
reporter: ["text", "html"],
},
npm run test:coverage prints a summary table and writes an HTML report to coverage/. Use it to find untested branches, not as a number to maximize.
Vitest vs. Jest for Next.js
Both are solid. A quick comparison:
| Vitest | Jest with next/jest | |
|---|---|---|
| TypeScript and ESM | Native through Vite | Through the Next.js SWC transform |
| Next.js preset | None needed beyond plugins | next/jest loads config and env, mocks CSS and fonts |
| Watch mode | Module-graph aware, very fast | Fast, file-based |
| API | Jest-compatible (vi instead of jest) | The original |
| ESM-only dependencies | Usually work without config | Often need transformIgnorePatterns |
| CSS and image imports | Handled by Vite | Auto-mocked by next/jest |
If you're starting fresh, Vitest is a comfortable default. If your team already has a big Jest suite, there's no urgent reason to switch. The Jest and React Testing Library guide covers that setup.
Conclusion
Vitest gives a Next.js project a fast, modern unit test runner with very little setup: a config file with the React and tsconfig paths plugins, a setup file for jest-dom and cleanup, and a couple of scripts. From there you can test Client Components and hooks in jsdom, call Route Handlers with a real NextRequest in the Node environment, and exercise Server Actions by mocking next/cache, next/headers, next/navigation, and server-only.
Keep business logic in plain functions where it's easy to test, use fake timers for anything time-based, and leave full-page behavior to end-to-end tests. That combination gives you quick feedback on every save and confidence that the pieces fit together.


