If you're scaling a React codebase past one app, a monorepo is the right call. The question is which tools don't turn that decision into a weekend of config archaeology. This guide covers react monorepo setup using pnpm workspaces for package management and Biome for linting and formatting — ditching ESLint, Prettier, and their combined fifteen config files for one binary and one JSON file.
Why Biome Over ESLint in a Monorepo
ESLint in a monorepo has a specific flavour of misery. Shared plugin versions that drift out of sync. Per-package configs that partially inherit from a root config but override it in ways nobody six months later remembers. An eslint-config-shared package that needs a bump every quarter and breaks three things. Prettier sitting alongside it, occasionally disagreeing about semicolons.
Biome replaces both. One dependency, one config, written in Rust, typically 10–20× faster than ESLint on comparable codebases. At monorepo scale — ten or more packages running lint in CI — that difference goes from a nicety to saving actual minutes per run. The Biome docs cover single-repo setups well. The monorepo story is buried. That's what this guide surfaces.
If you're starting with a single app before scaling to a monorepo, the React project setup with Biome, Vitest, and GitHub Actions guide covers the flat-repo baseline first.
Step 1: Scaffold the Workspace
mkdir my-monorepo && cd my-monorepo
pnpm init
Create pnpm-workspace.yaml at the root:
packages:
- 'packages/*'
Scaffold your packages:
mkdir -p packages/web packages/ui packages/shared
for dir in packages/web packages/ui packages/shared; do
(cd $dir && pnpm init)
done
Name each package with a scope in its package.json — @myapp/web, @myapp/ui, and so on. Scoped names make pnpm --filter commands readable and prevent collisions if any packages are ever published.
Add scripts to the root package.json:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"lint": "biome check .",
"format": "biome format --write .",
"lint:ci": "biome ci ."
}
}
"private": true is required. Without it, pnpm considers the root package publishable and will complain.
Step 2: Install and Configure Biome
Install Biome at the workspace root. The -w flag is critical — it installs into root node_modules rather than hoisting into a nested package:
pnpm add -D -w @biomejs/biome
pnpm biome init
The generated biome.json is permissive by default. Replace it:
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5",
"semicolons": "always"
}
},
"files": {
"ignore": ["**/node_modules", "**/dist", "**/.turbo"]
}
}
Run pnpm lint from the root. Biome walks the entire directory tree — no per-package invocations needed.
Step 3: Per-Package Overrides
Not every package needs identical rules. A Node scripts package should have noConsoleLog disabled. A browser-facing app might want stricter DOM-specific checks. Drop a biome.json into any package that diverges, using extends to inherit from root:
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["../../biome.json"],
"linter": {
"rules": {
"suspicious": {
"noConsoleLog": "off"
}
}
}
}
Worth knowing up front: extends takes a path relative to the current biome.json, not the project root. The docs don't make this obvious, and getting it wrong produces a vague "couldn't resolve config" error with no line number. Consider yourself warned.
To lint a single package during development without running the full monorepo:
pnpm biome check packages/web
# Or with workspace filtering if packages have their own lint scripts:
pnpm --filter @myapp/web lint
Step 4: CI Integration
Use biome ci in pipelines, not biome check. The ci subcommand is read-only: it checks everything, produces a clean report, and exits non-zero on any violation. No accidental auto-fixes landing in your pipeline output.
GitHub Actions (.github/workflows/ci.yml):
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm biome ci .
That's the entire lint job. Under 10 seconds on a mid-sized monorepo. No separate format step — biome ci covers both.
Two things to keep alongside Biome. First, Biome doesn't do TypeScript type checking — tsc --noEmit stays in CI for that; it catches errors Biome simply can't see. Second, if you're shipping individual packages as preview deployments to Vercel, the per-package biome.json ensures the rules each package deploys with are exactly the rules CI enforces for it.
For Turborepo users, wire Biome into the task pipeline so per-package caching works:
{
"pipeline": {
"lint": {
"inputs": ["**/*.ts", "**/*.tsx", "biome.json"]
}
}
}
Turbo hashes those inputs and skips lint entirely for unchanged packages. A 20-package monorepo with warm Turbo cache lints in under 3 seconds. For a broader look at how the build tooling ecosystem holds up at this scale, the Vite vs Turbopack vs Rspack bundler comparison is worth reading before committing to a full stack.
The Takeaway
pnpm workspaces + Biome is the correct default stack for React monorepos in 2026. pnpm handles package linking without hoisting headaches. Biome handles linting and formatting without config sprawl — one dependency, one file, one command in CI.
The only gap is TypeScript type checking. Keep tsc --noEmit in your pipeline; everything else Biome covers. If you're migrating from ESLint + Prettier rather than starting fresh, run biome migrate eslint and biome migrate prettier first — both are built-in commands that convert your existing configs and get you 80% of the way there without manual translation.