Skip to content

About

Render React Server Components in Vitest Browser Mode.

Topics

Resources

Stars

63 stars

Watchers

2 watching

Forks

Latest commit

 

History

220 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vitest-plugin-rsc

Test React Server Components and Next.js App Router apps in Vitest Browser Mode.

npm version CI License Node pnpm

vitest-plugin-rsc runs your server code in Vitest Browser Mode, in the same browser test runtime as your assertions. For a Next.js app, that's the whole app: Next's own request handler renders a route, the browser shows the HTML, and Next's own client code hydrates it.

That unlocks a kind of test unit tests and E2E tests can't easily reach:

DB → RSC → pixels → actions → DB → pixels. One slice at a time.

Pick one piece of the app — a wishlist carousel, a notes form, a settings panel, or a whole route. Seed exactly the state that piece needs, render it, interact with the hydrated UI in a real browser, run Server Actions, and assert what the user sees and what the server stored.

Table Of Contents

Why This Exists

Covering every state with only E2E is usually impractical. E2E runs are slow because each test has to drive the UI into the state you want to assert against, hard to parallelize because tests share infrastructure, and flaky because they aren't isolated. Validation errors, user roles, locales, feature flags, loading/empty/error states, time-dependent UI — most of those variants just get skipped. That coverage belongs at the base of the test pyramid.

Test pyramid: a small Playwright E2E layer above a wider Vitest unit, component, and integration layer

For React Server Components, that base has been missing. Rendering Server Components inside a unit-style test process has been an open problem since 2023, so the whole RSC pipeline got pushed up to E2E — exactly where broad variant coverage doesn't fit.

vitest-plugin-rsc fills the missing base. A single component, form, or whole route runs through the full RSC pipeline — server render, Flight, HTML, hydration, Server Action, rerender — with white-box control over the inputs and assertions on the rendered DOM.

Your assertions stay user-facing and your setup stays direct:

test("archive a note", async () => {
  // seed DB
  await signedInAs(testUser);
  await db.insert(notes).values({ ownerId: testUser.id, title: "Inbox triage" });

  // RSC -> pixels
  await renderServer(<NotesPage />, { url: "/notes" });
  await expect.element(page.getByText("Inbox triage")).toBeVisible();

  // action -> DB -> pixels
  await page.getByRole("button", { name: "Archive Inbox triage" }).click();
  await expect.element(page.getByText("Inbox triage")).not.toBeInTheDocument();
});

Agents do dramatically better when wrapped in a self-healing loop with fast unit tests — edit, run tests, repair, repeat — and RSC has been the hardest React surface to put in that loop.

What You Get

  • Real Next.js behavior: The request goes through Next's own route resolution, request handler, renderer, and router. Layouts, loading.tsx, error boundaries, redirects, cookies, Server Actions, route handlers, and the Data Cache do what they do in your app, and so do proxy.ts and the redirects, rewrites, and headers in next.config.
  • Next's own compiler: Your source files go through Next's SWC transform and its font and image loaders, so next/font, next/image, next/dynamic, and styled-jsx work, and a mistake that would stop next build fails the test with Next's error.
  • Focused scope: Test a whole route, a single component, a form, or a flow without booting the whole deployed app.
  • White-box inputs: The server runs in the test runtime, so the db your test seeds is the module instance your Server Components read. Set auth/session state, mock IO, fake clocks, and set cookies/headers.
  • Black-box output: Assert what the user sees and does via vitest/browser — Playwright locators (getByRole, getByText, etc.) and expect.element matchers.
  • Watch mode: With vitestPluginNext({ affectedTests: true }), an edit reruns just the tests that use that file. See Watch Mode.
  • No deployed infra: Use in-memory infrastructure like PGlite instead of spinning up a preview server and database.
  • Per-test isolation: Each test starts with an empty Data Cache, none of your app's cookies, and an empty localStorage and sessionStorage of its own.

Requirements

  • Vite 8 or later, and Vitest 5.0.3 or later in Browser Mode. The examples use Playwright as the browser provider. A host other than Vitest, like a framework of a component workshop, runs the plugin in a Vite server of its own: see Another Host Than Vitest.
  • For Next.js: the App Router, next@16.4 or later, and @next/routing, Next's own route resolution, at the same version as next.

The plugin calls Next.js internals, so CI tests against next@16.4.0, next@latest, and next@canary.

Next.js

Set Up

npm install -D vitest-plugin-rsc vitest @vitest/browser-playwright playwright
# Next's own route resolution, at the same version as your `next`.
npm install -D @next/routing@$(node -p "require('next/package.json').version")

@next/routing has to be the same version as your next, so upgrade them together, like eslint-config-next.

// vitest.config.ts
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
import { vitestPluginRSC } from "vitest-plugin-rsc";
import { vitestPluginNext } from "vitest-plugin-rsc/nextjs/plugin";

export default defineConfig({
  plugins: [vitestPluginRSC(), vitestPluginNext()],
  test: {
    include: ["**/*.test.{ts,tsx}"],
    browser: {
      enabled: true,
      headless: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
    },
    isolate: false,
  },
});

vitestPluginNext() reads your app from the project root. You don't need a setup file: the plugin cleans up before and after every test.

With isolate: false, the app's server loads once per worker instead of once per test file. It also means mocks are shared, which is why they belong in a setup file — see Mocks.

Render A Component

Pass a node to test one component instead of a whole page. It renders the way Testing Library renders a component: in a <div> container in document.body, without your app's layouts. Everything around it is still Next: the request, the cookies, Server Actions, the cache, and the router.

Without a url, the node renders at /. With a url, the pathname, search params, and route params come from it. The URL goes straight to your route, past proxy.ts, so a component behind a sign-in renders without one.

A component that calls location.reload(), or navigates to its own URL, like a <form method="get"> without an action, loads the test's tab again, as it does without the plugin: the node is the test's, not a page of your app.

wrapper wraps the node on the server, for the providers a layout would give it. It can be a Server Component:

