Skip to content
·[tooling]·9 min read

40 packages, 1 dev: Turborepo + Bun + Biome monorepo stack

Turborepo Bun Biome: how a solo dev runs a 40-package React Native + Web monorepo - the real turbo.json, biome.json, and CI dependency-graph guardrail.

Forty packages, one maintainer, and no ESLint config anywhere in the repo. That is not a boast - it is the direct result of a decision made early: every tool in the toolchain has to earn its place by governing all forty packages from one config file, not forty.

The repo is flare-engine, a modular 2D engine for React Native + Web (animation, gamification, interactive UI, with games as showcases - not a game engine, not a Unity or Godot competitor). The stack behind it is Turborepo Bun Biome, and this post is the actual setup: the real turbo.json, the real biome.json, the real CI guardrail, straight from the repo (trimmed only where a config is long, and I say so where I trim), not a starter template's idealized version.

Four binaries, four root configs - turbo.json, biome.json, tsconfig.base.json, and the Changesets config - each governing all forty packages at once (Bun's own "config" is just the workspaces array in the root package.json). Plus a CI step that fails the build the moment a package imports something it shouldn't. That is the whole story, and I want to show you the files, not describe them.

Four binaries, not twelve config files

The thesis is narrow: a solo maintainer can keep forty packages honest only if there is exactly one config of each kind, and every package extends it rather than declaring its own variant. Twelve packages each with a slightly different ESLint config is not a monorepo, it is twelve monorepos wearing a workspace file as a costume.

Here is the root package.json that runs all of it - Bun workspaces (not pnpm; that distinction matters and I will say it again below), the script table every package leans on, and the pinned package manager:

// C:\_PROG\flare-engine-workspace\flare-engine\package.json
{
  "name": "flare-engine",
  "version": "0.0.0",
  "private": true,
  "workspaces": ["packages/*", "benchmarks", "apps/*"],
  "scripts": {
    "build": "turbo build",
    "test": "turbo test",
    "lint": "turbo lint",
    "typecheck": "turbo typecheck",
    "check:deps": "bun scripts/check-deps-tiers.mjs",
    "check:changeset": "bun scripts/check-changeset.mjs",
    "lint:fix": "biome check --write .",
    "format": "biome format --write .",
    "release": "bun run propagate-license && turbo build --filter='./packages/*' && changeset publish"
  },
  "packageManager": "[email protected]"
}

workspaces is a plain array of globs - packages/*, the private benchmarks package, and apps/*. No pnpm-workspace.yaml, because there is no pnpm here. scripts.build/test/lint/typecheck are one-liners that hand off to turbo <task> - the root package.json doesn't know how to build anything itself, it just fans the call out. check:deps and check:changeset are the two custom guardrails covered later. lint:fix is biome check --write . over the entire repo - one binary, every package, one pass.

Bun as the whole toolbelt

Bun is the second of the three legs of Turborepo Bun Biome, and it's doing more work than "package manager" implies - it is the runtime, the test runner, and the workspace mechanism in one binary. bun install resolves the workspace graph and writes a text lockfile. bun test runs every package's unit tests without a separate Jest or Vitest config. bun scripts/check-deps-tiers.mjs runs a plain ESM script with zero build step.

I want to be explicit about something, because it is easy to get wrong reading about this stack in 2026: this is Bun workspaces, not pnpm. pnpm 10 is what runs this portfolio's own repo - a completely different project. Mixing the two up is a category error, and if you're evaluating "Bun workspaces vs pnpm" for your own monorepo, the honest answer is that flare-engine picked Bun specifically because collapsing the runtime, the package manager, and the test runner into one binary was worth more than pnpm's more mature workspace tooling.

The versions in this post are a snapshot, resolved from bun.lock on 2026-07-04: Bun 1.2.0, Turborepo 2.9.6, Biome 1.9.4, TypeScript 5.9.3. Bun 1.3, Turborepo 3.0, and Biome 2.2 already exist upstream as of this writing - if you're reading this later, check your own lockfile before copying a version number out of a blog post.

One turbo.json for 40 packages

Turborepo Bun Biome is the whole toolchain, and turbo.json is the file that makes the "one config governs forty packages" claim literal. Here it is in full - four tasks and a cache-key line:

// C:\_PROG\flare-engine-workspace\flare-engine\turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", "tsconfig.json", "biome.json", "bunfig.toml"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**", "out/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

build.dependsOn: ["^build"] is the caret-prefixed dependency graph: before a package builds, every package it depends on has to build first (the ^ means "this task in my dependencies," not "this task in me"). outputs tells Turborepo which directories to hash and cache - dist/** for the tsup output, out/** for the one Next.js app in apps/*. globalDependencies does the inverse: it puts the root configs (biome.json, tsconfig.base.json, bunfig.toml) into every task's cache key, so editing any one of them busts the cache for all forty packages at once, not just the package you touched. test.dependsOn: ["build"] means tests only run against built output, never against stale dist. typecheck.dependsOn: ["^build"] needs its dependencies' type declarations to exist first, but not its own build (typecheck doesn't need itself pre-built - tsc --noEmit reads .ts source directly). lint: {} has no dependency at all - it is fully parallel, because Biome only ever looks at one package's own src/ directory.

100%

That outputs array is also the entire cache contract. When Turborepo sees a task whose inputs (source files + lockfile + this turbo.json) hash to something it has seen before, it replays the recorded outputs instead of re-running the task - the "FULL TURBO" line in the terminal. I am not going to hand you a cache-hit percentage here; a third-party 2026 monorepo guide claims a specific number for Bun over pnpm, and that number is theirs, measured on their repo, not mine. The honest visual for this post is a real terminal capture of a cold turbo run build followed by a warm one hitting the cache - no invented statistic attached.

Biome: lint and format in one config

Biome replaces ESLint and Prettier with one Rust binary and one config file. biome.json, trimmed to the linter and formatter blocks:

// C:\_PROG\flare-engine-workspace\flare-engine\biome.json
{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "all",
      "semicolons": "always"
    }
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedImports": "error",
        "noUnusedVariables": "error"
      },
      "style": {
        "noNonNullAssertion": "off",
        "useConsistentArrayType": {
          "level": "error",
          "options": { "syntax": "shorthand" }
        },
        "useImportType": "error",
        "useSingleVarDeclarator": "off",
        "noDefaultExport": "error"
      }
    }
  }
}

noUnusedImports and noUnusedVariables are set to error, not warn - a dead import fails CI, it doesn't just clutter a report. useImportType is also error: every type-only import must say import type, which matters a great deal in a monorepo where verbatimModuleSyntax (next section) is on - a plain import of a type-only binding is a build error, not a lint nit. noNonNullAssertion is explicitly turned off - the codebase uses ! deliberately in places where the type system can't see an invariant the engine guarantees at runtime. noDefaultExport is set to error as well, so the whole codebase is named-exports-only, with a small overrides block that flips the rule back off for the handful of files that genuinely need a default export (config files like *.config.ts and the Next.js app pages). One file. Lint and format both live here; there is no separate .eslintrc and .prettierrc to keep in sync, because there's nothing to keep in sync.

One tsconfig base, 40 extends

tsconfig.base.json is the config every package's TypeScript extends - the same one-config-governs-all rule, applied to type-checking:

// C:\_PROG\flare-engine-workspace\flare-engine\tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "types": ["bun-types"],
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "noUncheckedIndexedAccess": true
  },
  "exclude": ["node_modules", "dist"]
}

strict and noUncheckedIndexedAccess are the two settings that catch the most real bugs in practice - the second means array[i] is typed T | undefined, not T, so every index access has to prove it checked bounds. verbatimModuleSyntax is what makes Biome's useImportType rule load-bearing rather than cosmetic: without it, a type-only import that isn't marked import type can survive erasure incorrectly at the module boundary. Forty packages extend this one file and layer on their own include paths - none of them re-declares strict or re-decides whether indexed access is checked.

Releasing in lockstep

Changesets manages versioning, and the .changeset/config.json makes a deliberate, debatable choice - lockstep, not independent SemVer:

// C:\_PROG\flare-engine-workspace\flare-engine\.changeset\config.json
{
  "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
  "changelog": ["@changesets/changelog-github", { "repo": "grzott/flare-engine" }],
  "commit": false,
  "fixed": [["@flare-engine/*"]],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@flare-engine/benchmarks"]
}

fixed: [["@flare-engine/*"]] means every scoped package bumps together - all forty move from 0.2.0 to 0.2.1 in the same release, even if only math changed. That is lazy by the independent-SemVer argument, and I'll name the tradeoff honestly: for a single-maintainer engine where consumers (Pan Tvardowski, the internal second game) always want the whole set moving in step, lockstep removes an entire class of "which version of render works with which version of ecs" support questions. If this were a multi-team OSS project with independent release cadences per package, I'd pick linked groups or full independence instead.

access: "restricted" is the other load-bearing field. @flare-engine/* publishes to the private npm registry at version 0.2.1 today - restricted until the October 2026 Apache-2.0 launch. Any example below that says bun add @flare-engine/math needs that fine print attached, every time:

# Restricted until the Oct-2026 Apache-2.0 launch - not yet public npm
bun add @flare-engine/math

ignore: ["@flare-engine/benchmarks"] excludes the private benchmarking package from versioning entirely - it lives outside packages/ and never ships.

A per-package example, packages/math/package.json, shows the four scripts every one of the forty packages carries:

// C:\_PROG\flare-engine-workspace\flare-engine\packages\math\package.json
{
  "name": "@flare-engine/math",
  "version": "0.2.1",
  "scripts": {
    "build": "tsup",
    "test": "bun test",
    "lint": "biome check src/",
    "typecheck": "tsc --noEmit"
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "https://registry.npmjs.org/"
  },
  "sideEffects": false
}

Same four scripts, every package, every time - tsup for the build, bun test for the tests, biome check src/ for lint, tsc --noEmit for typecheck. Turborepo's whole job is fanning these four identical scripts out across forty package.json files in dependency order.

The guardrail that ties it together

None of the above prevents a package from importing something one tier above it in the dependency graph - config alone doesn't enforce architecture. That enforcement is a custom script, scripts/check-deps-tiers.mjs, wired into CI:

// C:\_PROG\flare-engine-workspace\flare-engine\scripts\check-deps-tiers.mjs
/**
 * check-deps-tiers - CI guardrail for the @flare-engine/* dependency graph.
 *
 * Three checks over every packages/*:
 *   1. PHANTOM deps - declared in package.json (deps + peerDeps) but never
 *      imported from src (.ts/.tsx/.mts/.cts).
 *   2. MISSING deps - imported from src but not declared.
 *   3. UPWARD tier violations - a package depending on a strictly-higher tier,
 *      breaking the downward-only rule. `@flare-engine/engine` is the sanctioned
 *      umbrella barrel: it may depend on anything, but its own deps are still
 *      required to point downward from every OTHER package's perspective.
 *
 * Phantom and missing are both errors. Exit 1 on any violation, 0 when clean.
 * Bun-runnable plain ESM.
 */

Three checks, one script, exit 1 on any violation: phantom dependencies (declared but never imported - dead weight), missing dependencies (imported but never declared - the thing that silently works today and breaks the moment hoisting changes), and upward-tier violations (a lower-tier package reaching into a higher-tier one, breaking the downward-only dependency rule that keeps the whole graph acyclic). This is the mechanical enforcement of the same strict-layer discipline covered in Architecture before the AI build - that post argued for the rule; this script is what makes the rule impossible to violate by accident. The result, measured 2026-07-04: 0 dependency cycles across the full acyclic DAG.

CI wires all of this into one ordered chain, .github/workflows/ci.yml:

# C:\_PROG\flare-engine-workspace\flare-engine\.github\workflows\ci.yml
name: CI
 
on:
  pull_request:
    branches: [main]
 
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
 
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
 
      - run: bun install --frozen-lockfile
 
      - run: bun run lint
      - run: bun run check:deps
      - run: bun run check:changeset
      - run: bun run typecheck
      - run: bun run build
      - run: bun run test

bun install --frozen-lockfile first - no silent lockfile drift on a PR build. Then six steps in a fixed order: lint (fast, fully parallel, fails first if it's going to fail), check:deps (the tier guardrail above), check:changeset (did this PR remember to add a changeset), typecheck, build, test - the slowest and most expensive check runs last, after everything cheaper has already had a chance to fail fast. This is engine-only CI: it exercises lint/typecheck/build/test on Ubuntu with setup-bun, not on-device React Native builds, which run through a separate EAS pipeline outside this post's scope.

Limits

Bun, not pnpm. Said once above, said again here because it's the easiest thing to get backwards reading about this stack out of context: flare-engine runs on Bun workspaces. pnpm 10 is a different repo (this portfolio). If you're deciding between the two for your own monorepo, don't let this post stand in for a real Bun-vs-pnpm benchmark - it isn't one.

Versions drift fast. Bun 1.2.0, Turborepo 2.9.6, Biome 1.9.4, TypeScript 5.9.3 are the resolved versions from bun.lock on 2026-07-04. Bun 1.3, Turborepo 3.0, and Biome 2.2 are already out in the wild as this publishes. Treat every version number here as a dated snapshot, not a recommendation to pin.

No cache-hit percentage of my own. I did not measure a cache-hit rate on this repo, and I'm not going to borrow the "~40% fewer cache misses vs pnpm" number from someone else's 2026 guide and present it as mine. The gallery above shows a real, unedited turbo run build replay hitting FULL TURBO - that's the honest visual, with no percentage attached to it.

Restricted npm. @flare-engine/* publishes access: restricted at version 0.2.1 - private registry, not installable by the public - until the October 2026 Apache-2.0 launch. The repo's LICENSE file says MIT today; Apache-2.0 is the plan for the pre-launch relicense, not the current state.

This is a tooling post, not an engine-internals post. The framing here is a monorepo running a modular 2D engine for React Native + Web animation, gamification, and interactive UI - games are showcases built on it, not the product itself, and this isn't a game-engine-vs-Unity-or-Godot comparison. If you want the engine's internals, that's a different post.

Close

If you're setting up or cleaning up a TypeScript monorepo in 2026, the shape here scales down further than forty packages suggests. Two packages need almost none of this - you can skip the tier guardrail and the lockstep Changesets config until you actually have a dependency graph worth protecting. What doesn't scale down is the discipline of one config per tool: the moment you let a second package declare its own ESLint rules or its own tsconfig strictness, you've started the sprawl this whole stack exists to prevent.

This toolchain is also the reason a new prototype package - a second consumer app, an experiment that might not ship - takes minutes to wire up instead of days: bun add, extend the base tsconfig, and Turborepo already knows how to build, test, lint, and typecheck it in the right order. Clone the shape, not the exact numbers; your forty will look different from mine.

If you want the layering rule this repo's CI mechanically enforces, Architecture before the AI build is the post that argues for it. If you want to see where this repo started - 36 packages, three weeks, the same strict-layer discipline before the guardrail script existed - that's 36 packages, 1 dev: a React Native game engine in 3 weeks.

Related