EmpireUI
Get Pro
← Blog9 min read#bun#workspaces#monorepo

Bun Workspaces Guide: Monorepo With Bun Install and Scripts

Set up a Bun monorepo from scratch — workspaces, shared packages, filtered scripts, and the gotchas nobody documents until you hit them.

Terminal window showing Bun package manager workspace monorepo setup commands

Why Bun Workspaces Are Worth Your Time

Bun 1.x shipped workspace support that's actually good. Not "good for a new runtime" good — genuinely competitive with pnpm workspaces, and faster by a margin that you'll notice the first time you run bun install after a fresh clone. On a mid-sized monorepo with 12 packages and ~600 total dependencies, cold installs clock in around 4–6 seconds. pnpm takes 18–25 seconds on the same machine.

That said, workspaces aren't magic. The mental model is the same one you've used with npm, Yarn, or pnpm — a root package.json declaring workspace globs, child packages that cross-reference each other with workspace:* protocol, and filtered script execution. What Bun adds is speed and a slightly cleaner DX around cross-package scripts.

Honestly, the biggest win isn't raw install speed. It's that Bun collapses the toolchain. You're not stitching together npm + esbuild + ts-node + a test runner. One binary handles installs, bundling, running TypeScript directly, and test execution. In a monorepo context, fewer moving parts translates directly to less CI weirdness.

Worth noting: Bun workspaces require Bun ≥ 1.1.0 for stable workspace:* protocol support. If you're on anything older, upgrade first — the pre-1.1 behavior around symlink resolution had some sharp edges that'll waste your afternoon.

Setting Up the Root Package

Start with an empty directory and a root package.json. You're declaring the workspace globs here — Bun scans these at install time and links each matching directory as a workspace package.

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "bun run --filter='*' dev",
    "build": "bun run --filter='*' build",
    "test": "bun test"
  },
  "devDependencies": {
    "typescript": "^5.5.0"
  }
}