import { headers } from "next/headers";
import type { ReactNode } from "react";
import { Counter } from "./components/counter.tsx";

async function Tenant({ children }: { children: ReactNode }) {
  const tenant = (await headers()).get("x-tenant");
  return <section aria-label={`Tenant ${tenant}`}>{children}</section>;
}

test("wraps a node in a wrapper, which can be a Server Component", async () => {
  await renderServer(<Counter />, { wrapper: Tenant, headers: { "x-tenant": "acme" } });

  const tenant = page.getByRole("region", { name: "Tenant acme" });
  await tenant.getByRole("button", { name: "Count: 0" }).click();
  await expect.element(tenant.getByRole("button", { name: "Count: 1" })).toBeVisible();
});

layouts: true renders the node inside your app's layouts for that url, with everything they give it: providers, global CSS, and the data a layout reads. Without it, a node has the CSS of what its test file and the setup files import: import the global CSS of your root layout in a setup file to have it there.

import { RouterState } from "./components/router-state.tsx";

test("renders a node in place of a page, inside the layouts of the app", async () => {
  await renderServer(<RouterState />, { url: "/notes/7?q=1", layouts: true });

  // The root layout of the app, around the node.
  await expect.element(page.getByRole("navigation", { name: "Main" })).toBeVisible();
  const router = page.getByRole("main").getByRole("definition");
  await expect.element(router.nth(1)).toHaveTextContent('{"id":"7"}');
});

Two options decide how much of your app is around what you render: proxy runs proxy.ts, and layouts renders your layouts. proxy covers the redirects, rewrites, and headers of next.config too.

proxy layouts
renderServer({ url }) true true
renderServer(<Node />, { url }) false false

Set them per test, like a component in the layouts of its route, behind the proxy that guards it:

import { headers } from "next/headers";

// Reads the header that proxy.ts adds for a signed-in visitor.
async function Team() {
  return <p>Team: {(await headers()).get("x-team")}</p>;
}

test("renders a node in the layouts of a route, behind the proxy", async () => {
  await renderServer(<Team />, {
    url: "/team",
    layouts: true,
    proxy: true,
    headers: { cookie: "session=ada" },
  });

  await expect.element(page.getByRole("main")).toHaveTextContent("Team: core");
});

A Test File With "use client"

There are two kinds of components to test, and a test file for each:

  • A connected component gets something from the app: data from a Server Component, a Server Action, a cookie, the router. Test it with renderServer(), in a test file as above.
  • A Client Component of a design system, like a button or a dialog, gets only its props and has only its state. Test it in a test file with "use client", and render it as you would without Next: with render() of vitest-browser-react or of Testing Library.

A test file with "use client" is a client boundary, as such a file is in React. It is code of Next's browser layer, and runs on the React that Next has for the browser. A prop can be a function, like a spy, and a component of the test file can have state:

"use client";

import { expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { PressButton } from "./components/press-button.tsx";

test("passes a function to a Client Component, which calls it", async () => {
  const onPress = vi.fn();

  const screen = await render(<PressButton onPress={onPress}>Press</PressButton>);

  await screen.getByRole("button", { name: "Press" }).click();
  expect(onPress).toHaveBeenCalledOnce();
});

Install what you render with as a dev dependency: vitest-browser-react, or @testing-library/react and @testing-library/dom. The plugin has no API for it, and cleans up nothing it rendered: as in Vitest without Next, a library unmounts what it rendered with its cleanup(). Set the browser layer up in a setup file with "use client", which runs there before every test file, as a setup file does in Vitest:

// vitest.setup.client.tsx
"use client";

import { beforeEach } from "vitest";
import { cleanup } from "vitest-browser-react";

beforeEach(cleanup);

Call cleanup() in a hook of the setup file itself, as above. A module loads once for the tab, as Vitest loads a module that a setup file imports once per worker with isolate: false, so a hook that a library registers as it loads, like the one of import "vitest-browser-react" in its docs, is only the first test file's. Testing Library's configure() goes in such a setup file too, and so does what the browser needs set up before a test, like a file that calls i18n.init(): it loads once for the tab. A setup file without the directive is server code, in the rsc layer, whose React is the server's: vitest-browser-react or @testing-library/react there breaks every test file, and a file with "use client" that it imports is a reference, as in React, which loads when something renders it.

There is no page of your app around the component: no request, no URL and no router of Next. What that means for such a file:

  • renderServer() rejects: client code cannot render server code.
  • A Server Action that a component calls rejects: it needs Next's app. Test the component with renderServer() in a test file without the directive.
  • The hooks of next/navigation are Next's, outside its app: useRouter() throws invariant expected app router to be mounted, and usePathname(), useSearchParams() and useParams() return null. Test a component that needs the router with renderServer(), or pass it what it reads as a prop.
  • vi.mock(), vi.unmock(), vi.doMock(), vi.doUnmock() and vi.hoisted() are an error in it: its vi is the page's, whose mocker replaces the modules of the server's layer, not the browser's. Pass a mock as a prop instead. See Mocks.
  • What it imports is the browser's copy of a module. A db it imports is not the one your Server Components read.
  • A link or a form cannot navigate: there is no router. A click on Next's <Link> or an <a>, or a form that submits, is an uncaught error that fails the run, and the test stays where it is. Test navigation with renderServer().
  • The CSS that the file imports applies, and that of a setup file with the directive, until the next test file starts. The CSS of the setup file is in the document for a page of renderServer() too.
  • localStorage and sessionStorage are reset around every test, as for renderServer(). What a setup file writes there as it loads is gone before the first test: write it in a beforeEach().
  • What a setup file with the directive set up for the browser layer is there, as above. A page of renderServer() starts without it, as a page of Next does: with what your app loads.
  • With build: "production" React has no act(). Testing Library's render() needs it, and render() of vitest-browser-react resolves before React has rendered: find what it rendered with a locator, which waits. See Development Or Production.

See A Test File With "use client" for how this works.

Example: Server Action Form

A full page.tsx route here; the same pattern works for any component, form, or flow.

The page is a Server Component with a Server Action. On a validation error, the action writes the message to a cookie and calls refresh().

// app/notes/new/page.tsx
import { refresh } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

import { notes } from "#db/schema.ts";
import { requireUser } from "#lib/auth-session.ts";
import { db } from "#lib/db.ts";

export default async function NewNotePage() {
  const user = await requireUser();
  const error = (await cookies()).get("note-error")?.value;

  return (
    <form
      action={async (formData) => {
        "use server";

        const title = String(formData.get("title") ?? "").trim();
        const content = String(formData.get("content") ?? "");

        if (!title) {
          (await cookies()).set("note-error", "Title is required.");
          refresh();
          return;
        }

        await db.insert(notes).values({ ownerId: user.id, title, content });
        redirect("/notes");
      }}
    >
      <label htmlFor="title">Title</label>
      <input id="title" name="title" />
      {error && <p>{error}</p>}

      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" />

      <button>Create note</button>
    </form>
  );
}

The test seeds both server-side state (auth, database) and browser-side state (localStorage), renders the page, interacts with the form, and asserts the rerendered UI:

import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";

import { db } from "#lib/db.ts";
import { notes } from "#db/schema.ts";
import { signedInAs, testUser } from "#test/auth.ts";
import NewNotePage from "./page.tsx";

test("validates a new note without losing entered content", async () => {
  await signedInAs(testUser);
  localStorage.setItem("theme", "dark");

  await db.insert(notes).values({
    ownerId: testUser.id,
    title: "Inbox triage",
    content: "Existing note body",
  });

  await renderServer(<NewNotePage />, { url: "/notes/new" });

  await page.getByLabelText("Content").fill("Keep this body");
  await page.getByRole("button", { name: "Create note" }).click();

  await expect.element(page.getByText("Title is required.")).toBeInTheDocument();
  await expect.element(page.getByDisplayValue("Keep this body")).toBeInTheDocument();
});

That single test sets up:

  • Server-side state: the signed-in user (signedInAs) and a seeded database row (db.insert)
  • Browser-side state: a client-side preference written to localStorage

Setting server and browser state in the same setup is something a pure unit test cannot reach and a full E2E test can only do through the real UI.

vi.mock("#lib/db.ts") in the setup file replaces the production database adapter with the Vitest __mocks__ version next to it (lib/__mocks__/db.ts). The mock exports db, on a PGlite database in memory, and resetDb(), which empties every table: the setup file calls it before each test. See Drizzle + PGlite below for the wiring.

Router Hooks And Links

Many tests can omit routing options:

await renderServer(<CreateNoteForm />);

Pass url when the component needs location-aware behavior — usePathname, useSearchParams, useParams, next/link, or request URL-dependent code. The params come from your app's route for that URL:

import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";

import { NoteToolbar } from "./note-toolbar";

test("reads router state and navigates", async () => {
  await renderServer(<NoteToolbar />, { url: "/notes/123?tab=activity" });

  await expect.element(page.getByText("pathname: /notes/123")).toBeVisible();
  await expect.element(page.getByText("note id: 123")).toBeVisible();
  await expect.element(page.getByText("tab: activity")).toBeVisible();

  await page.getByRole("button", { name: "Go to notes" }).click();
  await expect.poll(() => window.location.pathname).toBe("/notes");
});

Navigating to another route of your app opens that page, with its layouts, like a click in the browser does.

For example, a client component can use normal Next APIs:

"use client";

import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";

export function NoteToolbar() {
  const router = useRouter();
  const pathname = usePathname();
  const params = useParams<{ id: string }>();
  const searchParams = useSearchParams();

  return (
    <>
      <p>pathname: {pathname}</p>
      <p>note id: {params.id}</p>
      <p>tab: {searchParams.get("tab")}</p>
      <button onClick={() => router.push("/notes")}>Go to notes</button>
      <Link href={{ pathname: "/notes/new", query: { from: params.id } }}>New note</Link>
    </>
  );
}

Request Headers And Cookies

Headers and cookies are the request's, in AsyncLocalStorage, as in Next. Pass request headers into renderServer. Inside Server Components, Server Actions, route handlers and proxy.ts, use Next's headers() and cookies() APIs as you normally would: Next makes its request store of every request the plugin sends it, as it does on a server.

The headers go with every request the browser sends to your app from then on, not only the first one: Server Actions, router.refresh(), navigations, and fetch calls get them too, until the test opens something else or ends. That is what you want for a header that a server in front of your app adds to every request, like x-forwarded-for. A request keeps the headers it sets itself. An accept header is for the first request alone.

The plugin keeps your app's cookies for the test, the way a browser keeps a site's:

  • A cookie header in the headers of renderServer sets cookies, as a Set-Cookie without attributes would. Its cookies are added to the ones your app has, and stay until your app changes them or the test ends. They aren't HttpOnly, so document.cookie shows them. Set an HttpOnly one with (await cookies()).set(name, value, { httpOnly: true }) in runInServerAction.
  • Every request to your app carries them: page loads, navigations, router.refresh(), Server Actions, route handlers, and fetch calls from Client Components.
  • A Set-Cookie from your server sets one, and one that has expired, with Max-Age=0 or an Expires in the past, deletes it. A cookie is a name and a value: its Path, Domain and Secure don't count, since every request goes to your app, as if it were served over https:.
  • A cookie header of handleRequest or runInServerAction is for that one request: it is sent instead of your app's cookies, and with the redirects handleRequest follows, with the cookies those set. The cookies the server sets are your app's from then on.
  • A request with credentials: "omit", of fetch or handleRequest, doesn't send your app's cookies, and the ones its response sets aren't kept.
  • Each test starts without cookies.

document.cookie is a view of your app's cookies, as in a browser. Your Client Components read the ones that aren't HttpOnly there, and a cookie they write there reaches your server, like the state that shadcn/ui's sidebar keeps. A script can't write an HttpOnly cookie or change one, and a fetch from client code doesn't see a Set-Cookie header, as in a browser. The page has no cookieStore, as in a browser without the Cookie Store API. MSW writes the Set-Cookie of a response it mocks into document.cookie, so a mocked API's cookie that isn't HttpOnly becomes your app's, also when your server sent the request.

The browser's own cookies for the origin are not your app's: the ones Vitest's UI, another host or another app on localhost set. Your app doesn't get them, and the plugin leaves them as they are.

import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";

import { FlashProbe } from "./flash-probe";

test("reads request headers and mutates cookies from an action", async () => {
  const requestHeaders = new Headers();
  requestHeaders.set("x-test-request", "from-test");
  requestHeaders.set("cookie", "flash=initial");

  await renderServer(<FlashProbe />, {
    url: "/flash",
    headers: requestHeaders,
  });

  await expect.element(page.getByText("request id: from-test")).toBeVisible();
  await expect.element(page.getByText("flash: initial")).toBeVisible();

  await page.getByRole("button", { name: "Save flash" }).click();
  await expect.element(page.getByText("flash: saved")).toBeVisible();
});
import { refresh } from "next/cache";
import { cookies, headers } from "next/headers";

export async function FlashProbe() {
  const requestId = (await headers()).get("x-test-request");
  const flash = (await cookies()).get("flash")?.value ?? "empty";

  return (
    <form
      action={async () => {
        "use server";

        (await cookies()).set("flash", "saved", { path: "/" });
        refresh();
      }}
    >
      <p>request id: {requestId}</p>
      <p>flash: {flash}</p>
      <button>Save flash</button>
    </form>
  );
}

To read a cookie in a test, assert on what a page renders, read the Set-Cookie headers of a response, read document.cookie for one that isn't HttpOnly, or call cookies() in a Server Action:

import { cookies } from "next/headers";
import { expect, test } from "vitest";
import { handleRequest, runInServerAction } from "vitest-plugin-rsc/nextjs/testing-library";

test("keeps the cookie a route handler sets", async () => {
  const response = await handleRequest("/api/notes/1");
  expect(response.headers.getSetCookie()).toEqual(["last-read=1; Path=/"]);

  const lastRead = await runInServerAction(async () => (await cookies()).get("last-read")?.value);
  expect(lastRead).toBe("1");
});

To change or delete one cookie in the middle of a test, do what a Server Action would:

await runInServerAction(async () => (await cookies()).delete("session"));

localStorage and sessionStorage are your app's own too, as its cookies are: each is a Storage of the plugin's, not the browser's storage of the origin. Each test starts with both empty, but for what the test or a beforeEach() writes there, and what your app stores stays in the test's document: no other document of the origin sees it, and your app sees nothing that another one stores. Your app hears the browser's storage events, which are about the host's storage, never its own. Each is one object for as long as the document lives, so a library that took localStorage as it loaded has the items of the test that runs. The browser's DevTools and Playwright's storageState() show the browser's storage, not your app's, and a spy on Storage.prototype sees none of your app's calls: spy on localStorage instead. See Web Storage for what else differs.

Cache And Revalidation

Server Components can use tagged cached fetch calls, and Server Actions can refresh the current tree or invalidate those tags. The outbound fetch is intercepted by MSW in tests — see playground/nextjs-notes-demo for a worked setup.

import { refresh, revalidatePath, revalidateTag, updateTag } from "next/cache";

import { createNote } from "#lib/notes";

async function readNotes() {
  const response = await fetch("https://example.test/api/notes", {
    cache: "force-cache",
    next: { tags: ["notes"] },
  });
  return response.json() as Promise<Array<{ id: string; title: string }>>;
}

export async function NotesPanel() {
  const notes = await readNotes();

  return (
    <section>
      <p>notes: {notes.length}</p>
      <form
        action={async () => {
          "use server";

          await createNote({ title: "New note" });
          updateTag("notes");
        }}
      >
        <button>Create note</button>
      </form>
      <form
        action={async () => {
          "use server";

          revalidateTag("notes", "max");
          refresh();
        }}
      >
        <button>Refresh stale notes</button>
      </form>
      <form
        action={async () => {
          "use server";

          revalidateTag("notes", { expire: 0 });
        }}
      >
        <button>Expire notes cache</button>
      </form>
      <form
        action={async () => {
          "use server";

          revalidatePath("/notes", "page");
        }}
      >
        <button>Revalidate notes page</button>
      </form>
    </section>
  );
}

The test still looks like a unit test. After the click, updateTag("notes") invalidates the cached fetch, the panel re-renders, and the assertion sees the new count:

import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";

import { NotesPanel } from "./notes-panel";

test("creating a note invalidates the notes cache", async () => {
  await renderServer(<NotesPanel />, { url: "/notes" });

  await expect.element(page.getByText("notes: 0")).toBeVisible();
  await page.getByRole("button", { name: "Create note" }).click();
  await expect.element(page.getByText("notes: 1")).toBeVisible();
});

Open A Whole Route

renderServer({ url }) opens a route the way a browser does. The request goes to Next's request handler, the browser shows the HTML it sends back, and Next's client code hydrates it. It resolves once the page has hydrated. From there, Next's router is in charge, so links, forms, and redirects behave as they do in your app.

import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";
import { db } from "./lib/notes.ts";

test("navigates on the client with next/link", async () => {
  db.notes.set("1", { id: "1", title: "Inbox triage", body: "Sort the inbox" });
  await renderServer({ url: "/" });

  await page.getByRole("link", { name: "Notes", exact: true }).click();
  await expect.element(page.getByRole("heading", { name: "Notes" })).toBeVisible();
  expect(window.location.pathname).toBe("/notes");

  await page.getByRole("link", { name: "Inbox triage" }).click();
  await expect.element(page.getByText("Sort the inbox")).toBeVisible();
  expect(window.location.pathname).toBe("/notes/1");
  await expect.poll(() => document.title).toBe("Inbox triage | Notes");
});

renderServer resolves with { response, unmount }: the server's Response to the document request, and a function that leaves the page.

const { response } = await renderServer({ url: "/" });

expect(response.status).toBe(200);

The headers you pass are sent with the request, and with every request of the page after it. A cookie header sets cookies for your app, see Request Headers And Cookies.

test("sends the cookies of a cookie header with the page", async () => {
  await renderServer({ url: "/notes", headers: { cookie: "last-created=7" } });

  await expect.element(page.getByText("Last created: 7")).toBeVisible();
});

A URL that isn't a route gets your app's not-found page, with status 404.

Route Handlers

handleRequest(url, init) sends one request to the app and resolves with the response. It takes what fetch takes. Use it when the response is what you assert on: a status, a header, the HTML, or the Flight payload. The request carries your app's cookies, and the cookies the server sets are your app's from then on. A cookie header is sent instead of your app's cookies, with this request and the redirects it follows. It carries the headers of what renderServer opened too, under its own.

Route handlers (app/**/route.ts) are served too, and handleRequest is how a test calls one:

import { expect, test } from "vitest";
import { handleRequest } from "vitest-plugin-rsc/nextjs/testing-library";
import { db } from "../lib/notes.ts";

test("gives a route handler the body of a request and its cookie header", async () => {
  db.notes.set("1", { id: "1", title: "Inbox triage", body: "Sort the inbox" });

  const response = await handleRequest("/api/notes/1", {
    method: "PUT",
    headers: { "content-type": "application/json", cookie: "editor=kasper" },
    body: JSON.stringify({ title: "Inbox zero" }),
  });

  expect(await response.json()).toEqual({
    note: { id: "1", title: "Inbox zero", body: "Sort the inbox" },
    editor: "kasper",
  });
  expect(db.notes.get("1")?.title).toBe("Inbox zero");
  // Set with cookies() in the handler.
  expect(response.headers.getSetCookie()).toEqual(["last-renamed=1; Path=/"]);
});

A fetch from a Client Component to a route handler reaches it too, with your app's cookies. As in a browser, it doesn't see the Set-Cookie headers of the response, which handleRequest does.

Code That A Server Action Calls

A form's Server Action is best tested through its page: fill in the form and submit it. For the code that a Server Action calls, runInServerAction(fn, { url }) runs a function as a Server Action of the page at url, in the request of the action. cookies() can be set there, and redirect(), refresh() and after() work. It resolves with what the function returns and rejects with what it throws, also the error of a redirect() or a notFound():

import { isRedirectError } from "next/dist/client/components/redirect-error";
import { cookies } from "next/headers";
import { expect, test } from "vitest";
import { runInServerAction } from "vitest-plugin-rsc/nextjs/testing-library";
import { createNote, setLanguage } from "../lib/actions.ts";
import { db } from "../lib/notes.ts";

test("keeps the cookies an action sets", async () => {
  await runInServerAction(() => setLanguage("nl"));

  const language = await runInServerAction(async () => (await cookies()).get("language")?.value);
  expect(language).toBe("nl");
});

test("creates the note and redirects", async () => {
  const formData = new FormData();
  formData.set("title", "Plan the week");

  const error = await runInServerAction(() => createNote(formData)).catch((error) => error);

  expect(isRedirectError(error)).toBe(true);
  expect(db.notes.get("1")?.title).toBe("Plan the week");
});

It sends a Server Action request like the one of Next's router, to a route at url that renders nothing, and opens no page: no app starts, so it is much faster than a page with a form. After a redirect() the call rejects with its error, and no page renders: open the page the action redirects to in your test, with renderServer({ url }) or handleRequest(). A page that is open stays open. The function and its result are the test's own, so they do not go through Flight.

The request carries your app's cookies, the headers of what renderServer() opened and the ones you pass, and the cookies the action sets are your app's from then on. A cookie header among them is sent instead of your app's cookies, with this request, not with the requests after it. With proxy: true it goes through the proxy, and a redirect there rejects, since the action did not run.

The function runs in the request of the action, so it cannot send a request to the app itself: that would wait for this one. Call the app's code instead. What it throws, Next also reports with console.error, as it does for any Server Action.

The Proxy, Redirects And Rewrites

proxy.ts and the redirects, rewrites, and headers in next.config run here too, through Next's own route resolution.

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  // Sends a visitor without a session elsewhere.
  if (pathname === "/team" && !request.cookies.has("session")) {
    const account = new URL("/account", request.url);
    account.searchParams.set("from", pathname);
    return NextResponse.redirect(account);
  }
  // Serves another route at this URL.
  if (pathname.startsWith("/go/")) {
    return NextResponse.rewrite(new URL(`/docs/${pathname.slice("/go/".length)}`, request.url));
  }
}
import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { handleRequest, renderServer } from "vitest-plugin-rsc/nextjs/testing-library";

