Setting Up a Modern React Monorepo with pnpm Workspaces and Biome

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.

H