The private: true field isn't optional here — it prevents you from accidentally publishing the root to npm. Don't skip it. The apps/* and packages/* split is a convention, not a requirement, but it's one worth following. Apps are deployable things (Next.js apps, API servers). Packages are shared code (UI components, utils, config files).

One more thing — the --filter flag in the scripts block is Bun's way of running scripts across workspace packages. --filter='*' targets every package that has that script. You can get more specific with --filter='./apps/*' or filter by package name with --filter='@myrepo/ui'. More on that in the scripts section.

Run bun install at the root after creating this file. Bun will scan the globs, find zero packages (since you haven't created any yet), and generate a bun.lockb. That lockfile is binary — don't try to read it, and definitely commit it.

Creating Workspace Packages

You need at least two packages to actually exercise the workspace linking. Let's build a shared UI library and a Next.js app that consumes it. Classic setup, gets the concepts across fast.

# Create the shared package
mkdir -p packages/ui
cd packages/ui
bun init -y

Edit packages/ui/package.json to give it a scoped name and a proper exports map: ``json { "name": "@myrepo/ui", "version": "0.0.1", "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }, "scripts": { "build": "bun build ./src/index.ts --outdir ./dist --target browser", "dev": "bun --watch ./src/index.ts" } } ``

Pointing exports directly at TypeScript source is a Bun-specific thing. Since Bun executes .ts natively, you don't need to pre-compile your shared package just so the consuming app can import it. In practice, this shaves 15–30 seconds off local startup in repos where you'd otherwise need a tsc --watch process per package.

Now create your app. The pattern is identical — mkdir -p apps/web, add a package.json, and reference the shared package using workspace:*: ``json { "name": "@myrepo/web", "dependencies": { "@myrepo/ui": "workspace:*", "next": "^14.0.0", "react": "^18.3.0" } } ` Then bun install from the root. Bun resolves workspace:* to a symlink inside node_modules/@myrepo/ui pointing at your local packages/ui` directory. Changes to the shared package show up immediately in the app — no publish step, no build step.

Running Filtered Scripts

This is where Bun workspaces diverge from Turborepo-style orchestration. Bun's --filter runs scripts in parallel across matching packages, but it doesn't do dependency-aware ordering out of the box. If @myrepo/web depends on a compiled output from @myrepo/ui, you need to handle that sequencing yourself or point the app at source (which works fine in dev).

# Run dev in every package that has a dev script
bun run --filter='*' dev

# Run build only in apps
bun run --filter='./apps/*' build

# Target a single package by name
bun run --filter='@myrepo/ui' build

# Run a script in the package whose directory you're currently in
bun run dev  # (from inside packages/ui)

Look, the filtering syntax is clean but it trips people up on one point: --filter accepts glob patterns that match against the name field in package.json OR against the directory path relative to root. --filter='./apps/*' is a path match. --filter='@myrepo/*' matches against package names. Both work, don't mix them in a single filter expression.

Quick aside: if you need proper build orchestration with caching and topological sort, Bun workspaces pair well with Turborepo. Turborepo handles the task graph; Bun handles the actual installs and script execution. You get the speed of Bun's installer with Turbo's smarter parallelism. The turbo.json packageManager field should still point at bun so Turbo knows which runner to use.

For most teams, though, the raw --filter approach is enough. You don't always need a task graph. A lot of monorepos just need bun run --filter='./apps/*' dev to spin up the apps and bun run --filter='./packages/*' build to compile shared libs before deploy.

Sharing TypeScript Config and Tooling

Root-level tooling config is one of the best parts of a monorepo. One tsconfig.base.json, one ESLint config, one Prettier setup — packages extend from root instead of each declaring their own. Here's a workable root TypeScript config for 2026:

// tsconfig.base.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Each package extends it and adds only what's specific to that package: ``json // packages/ui/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src"] } ``

Same pattern for Bun's runtime config. A root bunfig.toml applies to all packages unless overridden. You'd put your test configuration, preload scripts, or registry settings there: ``toml # bunfig.toml (root) [test] preload = ["./tests/setup.ts"] coverage = true coverageReporter = ["text", "lcov"] [install] frozen = false # set true in CI ``

In CI you'll want bun install --frozen-lockfile to catch lockfile drift. Add that as an env flag or flip frozen = true in your CI-specific bunfig.toml. Bun doesn't have an --ci flag like npm does, so the lockfile flag is your equivalent.

Cross-Package Imports and Type Safety

With workspace:* linking, import { Button } from '@myrepo/ui' just works from inside the Next.js app. Bun resolves the symlink, TypeScript picks up the types from the source file directly (no compile step needed in dev), and your editor gets autocomplete and go-to-definition pointing at the actual source. That's the dev experience you want.

Where it breaks down: if you have a package that only ships compiled output (maybe a third-party-style internal library you want to treat as a black box), you need to make sure main and types in its package.json point at ./dist, not ./src, and that you run its build before any consumer tries to type-check. Bun won't do that for you. A simple bun run --filter='@myrepo/shared-lib' build && bun run --filter='./apps/*' dev sequence handles it.

// packages/ui/src/index.ts
export { Button } from './components/Button';
export { Card } from './components/Card';
export type { ButtonProps } from './components/Button';

// apps/web/src/app/page.tsx
import { Button } from '@myrepo/ui';
// Works — Bun resolves to packages/ui/src/index.ts via symlink

One gotcha worth flagging: path aliases in tsconfig.json (like @/*) don't automatically propagate across workspace boundaries. If your UI package uses @/utils internally, you need to configure that alias in the UI package's own tsconfig.json, not just in the root. Each package's TypeScript is resolved independently.

CI, Publishing, and Common Pitfalls

In CI, install with bun install --frozen-lockfile and run scripts from the root. GitHub Actions with oven-sh/setup-bun@v2 is the standard approach as of late 2025. The action installs a specific Bun version, then your workflow scripts are just bun install and bun run build.

# .github/workflows/ci.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.1.38
      - run: bun install --frozen-lockfile
      - run: bun run --filter='./packages/*' build
      - run: bun run --filter='./apps/*' build
      - run: bun test

Publishing internal packages to a private registry? bun publish works exactly like npm publish. Add publishConfig to your package's package.json pointing at your registry URL, then bun publish --access restricted from that package's directory. You can also run bun run --filter='@myrepo/ui' publish from root.

Common pitfalls you'll hit: (1) Forgetting private: true on the root and accidentally publishing it. (2) Using file: protocol instead of workspace:*file: copies the package at install time instead of symlinking, so local changes won't reflect. (3) Circular dependencies between workspace packages. Bun won't warn you about these; they'll just cause confusing resolution errors at runtime. Map your package dependency graph before you start if the repo is large. And if you're building a component library for a design system, consider browsing Empire UI's architecture — it's a real-world example of cross-package component sharing done cleanly.

In practice, Bun workspaces give you 80% of what Turborepo does for the average team, with zero extra config. Start simple, add Turbo only when you have enough packages that incremental builds actually matter. For frontend-heavy monorepos — especially ones with shared UI component packages like what you'd build with Empire UI components — the native Bun workspace setup gets you surprisingly far before you need orchestration tooling.

FAQ

Does `bun install` hoist packages the same way npm does?

By default, yes — Bun hoists dependencies to node_modules at the root, similar to npm and Yarn classic. You can't opt into pnpm's strict isolated layout yet. This means workspace packages share a flat node_modules, which occasionally causes phantom dependency issues if you're not careful about declaring deps explicitly in each package's package.json.

Can I use `workspace:*` with packages that need to be compiled before they're consumed?

You can, but you're responsible for sequencing the build. Bun won't automatically build @myrepo/ui before it runs @myrepo/web. Point consuming packages at TypeScript source during dev (via the exports field) and add an explicit build step in CI that compiles packages before apps.

Is `bun.lockb` safe to commit to git?

Yes, commit it. It's a binary file that Bun generates and reads — similar to yarn.lock but not human-readable. It pins exact versions of every dependency across your entire workspace. Teams that skip committing it end up with install drift between machines, which causes the classic "works on my machine" CI failures.

How does `bun run --filter` compare to `turbo run`?

Bun's --filter runs matching scripts in parallel without any dependency ordering or caching. Turborepo's turbo run respects your turbo.json task graph, caches outputs, and skips unchanged packages. For small monorepos --filter is enough; once you have 10+ packages with real interdependencies, Turbo's caching pays off.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Turborepo Setup Guide: Monorepo Caching, Pipelines and Remote CacheHono.js Guide: Ultra-Fast Edge API for Bun, Deno and CloudflareReact Monorepo With Turborepo: Shared Components, Configs, PackagesNx Monorepo Guide: Affected Commands, Caching and React Libraries