test("follows a redirect of the proxy", async () => {
  const response = await handleRequest("/team", { redirect: "manual" });

  expect(response.status).toBe(307);
  expect(response.headers.get("location")).toBe("/account?from=%2Fteam");

  await renderServer({ url: "/team" });

  await expect.element(page.getByRole("heading", { name: "Account" })).toBeVisible();
  expect(window.location.pathname + window.location.search).toBe("/account?from=%2Fteam");
});

test("serves the route the proxy rewrites to, at the URL that was asked for", async () => {
  await renderServer({ url: "/go/routing" });

  await expect.element(page.getByRole("heading", { name: "Docs: routing" })).toBeVisible();
  expect(window.location.pathname).toBe("/go/routing");
});

proxy: false opens the route at exactly the URL you give, without the proxy or the redirects, rewrites, and headers of next.config. A protected page opens without signing in first:

test("opens a page without the proxy", async () => {
  await renderServer({ url: "/team", proxy: false });

  await expect.element(page.getByRole("heading", { name: "Team" })).toBeVisible();
});

The page's Server Actions and router.refresh() skip it too. A navigation to another route goes through it, as in your app. A node skips it by default, see Render A Component.

The proxy runs in the test runtime, in the same modules as the test. So a module it imports is the instance the test imports, and vi.mock() replaces it for both: mock the session it reads, or assert on what it wrote.

