Type something to search...
Testing Next.js Applications with Vitest

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-react compiles JSX in your components and tests.
  • jsdom provides a simulated DOM for component tests.
  • vite-tsconfig-paths makes Vitest understand the @/ alias from your tsconfig.json, so you don't have to duplicate it.
  • @testing-library/jest-dom adds matchers like toBeInTheDocument. 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.
  • setupFiles runs before every test file.
  • include and exclude keep Vitest away from build output and from Playwright specs in e2e/, which use a different runner.
  • clearMocks: true clears the recorded calls of every mock before each test, so an assertion like not.toHaveBeenCalled() isn't affected by an earlier test. Mock implementations are kept, and you set return values in beforeEach where 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-only must 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 uses mockResolvedValue.
  • redirect() normally throws a special error that Next.js catches to perform the redirect. Mocking it with vi.fn() turns it into a recordable call. If your code relies on redirect stopping execution, make the mock throw instead (vi.fn(() => { throw new Error("NEXT_REDIRECT"); })) and assert with await expect(...).rejects.toThrow("NEXT_REDIRECT").
  • db is 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:

VitestJest with next/jest
TypeScript and ESMNative through ViteThrough the Next.js SWC transform
Next.js presetNone needed beyond pluginsnext/jest loads config and env, mocks CSS and fonts
Watch modeModule-graph aware, very fastFast, file-based
APIJest-compatible (vi instead of jest)The original
ESM-only dependenciesUsually work without configOften need transformIgnorePatterns
CSS and image importsHandled by ViteAuto-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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading