A single repo often lives in two worlds at once. The backend runs in Node: there is process, Buffer, the file system, and no browser - nor should there be. The frontend counts on the DOM - document, window, events - which Node does not have. testEnvironment in Jest sets that world for the whole config: either node or jsdom (a light browser emulation on top of Node). One flat config is forced to pick a single option for everything - and thereby serves only half of the repository.
The first thing that comes to mind is to keep two separate configs and two scripts: one run for the server, another for the web. It works, but it splits the suite in two. Two runs instead of one, two coverage reports with nothing to merge them later, a watch mode you have to start twice, and CI juggling two commands. The more packages a monorepo has, the more this fragmentation gets in the way.
projects solves it differently: a single Jest run holds several independent configs at once. The projects field takes an array where each element is a self-contained mini-config with its own testEnvironment, its own testMatch (which files count as tests), its own setupFilesAfterEnv. Jest runs them in one pass and labels the output by displayName, so you can see at once whose test it is - server or web. One command, one shared report, but different environments inside.
It works because each package gets exactly its own world and does not pay for someone else's. A server with testEnvironment: 'node' does not spin up jsdom - which is noticeable startup time and extra global objects the backend does not need. The web with testEnvironment: 'jsdom' gets a real document and window and does not inherit accidental assumptions from Node - it does not quietly rely on process or Buffer, which will not exist in a browser. The boundaries between environments stop leaking into each other.
In a monorepo this scales through shared defaults. What is the same for every package - the TypeScript transformer, the moduleNameMapper for aliases, the base settings - is pulled into a preset (a reusable config preset), and each project keeps only the differences: name, environment, where to look for tests. Adding a new package then becomes a few lines of project on top of the shared preset, not a copy of the whole config that drifts apart from the rest by tomorrow.
The price is that some settings live only at the top level, not inside a project. Collecting coverage and its thresholds, reporters, watch plugins are configured globally for the whole run - you cannot set them per project as freely as the environment. So projects earn their place when packages genuinely differ in environment or file set; for a single app with one environment it is an extra layer.
A typical failure shows up the moment the boundaries get mixed up. A component test that reaches for screen and document lands under a project with the node environment - and fails on the very first access to document, which Node does not have. The reverse case is subtler: in one flat jsdom config a test that is server-side in spirit quietly leans on window, which jsdom kindly supplied - in real Node such code fails, but the suite does not catch it, because the environment was the wrong one. projects makes that boundary explicit, and both classes of error disappear.
const config = {
projects: [
{
displayName: 'server',
testEnvironment: 'node',
testMatch: ['<rootDir>/apps/api/**/*.test.ts'],
},
{
displayName: 'web',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/apps/web/**/*.test.tsx'],
setupFilesAfterEnv: ['<rootDir>/test/setup-dom.ts'],
},
],
}