See The Server In Front Of The App for how this works.

Mocks

vi.mock() replaces the module that your Server Components, Server Actions, and route handlers import. It does not reach a Client Component in the browser, and a test file with "use client" cannot call it. Put mocks of app modules in a setup file, so every test file gets them.

A bare vi.mock() is enough. Each test says what the mock does, through vi.mocked():

// vitest.setup.ts
import { vi } from "vitest";

vi.mock("./app/lib/weather.ts");

Until vitest-dev/vitest#11520 is released, also add await import("./app/lib/weather.ts"); after the vi.mock() call.

// vitest.config.ts
test: {
  setupFiles: ["./vitest.setup.ts"],
}
import { expect, test, vi } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/nextjs/testing-library";
import { getForecast } from "../lib/weather.ts";

test("renders a route with a server module mocked in the setup file", async () => {
  vi.mocked(getForecast).mockResolvedValue("sunny");

  await renderServer({ url: "/forecast" });

  await expect.element(page.getByText("Today: sunny")).toBeVisible();
  expect(getForecast).toHaveBeenCalledOnce();
});

Fonts, Images And Styles

Your app's source files are compiled by Next's own compiler, in each of Next's three layers.

// app/fonts/fonts.ts
import { Inter } from "next/font/google";
import localFont from "next/font/local";

