One Command, Two Environments: Using Vitest Projects in a Monorepo
Vitest projects let one root config run node and jsdom test suites together in a monorepo. A worked two-package example, filtering for fast local loops, and the memory and coverage trade-offs.
20 May 2026, 03:06 UTC

Your monorepo has a UI package that needs a DOM and an API package that must never see one. Today that means two test configs, two CI steps, and two coverage reports that nobody merges. Vitest's projects feature collapses that into a single vitest run that executes both suites, each in its own environment, and reports results side by side.
The thesis is simple: if your packages share a toolchain, they should share a test runner invocation too — but only if you understand where the convenience ends.
What a Vitest project actually is
A project is a named test configuration inside one root run. Each project gets its own environment (for example node versus jsdom), its own include globs, and its own setup files. Because Vitest is built on Vite's transform pipeline, each project also inherits your app's Vite plugins and resolve.alias settings. That last point is the real win over a Jest multi-project setup: the aliases and transforms your app already uses don't need to be duplicated in test config.
One caveat before the example: the configuration shape has changed across Vitest major versions. Older releases used a vitest.workspace.ts file exporting an array; newer releases (Vitest 3.x) moved to a test.projects field in the root config. Check your installed version's type definitions or vitest --help before copying anything below.
A worked example: ui + api
Assume a pnpm/npm workspaces repo with packages/ui (React components) and packages/api (Node services). A root config for Vitest 3.x looks like this:
// vitest.config.ts (repo root)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
},
projects: [
{
test: {
name: 'ui',
environment: 'jsdom',
include: ['packages/ui/**/*.test.{ts,tsx}'],
setupFiles: ['./packages/ui/test/setup.ts'],
},
},
{
test: {
name: 'api',
environment: 'node',
include: ['packages/api/**/*.test.ts'],
},
},
],
},
});The ui setup file is where you'd register testing-library matchers:
// packages/ui/test/setup.ts
import '@testing-library/jest-dom/vitest';Run everything from the repo root (no special permissions needed, just the dev dependencies installed):
npx vitest runYou should see results grouped by project name — [ui] and [api] prefixes in the output. That prefix is your verification that both environments actually ran; if a project silently matches zero files, Vitest will tell you, so treat "no test files found" as a config bug, not a pass.
Keeping the local loop fast
Running the whole workspace on every save gets old quickly. Filter by project name when you're only touching one package:
npx vitest --project apiThis skips the ui project entirely — no jsdom startup, no setup files loaded. Confirm it works by checking that the output only shows the api label. In CI, keep the unfiltered vitest run so nothing drifts.
Shared settings like the coverage provider live at the root; per-project overrides live inside each project block. Resist the urge to copy-paste the root block into every project — the point is that common config exists once.
The honest trade-offs
Memory and noise. Projects run in parallel, and each one spins up its own environment and transform cache. On a large repo, watch mode with several projects is noticeably heavier and the interleaved output is harder to scan than a single-project run. Some teams prefer per-package test scripts orchestrated by their task runner (Turborepo, Nx) and reserve the root projects config for CI. That's a legitimate choice, not a failure.
Coverage aggregation needs care. A merged coverage report is convenient, but a package with no tests at all can dilute your aggregate percentage and mask a real gap. Set per-package thresholds or explicitly scope coverage.include rather than trusting the global number.
jsdom is not a browser. It simulates the DOM but has no layout engine and imperfect event behavior. Component tests that depend on real rendering, focus behavior, or visual interaction belong in Vitest's browser mode or a Playwright-based setup — don't let a green jsdom suite give you false confidence.
Where to start
Pick your two most divergent packages, write a root config with one project each, and run npx vitest run from the repo root. Verify three things before rolling it out: both project labels appear in the output, --project <name> skips the other suite, and the merged coverage numbers match what each package reported alone. If all three hold, you can delete the per-package CI steps and stop maintaining two configs that were always going to drift apart.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.