This is Part 1 of a two-part series on migrating a production React app to a monorepo. This post covers the migration itself — the script, the structure decisions, and two invisible runtime bugs that only appear after you switch package managers. Part 2 covers integrating feature branches, adding a new sub-path app, and the merge strategy that saved us from 45-file conflict hell.
The Codebase
A production SaaS platform — React 18, Vite, Tailwind CSS, shadcn UI, Firebase Realtime Database, React Query, Zustand. Over 500 files, 30+ shadcn UI components, active development by multiple engineers.
Everything lived under one src/ directory:
acme-dashboard/
├── src/
│ ├── components/
│ │ ├── ui/ ← 30+ shadcn components
│ │ ├── AppSidebar/
│ │ ├── ProfileView/
│ │ └── ...
│ ├── features/ ← Feature modules
│ ├── pages/
│ ├── hooks/
│ ├── stores/
│ ├── contexts/
│ └── configs/
│ └── firebase-config.js
├── public/
├── vite.config.js
└── package.json
This worked fine until a second app needed the same shadcn components. Copy-pasting 30+ UI components across repos and keeping them in sync wasn't sustainable. We needed a shared package.
Why pnpm + Turborepo
We evaluated three setups:
| Setup | Pros | Cons |
|---|---|---|
| npm workspaces | Zero config, familiar | Slow installs, phantom dependencies from hoisting |
| yarn berry (PnP) | Fast, strict | Breaks many packages, IDE support is flaky |
| pnpm workspaces + Turborepo | Fast installs, strict isolation, task orchestration | Singleton issues (we'll get to this) |
pnpm won because of disk efficiency (content-addressable store), strict module isolation (no phantom dependencies), and speed. Turborepo added parallel task execution and caching on top.
The trade-off we didn't know about yet: pnpm's strict isolation would break Firebase in a way that produces zero warnings at install time and only fails at runtime. More on that later.
The Target Structure
acme-dashboard/
├── apps/
│ └── dashboard/ # @acme/dashboard
│ ├── src/
│ │ ├── components/ # Dashboard-specific components
│ │ ├── features/
│ │ ├── pages/
│ │ └── ...
│ ├── vite.config.js
│ └── package.json
├── packages/
│ └── ui/ # @acme/ui
│ ├── src/
│ │ ├── components/ # Shared shadcn components
│ │ ├── styles/
│ │ └── lib/utils.js
│ └── package.json
├── scripts/
│ └── migrate-to-monorepo.sh
├── pnpm-workspace.yaml
├── turbo.json
└── package.json # Root workspace config
Two decisions were made upfront:
Only shadcn UI components move to the shared package. Feature components, pages, stores — everything else stays in
apps/dashboard/. Extracting too much creates coupling between the shared package and app-specific code.Two components stay in dashboard even though they're in
components/ui/.async-selectdepends on React Query hooks.confirm-dialoguses the app's toast system. Moving them would pull the entire app intopackages/uias a dependency.
The Migration Script
We wrote a 24-step bash script. Not because bash is the right tool — but because the migration is a one-time operation, and a script makes it reproducible. When three feature branches all need migration, running the same script ensures consistency.
Phase 1: Scaffold (Steps 1–10)
# Create workspace structure
mkdir -p apps/dashboard packages/ui/src/{components,styles,lib,hooks}
# Move app files
mv src/ apps/dashboard/src/
mv public/ apps/dashboard/public/
mv vite.config.js apps/dashboard/
mv index.html apps/dashboard/
# ... tailwind.config, postcss.config, etc.
Generate workspace config:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": { "persistent": true, "cache": false },
"build": { "dependsOn": ["^build"], "outputs": ["build/**"] },
"format": {},
"lint": {}
}
}
Phase 2: Extract Shared UI (Steps 11–20)
This is where the decisions matter. We move shadcn components but need to handle their dependencies:
# Move components
mv apps/dashboard/src/components/ui/* packages/ui/src/components/
# Keep the two that depend on app code
mv packages/ui/src/components/async-select.jsx apps/dashboard/src/components/ui/
mv packages/ui/src/components/confirm-dialog.jsx apps/dashboard/src/components/ui/
# Move shared utilities
mv apps/dashboard/src/lib/utils.js packages/ui/src/lib/
mv apps/dashboard/src/styles/global.css packages/ui/src/styles/
The shared package's package.json:
{
"name": "@acme/ui",
"private": true,
"type": "module",
"exports": {
"./components/*": "./src/components/*",
"./styles/*": "./src/styles/*",
"./lib/*": "./src/lib/*"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
We defined exports for correctness, but — spoiler — they won't be what actually resolves the imports. More on that in the next section.
Phase 3: Rewrite Imports (Steps 21–23)
Every file that imported from ~/components/ui/ needs to point to @acme/ui/components/:
find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) -exec \
sed -i '' \
-e 's|from "~/components/ui/\([^"]*\)"|from "@acme/ui/components/\1"|g' \
-e "s|from '~/components/ui/\([^']*\)'|from '@acme/ui/components/\1'|g" \
{} +
# Revert the two excluded components
find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) -exec \
sed -i '' \
-e 's|from "@acme/ui/components/async-select"|from "~/components/ui/async-select"|g' \
-e 's|from "@acme/ui/components/confirm-dialog"|from "~/components/ui/confirm-dialog"|g' \
{} +
Phase 4: Verify (Step 24)
This step exists because imports will break again — every cherry-pick, merge, or branch integration can reintroduce old-style imports:
BROKEN=$(find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) \
-exec grep -l 'from "~/components/ui/' {} + 2>/dev/null | \
xargs grep 'from "~/components/ui/' 2>/dev/null | \
grep -v "async-select" | grep -v "confirm-dialog" || true)
if [ -n "$BROKEN" ]; then
warn "Found broken ~/components/ui/ imports — fixing..."
# ... same sed replacement as above
fi
This step is idempotent. Running it on already-fixed code changes nothing. We ended up running it five more times over the next week as branches merged in.
The First Invisible Bug: CSS Imports Don't Resolve Exports
After running the script and pnpm install, we started the dev server:
[postcss] ENOENT: no such file or directory, open '@acme/ui/styles/globals.css'
The import in apps/dashboard/src/styles/global.css:
@import "@acme/ui/styles/globals.css";
We'd set up exports in packages/ui/package.json to handle this. But CSS @import statements don't resolve through Node.js exports fields. PostCSS and Vite's CSS pipeline bypass the module system entirely. They look for the file on disk, following the package's main field or the literal path — never exports.
This is a fundamental gap. The exports field was designed for JavaScript modules. CSS tooling predates it and has no integration with it.
The fix
Skip exports for resolution and use a Vite alias:
// apps/dashboard/vite.config.js
resolve: {
alias: [
{ find: "~", replacement: resolve(__dirname, "./src") },
{ find: "@acme/ui", replacement: resolve(__dirname, "../../packages/ui/src") },
],
}
The alias resolves @acme/ui/styles/globals.css to ../../packages/ui/src/styles/globals.css before the CSS pipeline ever sees it. Works for JS imports, CSS imports, and dynamic imports.
We kept exports in the package.json for documentation purposes, but the alias is what actually resolves everything.
The thing that trips people up: this only matters for packages that contain non-JS assets (CSS, images, fonts). Pure JS packages work fine with exports. The moment you share styles across workspaces, you need the alias.
The Second Invisible Bug: pnpm Breaks Firebase
The CSS fix let the dev server start. The app loaded to a white screen with this in the console:
Uncaught Error: Service database is not available
at firebase-config.js:46:27
Line 46: export const realTimeDB = getDatabase(app);
Same code, same Firebase version, same config. The only change was the package manager.
The debugging journey
We started with the obvious — is firebase/database installed? Yes. Is app initialized? Yes. Is getDatabase imported correctly? Yes.
We added console logs:
const app = initializeApp(firebaseConfig);
console.log("App:", app.name); // "[DEFAULT]" ✓
console.log("App options:", app.options); // Full config ✓
const db = getDatabase(app); // 💥 Error
The app was initialized. The database just couldn't see it.
How pnpm isolates modules
npm and yarn use a flat node_modules/. Every package can see every other package. pnpm uses a virtual store — each package gets its own isolated dependencies:
node_modules/
├── .pnpm/
│ ├── @firebase+app@0.11.4/
│ │ └── node_modules/
│ │ └── @firebase/app/ ← copy A
│ └── @firebase+database@1.0.20/
│ └── node_modules/
│ └── @firebase/database/ ← can't see copy A
│ └── (no @firebase/app here)
└── @firebase/app/ ← hoisted symlink
@firebase/database needs @firebase/app but doesn't declare it as a direct dependency (it's a peer dependency expected to be hoisted). In npm/yarn, hoisting makes it available everywhere. In pnpm, the package at .pnpm/@firebase+database@1.0.20/ can't traverse up to find the hoisted copy.
What Vite does with this
Vite's dev server resolves imports on the fly. When @firebase/database internally imports @firebase/app, Vite starts resolution from the database package's location in .pnpm/. It doesn't find @firebase/app there, so it tries... and either finds a different copy or fails silently by creating a new instance.
The result: two separate @firebase/app instances. Your code registers the Firebase app in instance A. @firebase/database looks for it in instance B. The registry is empty. "Service not available."
We confirmed this by checking the actual paths:
# Where does @firebase/database live?
realpath node_modules/.pnpm/@firebase+database@1.0.20/node_modules/@firebase/database
# → /Users/.../node_modules/.pnpm/@firebase+database@1.0.20/.../@firebase/database
# Can it see @firebase/app?
ls node_modules/.pnpm/@firebase+database@1.0.20/node_modules/@firebase/app
# → No such file or directory
# Where is @firebase/app actually?
realpath node_modules/@firebase/app
# → /Users/.../node_modules/.pnpm/@firebase+app@0.11.4/.../@firebase/app
Two completely isolated paths. No way for one to find the other.
The fix
Two Vite config options, both required:
// apps/dashboard/vite.config.js
resolve: {
dedupe: ["@firebase/app", "@firebase/component"],
},
optimizeDeps: {
include: [
"firebase/app",
"firebase/auth",
"firebase/database",
],
},
resolve.dedupe: Forces Vite to always resolve @firebase/app and @firebase/component from the project root, regardless of where the import originates. One copy, one singleton, one registry.
optimizeDeps.include with firebase/database: Forces Vite to pre-bundle the database module during dev server startup. Pre-bundling respects the dedupe setting, so the pre-bundled database uses the same @firebase/app instance as everything else.
Without both, it doesn't work:
| Config | Result |
|---|---|
Only optimizeDeps.include
|
Pre-bundles each module separately → separate instances |
Only resolve.dedupe
|
Dev server bypasses pre-bundling, resolves from .pnpm directly |
optimizeDeps.exclude: ["firebase"] |
No pre-bundling at all → .pnpm resolution → broken |
| Neither | Same as before migration broke it |
This isn't just a Firebase problem
Any library that uses a global singleton pattern will break under pnpm + Vite if the internal packages don't declare proper peer dependencies. Libraries we've seen this affect:
- Firebase (all services)
- Some analytics SDKs that use a shared event bus
- Auth libraries with global token stores
- Any library where sub-packages need to share state via a common core
The fix is always the same: resolve.dedupe the core package + include the sub-packages in optimizeDeps.
Running the Migration
The complete migration checklist:
# 1. Run the script
bash scripts/migrate-to-monorepo.sh
# 2. Install dependencies (creates workspace symlinks)
pnpm install
# 3. Verify the Vite config has:
# - @acme/ui alias
# - Firebase dedupe
# - Firebase in optimizeDeps.include
# 4. Start dev server
pnpm dev
# 5. Verify:
# - App loads without white screen
# - Firebase Realtime DB works (no "Service not available")
# - All UI components render (no broken imports)
# - CSS styles apply (shared globals.css loads)
If step 4 shows errors, the most common causes:
-
ENOENT on
@acme/ui: Runpnpm install— the workspace symlink doesn't exist yet -
"Service X is not available": Add the Firebase service to
resolve.dedupeandoptimizeDeps.include - Missing component: Run Step 24 again — an old import snuck through
- CSS not loading: Check the Vite alias points to the right relative path
What We Ended Up With
// Root package.json
{
"scripts": {
"dev": "turbo dev",
"dev:dashboard": "pnpm --filter @acme/dashboard dev",
"build": "turbo build",
"format": "turbo format"
}
}
$ pnpm dev
# Starts dashboard on localhost:3002
# Turborepo orchestrates all workspace tasks in parallel
The dashboard works exactly as before — same features, same routes, same behavior. But now packages/ui is a shared dependency that any new app can import from. And that's exactly what we needed for Part 2, where we add a second app, integrate a 110-file feature branch, and learn why you should never merge two independently-migrated branches.
Next: Part 2 — Feature Branches, Sub-Path Apps, and the Merge Strategy Nobody Tells You About
Top comments (0)