export const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const geist = localFont({ src: "./geist-latin.woff2", variable: "--font-geist" });
import { geist } from "./fonts.ts";

test("sets text in a font of next/font/local, a file of the app", async () => {
  await renderServer({ url: "/fonts" });

  const text = page.getByText("Set in Geist", { exact: true });
  await expect.element(text).toHaveClass(geist.className);
  await expect.element(text).toHaveStyle({ fontFamily: geist.style.fontFamily });
  // Served where Next's build puts it.
  const [face] = await document.fonts.load(`16px ${geist.style.fontFamily}`);
  expect(face?.status).toBe("loaded");
});
  • next/font/local and next/font/google, with the class names and font files Next makes.
  • next/image, including imported images and Next's image optimizer.
  • next/dynamic, next/script, styled-jsx, global CSS, and CSS modules.
  • next build's checks: a client hook in a Server Component, or server-only code in a Client Component, fails the test with Next's error.
  • paths from your tsconfig.json, like @/components/button.

Server Code In The Browser

The server runs in the browser, but your server code is told it's on a server, the way Next's build tells it: typeof window is "undefined", and fetch is the one Next patches. Test files keep the browser's window and fetch.

If a module needs to know it's in a browser, list it in browserModules:

// test/browser.ts
export function scrollToTop(): void {
  if (typeof window !== "undefined") window.scrollTo(0, 0);
}
vitestPluginNext({ browserModules: ["test/**"] });

A Client Component that renders something different on the server than in the browser fails the way it does in production, with a hydration mismatch. React reports that with console.error, and the playgrounds' tests fail on any console.error:

let consoleError: MockInstance<typeof console.error>;

beforeEach(() => {
  consoleError = vi.spyOn(console, "error");
});

afterEach(() => {
  expect(consoleError.mock.calls).toEqual([]);
});

Example: Drizzle + PGlite

PGlite runs Postgres in-process, so it works inside the browser test runtime. This is how playground/nextjs-notes-demo does it: one PGlite database for each browser page that runs tests (with isolate: false, the test files of a worker share one), which every test empties first.

Two files sit next to each other:

  • lib/db.ts — the database adapter your app code imports.
  • lib/__mocks__/db.ts — the test stand-in that vi.mock("#lib/db.ts") swaps in. It makes the PGlite database as the module loads, with the tables of the Drizzle schema, and exports db, Drizzle on that database, and resetDb(), which empties every table. Drizzle Kit, which turns the schema into SQL, runs in Node, so the Vitest config prepares the tables and hands them to the mock.

resetDb() empties the tables with DELETE, under SET session_replication_role = replica, so that the foreign keys do not fix the order of the tables. The setup file mocks the module and empties the database before each test:

// vitest.setup.ts
import { beforeEach, vi } from "vitest";
import * as dbModule from "#lib/db.ts";

vi.mock("#lib/db.ts");

const { resetDb } = dbModule as typeof import("#lib/__mocks__/db.ts");

beforeEach(async () => {
  await resetDb();
});

App code keeps importing db from #lib/db.ts. Tests then seed rows with the same db.insert(...) calls the app uses, like the test under Why This Exists.

A Static Build

vite build builds the app into a static site: the three layers, with the server in the browser of whoever opens the page, and no server behind it. That is what a host that publishes the app's pages needs, so the directory can go to a static host. The plugin builds with Vite's app builder, as vite build does: await (await createBuilder(config, null)).buildApp(). Vite's build() builds one environment, so the plugin stops it with an error that says so, and it does not build in --watch mode.

Everything that runs with the dev server runs from the build: pages and nodes, Client Components and their references, Server Actions, route handlers, the proxy, cookies, fonts, images and stylesheets. What differs:

  • Next's runtime and React are their production builds, as after next build. A page of the e2e demo's build loads in about 60% of the time of the development build, with about 60% of the bytes, as pnpm perf:static measures it. A page has no React warnings, no owner stacks, no second render of <StrictMode> and none of Next's development checks. React's error messages are a number and a link, and the browser gets the error of a Server Component as a digest, without its message. build: "development" builds the development code, for a build to debug in: see the table in Development Or Production. Next's dev overlay is in neither: the plugin does not render it. The app's own process.env.NODE_ENV is the build too, not "test".
  • images.unoptimized is on, unless the app has an image loader of its own: there is no image optimizer to ask.
  • The code of the ssr and the browser layer is one file each, vitest-plugin-rsc/*/modules.json, which a tab fetches once: the browser layer's first, which the HTML of a page of the host starts with a preload, and the ssr layer's after it, or at once when a request needs it. The preload asks in the browser's default cache mode, so the no-cache below is what has the browser ask whether its copy is current. Its name does not change with its content, nor does vitest-plugin-rsc/next-files.json: the browser asks the server whether its copy is current, so serve them like index.html, and compressed, as the two layers are 1.5 MB of JavaScript in production, and 3 MB in development: see the headers below.
  • The CSS of a route is linked by Next, as with a dev server: a file per stylesheet under /_next/static/css/, with the stylesheets of every route and of every file of the host in vitest-plugin-rsc/next-files.json, and the modules of every route, which a page load starts at once with its stylesheets. A node has the CSS of the files of the host that the host names as rendering it, hostFiles of renderServer(), as with a dev server. In a build the files of the host are what host.files matches: there is no Vitest config to say so.
  • The files of the build name each other by relative paths. To serve the build from a path, build it with that base, or with base: "./".
  • The plugin puts no path of the machine into the build. The files of a route are named from the root of the project, app/layout.tsx, and so are the paths of the Next config in the layers, like configFile, and the files that a compile error quotes. The same tree builds into the same files, byte for byte, in another directory as on another machine, so their content hashes hold. What the app puts in itself stays, like a path in env of next.config. A source map, with build.sourcemap, names its sources by the way from the map, as Vite names them.
  • experimental.inlineCss is not honoured in a build: the stylesheets are linked.
  • A file of the host with "use client", like a file that the host renders, runs in the browser layer as with a dev server, with its stylesheets from next-files.json, and once for the document: a build has one module of the file. The packages of the host, host.packages, are the page's own in the build too: the browser layer imports them from the host's build, so the host and its files with the directive share one instance, like the spies of a testing package of the host.
  • A host with pages of its own, like index.html and callout.html in build.rolldownOptions.input, gets them built as its pages: the layers build their own entries.

A static host serves the build with these headers, by the path of a file in the directory of the build:

  • cache-control: no-cache and an ETag: index.html and the other pages of the host, vitest-plugin-rsc/*/modules.json, vitest-plugin-rsc/next-files.json, and any other file without a hash of its content in its name, like a file of public/. A name that stays from build to build is asked for again, and a 304 answers when the copy of the browser is current.
  • cache-control: public, max-age=31536000, immutable: assets/, _next/static/ and vitest-plugin-rsc/*/assets/. A hash of the content is in their names, so the browser keeps them without asking.
  • Compressed, with brotli for a browser that takes it.

pnpm perf:static serves its builds this way.

vitestPluginRSC() on its own builds the same way, without Next.js: its two layers, from a page of the project that calls renderServer() of vitest-plugin-rsc/testing-library. Server Components, Client Components, Server Actions and the CSS of both run from the build, with React's production code. See A Static Build and playground/rsc-vitest-demo.

playground/nextjs-e2e-demo builds its app with a page of its own, index.html, that calls renderServer() as a test does, and one that renders a Client Component with loadClientModule(), client.html, and a test project opens the build in a browser from a server of files: see its build-test/. playground/nextjs-host-demo builds its two pages the same way, with its files of the host that have "use client" and a package of the host, and opens them in its own build-test/. pnpm perf:static measures what one page load of its build costs per route, in bytes, requests, round trips and time, for both builds, on localhost and over an emulated network: see docs/performance.md.

API

import {
  cleanup,
  handleRequest,
  renderServer,
  runInServerAction,
} from "vitest-plugin-rsc/nextjs/testing-library";
import { vitestPluginNext } from "vitest-plugin-rsc/nextjs/plugin";
Function What it does
renderServer({ url, headers, proxy, signal }) Opens a route. Resolves with { response, errors, unmount } once the page has hydrated.
renderServer(<Node />, options) Renders a node in a container, on a route of its own. See the options below.
rerender(ui) On the result of renderServer(<Node />): renders ui in its place without a page load, keeping the state of its Client Components. Every call is a request of its own. Resolves once React has committed it and run its effects.
handleRequest(input, init) Sends one request to the app, like fetch. Resolves with the Response.
runInServerAction(fn, options) Runs fn as a Server Action at url, without opening a page. Options: url, proxy, headers.
cleanup({ url }) Leaves the page, clears your app's cookies, storage and the cache. The plugin runs it around every test. url, for another host, is the URL the document has after it.
loadClientModule(module) For a host other than Vitest: the exports of a file of the host with "use client" from the browser layer, for the module the host imported, with a dev server and in a static build. The file runs once for each version of it, again after a change of it or of what it imports, and a file that imports it gets the same evaluation; other modules come back as they are. See Another Host Than Vitest.
vitestPluginNext({ browserModules }) The Vite plugin. browserModules are glob patterns, relative to the project root.
vitestPluginNext({ affectedTests }) true lets watch mode and vitest --changed find a route's test files. Off by default.
vitestPluginNext({ build }) Experimental. "production" runs Next and React as next start does: faster, without their warnings. See Development Or Production. "development" by default, "production" for vite build.

The options for a node, all optional:

Option What it does
url The request URL. Defaults to /. The params are the ones your app's route has for it.
headers Request headers, on top of the ones a browser sends. Sent with every request, also after the first. A cookie header sets cookies for your app.
wrapper A component that wraps the node on the server. It can be a Server Component.
proxy true runs proxy.ts and the routing of next.config for the request, as for a route.
layouts true renders the node in place of the page at url, inside your app's layouts.
container An empty element for the node. Defaults to a new <div> in baseElement, which cleanup removes.
baseElement Defaults to container if you pass one, else document.body.
signal An AbortSignal, for a route too. When it aborts, the page load stops, the page is left, and the render rejects, as fetch does.

A node has the CSS of what its test file and the setup files import. A host other than Vitest passes one more option, hostFiles: the files of the host that render the node, for their CSS. A Vitest test never passes it. See Another Host Than Vitest.

A node resolves with { container, baseElement, asFragment, rerender, unmount, response, errors }. errors has what React reported on the page as it rendered and after, in order: a render that threw, an error that an error boundary caught, a Server Action that threw. A render that throws still resolves, with Next's error page, and its error in errors. rerender(ui) renders another node in its place without a page load, so the state of its Client Components stays: one request of Next's router, as router.refresh() sends, with the same wrapper, url and headers. Every call is a request of its own. It resolves once React has committed the new node and run its effects, and rejects when something else took the node's place, like an error page (a NodeReplacedError), or the page was left. See Rendering The Node Again.

const { rerender } = await renderServer(<Greeting name="Ada" />);
await page.getByRole("button", { name: "Count: 0" }).click();

await rerender(<Greeting name="Grace" />);
// "Hello Grace", and the counter still says "Count: 1".

React Server Components Without Next.js

vitestPluginRSC() on its own renders Server Components for any React app. There's no request and no router: renderServer renders a node to a Flight stream and reads it back in the same test runtime.

// vitest.config.ts
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
import { vitestPluginRSC } from "vitest-plugin-rsc";

export default defineConfig({
  plugins: [vitestPluginRSC()],
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
    },
    setupFiles: ["./src/vitest.setup.ts"],
  },
});
// src/vitest.setup.ts
import { beforeAll, beforeEach } from "vitest";
import { cleanup, initialize } from "vitest-plugin-rsc/testing-library";

beforeAll(() => {
  initialize();
});

beforeEach(async () => {
  await cleanup();
});
import { expect, test } from "vitest";
import { page } from "vitest/browser";
import { renderServer } from "vitest-plugin-rsc/testing-library";
import { ServerCounter } from "./server.tsx";

test("server action", async () => {
  await renderServer(<ServerCounter />);

  await page.getByRole("button", { name: "server-counter: 0" }).click();
  await page.getByRole("button", { name: "server-counter: 1" }).click();
  await expect.element(page.getByRole("button", { name: "server-counter: 2" })).toBeVisible();
});

This renderServer takes { container, baseElement, wrapper } and resolves with { container, baseElement, rerender, unmount, asFragment }. After a Server Action the tree is rendered again.

vite build builds a page of the project that calls renderServer() into a static site, as for Next.js: see A Static Build.

Server Code That Runs In A Browser

The plugin runs server code inside the browser test runtime. That sounds wrong, but the surface is closer than it looks: Node.js has most of the web APIs a browser has, and server code that sticks to those runs in a browser too.

The plugin shims what Next's server needs from Node.js, like AsyncLocalStorage, Buffer, and process.env.

A fast unit test shouldn't touch the real database, filesystem, or network — those make tests slow and flaky. Standard practice is to keep IO inside the test runtime, which here is the browser:

When you have a choice, prefer the APIs that Node and browsers share: Web Streams, Uint8Array, Web Crypto, Blob and File, and fetch, Request, Response, Headers, URL, and FormData.

If a dependency still imports a Node module that isn't shimmed, drop in vite-plugin-node-polyfills for the rest:

import { nodePolyfills } from "vite-plugin-node-polyfills";
import { defineConfig } from "vitest/config";
import { vitestPluginRSC } from "vitest-plugin-rsc";

export default defineConfig({
  plugins: [nodePolyfills(), vitestPluginRSC()],
});

Test Concurrency

test.concurrent doesn't work for tests that read AsyncLocalStorage, which includes anything that touches Next.js App Router internals (headers(), cookies(), next/cache, Server Actions). The plugin's AsyncLocalStorage shim is sequential, so concurrent tests would leak context between each other. Sequential tests within a file are fine; test files still run in parallel.

How It Works

Next.js is a build and a runtime, and only the build is tied to a bundler. So the plugin does the build with Vite, asks Next's own build code for everything else, and runs Next's runtime unchanged. Routing, the proxy, and next.config redirects come from Next's own @next/routing.

Next compiles an app into three layers, each with its own module graph and its own build of React. Each is a Vite environment here, and all three run in the browser test runtime:

Layer Runs Vite environment
rsc Server Components, Server Actions, route handlers, which requests are the app's client
ssr Next's route module, the HTML renderer, the server in front of the app next_ssr
browser Next's router, your Client Components react_client

The test runs in client, the rsc layer's Vite environment. That's why a module your test imports is the instance your Server Components read. A test file with "use client" runs in react_client, without a page of your app.

For the full walkthrough, see docs/next-routes.md. docs/architecture.md covers the part without Next.js: the two environments and the Module Runner bridge between them.

Playgrounds

  • playground/nextjs-e2e-demo — a small Next.js app with a test for every feature of the Next.js support. Most samples above come from its tests. Its build-test/ builds the app with vite build and opens the build in a browser: see A Static Build.
  • playground/nextjs-notes-demo — a fuller Next.js notes app with Better Auth, Drizzle, PGlite test databases, and shadcn/ui. Its tests open whole routes with the database mocked in the test runtime, and the rest of the app real: a test signs in with a Better Auth session on that database. This is the repository's acceptance app.
  • playground/rsc-vitest-demo — a minimal non-Next RSC app. Use this as the smallest end-to-end example of vitest-plugin-rsc on its own. Its build-test/ builds the page of host/main.tsx with vite build and opens the build in a browser.
  • playground/nextjs-host-demo — the Next.js plugin in a Vite dev server without Vitest, as another host runs it. Its tests start that server and drive it with Playwright.

Vitest suites are wired through the root workspace, while each package or playground owns its local config:

pnpm test
pnpm test --project nextjs-e2e-demo
pnpm test --project nextjs-notes-demo-browser

VITEST_PLUGIN_RSC_BUILD=production runs the two Next.js playgrounds as next start would, as CI does too.

About

Render React Server Components in Vitest Browser Mode.

Topics

Resources

Stars

63 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages