Tools: Turborepo + Vite with React Monorepo (Part 2)

Tools: Turborepo + Vite with React Monorepo (Part 2)

Source: Dev.to

The Problem: Three Branches, One Migration ## Adding a Second App to the Monorepo ## The workspace setup ## The five layers of sub-path configuration ## The debugging principle ## Integrating Feature Branches: The Wrong Way ## What we tried first ## The resolution that silently broke everything ## What actually happened ## The lesson ## Integrating Feature Branches: The Right Way ## Step 1: Start from the migrated base ## Step 2: Get the feature PR's final file state ## Step 3: Copy with path mapping ## Step 4: Fix imports ## Step 5: Add new dependencies ## Step 6: Commit and merge ## Why this works ## The Import Safety Net ## The Complete Workflow ## What We Learned ## Things that broke silently ## Things that could have been caught earlier ## The production-level principles ## Final State This is Part 2 of a two-part series. Part 1 covered the migration itself — moving 500+ files to a pnpm monorepo, fixing CSS resolution, and debugging a Firebase singleton bug that only appears under pnpm's strict module isolation. This post covers what happens after migration: integrating feature branches that don't know the monorepo exists, adding a second app under a sub-path, and the git merge strategy that turned 45 conflicts into zero. After the migration landed on chore/monorepo-migration, we had two feature branches still built against the old flat src/ structure: Both needed to land on the migrated branch. Both had files that overlapped with each other and with the base. This is where things got interesting. The marketplace was a separate React app that needed to live under a sub-path: example.com/marketplace. Same domain as the dashboard, different app. turbo dev starts both apps in parallel. Simple enough. The complexity is in sub-path hosting. Sub-path hosting means the marketplace is served at /marketplace instead of /. This requires five things to agree, and missing any one produces a blank white screen with no error message. This tells Vite to prefix all asset URLs with /marketplace/. Without it, the app tries to load main.js from / instead of /marketplace/main.js. Layer 2: React Router basename Without this, the router doesn't recognize /marketplace/some-page as a valid route. All navigation fails silently. During development, the dashboard runs on port 3002. When a request comes in for /marketplace/*, the proxy forwards it to the marketplace dev server on port 3003. Layer 4: The trailing slash This one cost us 2 hours. The browser requests /marketplace (no trailing slash). The proxy matches and forwards to port 3003. But the marketplace's Vite server expects /marketplace/ (with trailing slash) because that's its base. The HTML never loads. Blank screen. No error. The fix — a 12-line Vite plugin: One character — / — was the difference between a working app and a blank screen. Layer 5: Asset references in index.html Not /favicon.png. Not favicon.png. Must match the base path exactly. The favicon is the easy one to spot. The harder ones are Open Graph images, manifest files, and any hardcoded asset paths in your HTML. Sub-path apps fail silently. When something is misconfigured: You get a blank white screen and have to check all five layers manually. We now have a checklist: With the marketplace working on the monorepo branch, we needed to bring in the outreach feature from feat/automated-outreach — 110 files, all referencing src/ paths. Result: 45 merge conflicts. Both branches had independently run the migration script. Git saw every file that was moved from src/ to apps/dashboard/src/ as a conflict — because both branches created the same file at the same destination, even when the content was identical. Under time pressure, we resolved all 45 conflicts by bulk-checking out files from the marketplace branch: The app started. No conflict markers. Looked clean. git checkout <branch> -- <file> replaces the entire file with that branch's version. For shared UI components (button.jsx, card.jsx), this was fine — identical in both branches. But campaign/constants/index.js was different. The outreach branch had added: The marketplace branch had the older version without these exports. By checking out the marketplace version, we silently deleted the outreach code. No warning. No error at merge time. The imports compiled fine because the file existed — it just didn't export what the outreach components expected. We found similar silent deletions in: Total: 26 files with silently dropped code. All discovered only at runtime. Never bulk-resolve merge conflicts by checking out one side. It's the git equivalent of deleting files you haven't read. The correct approach depends on which branch owns each file: And after resolving, always diff against both parents: We scrapped the broken merge and started over. The approach that works: This gives us the clean monorepo structure — apps/dashboard/, packages/ui/, all the Vite config — without any feature code. Instead of cherry-picking commits (which carry merge history and conflict potential), we extract the final state of every changed file: Every file in the PR uses src/ paths. We need them under apps/dashboard/src/: This copies the final version of each file — all 18 commits squashed into the end result — and places it in the monorepo structure. The copied files still have old-style imports. Run the verification script: The outreach feature introduced three new packages: Result: zero conflicts. The outreach commit added and modified files in apps/dashboard/src/features/campaign/. The marketplace branch added apps/marketplace/ and dashboard proxy config. No overlapping files. The key insight: don't migrate the feature branch. Apply the feature's changes directly onto the already-migrated base. When you migrate two branches independently: When you apply features onto the migrated base: The second approach treats migration as infrastructure and features as content. Infrastructure is shared (one copy). Content is separate (no overlap). Throughout this process — cherry-picks, merges, file copies — old-style imports kept reappearing. Every time code from a pre-migration branch enters the monorepo, some files will have: We ran Step 24 of the migration script five times over the course of the integration. It's designed to be run repeatedly: Running it on already-fixed code is a no-op. Running it after a merge catches anything that slipped through. It's the cheapest safety net we have. Treat migration as a gate, not a one-time event. Until every pre-migration branch is merged, this gate needs to exist. Here's the process we now follow for integrating any pre-migration feature branch: A pre-migration CI check: If we'd added grep -r '~/components/ui/' apps/dashboard/src/ to CI, broken imports would fail the build instead of reaching dev. Firebase integration test: A simple test that calls getDatabase(app) would have caught the singleton issue immediately instead of us discovering it in the browser. Sub-path smoke test: A curl to localhost:3002/marketplace checking for a 200 with non-empty body would have caught the trailing slash issue in seconds. pnpm breaks singleton libraries. Add resolve.dedupe for any library that shares state between sub-packages. This is invisible until runtime. CSS tooling lives outside the module system. Use Vite aliases for shared packages that contain styles. exports won't help you. Sub-path apps fail silently. Five layers must agree. Build a checklist and test with hard refresh. Never bulk-resolve git conflicts. Read both sides. Diff against both parents after resolving. Migration is ongoing. Until every old-structure branch is merged, keep the import verification script and run it after every merge. Apply features onto the migrated base, not the other way around. This single decision turned 45 conflicts into zero. One command starts everything: Two apps running, shared components, Firebase working, all feature branches integrated. The migration touched 500+ files across five branches. The final commit history is clean. The process is documented. And we have a script that catches the next engineer's old-style imports before they reach production. That's the part the tutorials leave out — the migration isn't done when the script finishes. It's done when the last pre-migration branch merges and the safety net can finally be removed. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: # pnpm-workspace.yaml packages: - "apps/*" - "packages/*" Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # pnpm-workspace.yaml packages: - "apps/*" - "packages/*" COMMAND_BLOCK: # pnpm-workspace.yaml packages: - "apps/*" - "packages/*" COMMAND_BLOCK: apps/ ├── dashboard/ # localhost:3002 └── marketplace/ # localhost:3003, hosted at /marketplace Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: apps/ ├── dashboard/ # localhost:3002 └── marketplace/ # localhost:3003, hosted at /marketplace COMMAND_BLOCK: apps/ ├── dashboard/ # localhost:3002 └── marketplace/ # localhost:3003, hosted at /marketplace CODE_BLOCK: // apps/marketplace/package.json { "name": "@acme/marketplace", "scripts": { "dev": "vite --port 3003" } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // apps/marketplace/package.json { "name": "@acme/marketplace", "scripts": { "dev": "vite --port 3003" } } CODE_BLOCK: // apps/marketplace/package.json { "name": "@acme/marketplace", "scripts": { "dev": "vite --port 3003" } } CODE_BLOCK: // Root package.json { "scripts": { "dev": "turbo dev", "dev:dashboard": "pnpm --filter @acme/dashboard dev", "dev:marketplace": "pnpm --filter @acme/marketplace dev" } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Root package.json { "scripts": { "dev": "turbo dev", "dev:dashboard": "pnpm --filter @acme/dashboard dev", "dev:marketplace": "pnpm --filter @acme/marketplace dev" } } CODE_BLOCK: // Root package.json { "scripts": { "dev": "turbo dev", "dev:dashboard": "pnpm --filter @acme/dashboard dev", "dev:marketplace": "pnpm --filter @acme/marketplace dev" } } CODE_BLOCK: // apps/marketplace/vite.config.js export default defineConfig({ base: "/marketplace/", server: { port: 3003 }, }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // apps/marketplace/vite.config.js export default defineConfig({ base: "/marketplace/", server: { port: 3003 }, }); CODE_BLOCK: // apps/marketplace/vite.config.js export default defineConfig({ base: "/marketplace/", server: { port: 3003 }, }); CODE_BLOCK: // apps/marketplace/src/index.jsx <BrowserRouter basename="/marketplace"> <App /> </BrowserRouter> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // apps/marketplace/src/index.jsx <BrowserRouter basename="/marketplace"> <App /> </BrowserRouter> CODE_BLOCK: // apps/marketplace/src/index.jsx <BrowserRouter basename="/marketplace"> <App /> </BrowserRouter> CODE_BLOCK: // apps/dashboard/vite.config.js server: { proxy: { "/marketplace": { target: "http://localhost:3003", changeOrigin: true, }, }, } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // apps/dashboard/vite.config.js server: { proxy: { "/marketplace": { target: "http://localhost:3003", changeOrigin: true, }, }, } CODE_BLOCK: // apps/dashboard/vite.config.js server: { proxy: { "/marketplace": { target: "http://localhost:3003", changeOrigin: true, }, }, } COMMAND_BLOCK: function marketplaceRedirect() { return { name: "marketplace-redirect", configureServer(server) { server.middlewares.use((req, _res, next) => { if (req.url === "/marketplace") { req.url = "/marketplace/"; } next(); }); }, }; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: function marketplaceRedirect() { return { name: "marketplace-redirect", configureServer(server) { server.middlewares.use((req, _res, next) => { if (req.url === "/marketplace") { req.url = "/marketplace/"; } next(); }); }, }; } COMMAND_BLOCK: function marketplaceRedirect() { return { name: "marketplace-redirect", configureServer(server) { server.middlewares.use((req, _res, next) => { if (req.url === "/marketplace") { req.url = "/marketplace/"; } next(); }); }, }; } CODE_BLOCK: <!-- apps/marketplace/index.html --> <link rel="icon" href="/marketplace/favicon.png" /> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <!-- apps/marketplace/index.html --> <link rel="icon" href="/marketplace/favicon.png" /> CODE_BLOCK: <!-- apps/marketplace/index.html --> <link rel="icon" href="/marketplace/favicon.png" /> CODE_BLOCK: □ Vite base matches sub-path (with trailing slash) □ React Router basename matches sub-path (without trailing slash) □ Dashboard proxy rule exists for sub-path □ Trailing slash redirect plugin is active □ All index.html asset paths use the sub-path prefix □ Hard refresh works (not just client-side navigation) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: □ Vite base matches sub-path (with trailing slash) □ React Router basename matches sub-path (without trailing slash) □ Dashboard proxy rule exists for sub-path □ Trailing slash redirect plugin is active □ All index.html asset paths use the sub-path prefix □ Hard refresh works (not just client-side navigation) CODE_BLOCK: □ Vite base matches sub-path (with trailing slash) □ React Router basename matches sub-path (without trailing slash) □ Dashboard proxy rule exists for sub-path □ Trailing slash redirect plugin is active □ All index.html asset paths use the sub-path prefix □ Hard refresh works (not just client-side navigation) COMMAND_BLOCK: # "Quick" conflict resolution git checkout feat/marketplace-integration -- packages/ui/src/components/button.jsx git checkout feat/marketplace-integration -- packages/ui/src/components/card.jsx git checkout feat/marketplace-integration -- apps/dashboard/src/features/campaign/constants/index.js # ... 42 more files Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # "Quick" conflict resolution git checkout feat/marketplace-integration -- packages/ui/src/components/button.jsx git checkout feat/marketplace-integration -- packages/ui/src/components/card.jsx git checkout feat/marketplace-integration -- apps/dashboard/src/features/campaign/constants/index.js # ... 42 more files COMMAND_BLOCK: # "Quick" conflict resolution git checkout feat/marketplace-integration -- packages/ui/src/components/button.jsx git checkout feat/marketplace-integration -- packages/ui/src/components/card.jsx git checkout feat/marketplace-integration -- apps/dashboard/src/features/campaign/constants/index.js # ... 42 more files CODE_BLOCK: SyntaxError: does not provide an export named 'getOutreachStatus' Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: SyntaxError: does not provide an export named 'getOutreachStatus' CODE_BLOCK: SyntaxError: does not provide an export named 'getOutreachStatus' CODE_BLOCK: export const OUTREACH_AUTOMATION_STATUS = { NOT_STARTED: "not_started", ACTIVE: "active", PAUSED: "paused", }; export const OUTREACH_REACHOUT_STATUS = { yet_to_reachout: { label: "Yet to Reachout", color: "text-slate-500", }, reached_out_no_reply: { label: "Reached Out - No Reply", color: "text-amber-500", }, // ... }; export const getOutreachStatus = (status) => OUTREACH_REACHOUT_STATUS[status] || OUTREACH_REACHOUT_STATUS.yet_to_reachout; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: export const OUTREACH_AUTOMATION_STATUS = { NOT_STARTED: "not_started", ACTIVE: "active", PAUSED: "paused", }; export const OUTREACH_REACHOUT_STATUS = { yet_to_reachout: { label: "Yet to Reachout", color: "text-slate-500", }, reached_out_no_reply: { label: "Reached Out - No Reply", color: "text-amber-500", }, // ... }; export const getOutreachStatus = (status) => OUTREACH_REACHOUT_STATUS[status] || OUTREACH_REACHOUT_STATUS.yet_to_reachout; CODE_BLOCK: export const OUTREACH_AUTOMATION_STATUS = { NOT_STARTED: "not_started", ACTIVE: "active", PAUSED: "paused", }; export const OUTREACH_REACHOUT_STATUS = { yet_to_reachout: { label: "Yet to Reachout", color: "text-slate-500", }, reached_out_no_reply: { label: "Reached Out - No Reply", color: "text-amber-500", }, // ... }; export const getOutreachStatus = (status) => OUTREACH_REACHOUT_STATUS[status] || OUTREACH_REACHOUT_STATUS.yet_to_reachout; COMMAND_BLOCK: # Did we lose anything from the outreach branch? git diff HEAD -- apps/dashboard/src/features/campaign/ # Did we lose anything from the marketplace branch? git diff feat/marketplace-integration -- packages/ui/ Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # Did we lose anything from the outreach branch? git diff HEAD -- apps/dashboard/src/features/campaign/ # Did we lose anything from the marketplace branch? git diff feat/marketplace-integration -- packages/ui/ COMMAND_BLOCK: # Did we lose anything from the outreach branch? git diff HEAD -- apps/dashboard/src/features/campaign/ # Did we lose anything from the marketplace branch? git diff feat/marketplace-integration -- packages/ui/ COMMAND_BLOCK: git checkout -b feat/automated-outreach upstream/chore/monorepo-migration Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: git checkout -b feat/automated-outreach upstream/chore/monorepo-migration COMMAND_BLOCK: git checkout -b feat/automated-outreach upstream/chore/monorepo-migration COMMAND_BLOCK: # Fetch the PR git fetch upstream pull/XXXX/head:pr-ref # List all files the PR changed gh pr diff XXXX --name-only # → src/features/campaign/api/mutations/createProposalMutation.js # → src/features/campaign/api/mutations/index.js # → src/features/campaign/constants/index.js # → ... (110 files total) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # Fetch the PR git fetch upstream pull/XXXX/head:pr-ref # List all files the PR changed gh pr diff XXXX --name-only # → src/features/campaign/api/mutations/createProposalMutation.js # → src/features/campaign/api/mutations/index.js # → src/features/campaign/constants/index.js # → ... (110 files total) COMMAND_BLOCK: # Fetch the PR git fetch upstream pull/XXXX/head:pr-ref # List all files the PR changed gh pr diff XXXX --name-only # → src/features/campaign/api/mutations/createProposalMutation.js # → src/features/campaign/api/mutations/index.js # → src/features/campaign/constants/index.js # → ... (110 files total) COMMAND_BLOCK: gh pr diff XXXX --name-only | while read f; do [ "$f" = "package.json" ] && continue target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: gh pr diff XXXX --name-only | while read f; do [ "$f" = "package.json" ] && continue target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done COMMAND_BLOCK: gh pr diff XXXX --name-only | while read f; do [ "$f" = "package.json" ] && continue target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done COMMAND_BLOCK: find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) \ -exec sed -i '' \ 's|from "~/components/ui/\([^"]*\)"|from "@acme/ui/components/\1"|g' \ {} + # Revert 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' \ {} + Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) \ -exec sed -i '' \ 's|from "~/components/ui/\([^"]*\)"|from "@acme/ui/components/\1"|g' \ {} + # Revert 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' \ {} + COMMAND_BLOCK: find apps/dashboard/src -type f \( -name "*.jsx" -o -name "*.js" \) \ -exec sed -i '' \ 's|from "~/components/ui/\([^"]*\)"|from "@acme/ui/components/\1"|g' \ {} + # Revert 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' \ {} + CODE_BLOCK: pnpm add --filter @acme/dashboard react-markdown remove-markdown turndown Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: pnpm add --filter @acme/dashboard react-markdown remove-markdown turndown CODE_BLOCK: pnpm add --filter @acme/dashboard react-markdown remove-markdown turndown COMMAND_BLOCK: git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add automated outreach feature to monorepo" # Now merge the marketplace branch git merge feat/marketplace-integration --no-edit Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add automated outreach feature to monorepo" # Now merge the marketplace branch git merge feat/marketplace-integration --no-edit COMMAND_BLOCK: git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add automated outreach feature to monorepo" # Now merge the marketplace branch git merge feat/marketplace-integration --no-edit CODE_BLOCK: Base → Migration → Branch A (all files moved to apps/dashboard/) Base → Migration → Branch B (all files moved to apps/dashboard/) Merge A + B → 45 conflicts (same files created at same paths) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Base → Migration → Branch A (all files moved to apps/dashboard/) Base → Migration → Branch B (all files moved to apps/dashboard/) Merge A + B → 45 conflicts (same files created at same paths) CODE_BLOCK: Base → Migration → Branch A (all files moved to apps/dashboard/) Base → Migration → Branch B (all files moved to apps/dashboard/) Merge A + B → 45 conflicts (same files created at same paths) CODE_BLOCK: Base → Migration → Feature A applied → Feature B merged → 0 conflicts Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Base → Migration → Feature A applied → Feature B merged → 0 conflicts CODE_BLOCK: Base → Migration → Feature A applied → Feature B merged → 0 conflicts CODE_BLOCK: import { Button } from "~/components/ui/button"; // broken Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import { Button } from "~/components/ui/button"; // broken CODE_BLOCK: import { Button } from "~/components/ui/button"; // broken CODE_BLOCK: import { Button } from "@acme/ui/components/button"; // correct Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import { Button } from "@acme/ui/components/button"; // correct CODE_BLOCK: import { Button } from "@acme/ui/components/button"; // correct COMMAND_BLOCK: 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 # Fix and report fi Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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 # Fix and report fi COMMAND_BLOCK: 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 # Fix and report fi COMMAND_BLOCK: # 1. Start from the migrated base git checkout -b feat/my-feature upstream/chore/monorepo-migration # 2. Fetch and extract the feature PR git fetch upstream pull/XXXX/head:pr-ref gh pr diff XXXX --name-only | while read f; do target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done # 3. Fix imports # (run Step 24) # 4. Add new dependencies pnpm add --filter @acme/dashboard <new-packages> # 5. Commit git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add <feature> to monorepo" # 6. Merge other branches (zero conflicts expected) git merge feat/other-branch --no-edit # 7. Run Step 24 again (the merge may have brought old imports) # 8. Verify grep -r '~/components/ui/' apps/dashboard/src/ \ --include='*.jsx' --include='*.js' \ | grep -v 'async-select\|confirm-dialog' # Should return nothing Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # 1. Start from the migrated base git checkout -b feat/my-feature upstream/chore/monorepo-migration # 2. Fetch and extract the feature PR git fetch upstream pull/XXXX/head:pr-ref gh pr diff XXXX --name-only | while read f; do target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done # 3. Fix imports # (run Step 24) # 4. Add new dependencies pnpm add --filter @acme/dashboard <new-packages> # 5. Commit git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add <feature> to monorepo" # 6. Merge other branches (zero conflicts expected) git merge feat/other-branch --no-edit # 7. Run Step 24 again (the merge may have brought old imports) # 8. Verify grep -r '~/components/ui/' apps/dashboard/src/ \ --include='*.jsx' --include='*.js' \ | grep -v 'async-select\|confirm-dialog' # Should return nothing COMMAND_BLOCK: # 1. Start from the migrated base git checkout -b feat/my-feature upstream/chore/monorepo-migration # 2. Fetch and extract the feature PR git fetch upstream pull/XXXX/head:pr-ref gh pr diff XXXX --name-only | while read f; do target="apps/dashboard/$f" mkdir -p "$(dirname "$target")" git show pr-ref:"$f" > "$target" done # 3. Fix imports # (run Step 24) # 4. Add new dependencies pnpm add --filter @acme/dashboard <new-packages> # 5. Commit git add apps/dashboard/ pnpm-lock.yaml git commit -m "feat: add <feature> to monorepo" # 6. Merge other branches (zero conflicts expected) git merge feat/other-branch --no-edit # 7. Run Step 24 again (the merge may have brought old imports) # 8. Verify grep -r '~/components/ui/' apps/dashboard/src/ \ --include='*.jsx' --include='*.js' \ | grep -v 'async-select\|confirm-dialog' # Should return nothing COMMAND_BLOCK: my-app/ ├── apps/ │ ├── dashboard/ # @acme/dashboard (port 3002) │ └── marketplace/ # @acme/marketplace (port 3003, /marketplace) ├── packages/ │ └── ui/ # @acme/ui (shared shadcn/ui components) ├── scripts/ │ └── migrate-to-monorepo.sh ├── pnpm-workspace.yaml ├── turbo.json └── package.json Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: my-app/ ├── apps/ │ ├── dashboard/ # @acme/dashboard (port 3002) │ └── marketplace/ # @acme/marketplace (port 3003, /marketplace) ├── packages/ │ └── ui/ # @acme/ui (shared shadcn/ui components) ├── scripts/ │ └── migrate-to-monorepo.sh ├── pnpm-workspace.yaml ├── turbo.json └── package.json COMMAND_BLOCK: my-app/ ├── apps/ │ ├── dashboard/ # @acme/dashboard (port 3002) │ └── marketplace/ # @acme/marketplace (port 3003, /marketplace) ├── packages/ │ └── ui/ # @acme/ui (shared shadcn/ui components) ├── scripts/ │ └── migrate-to-monorepo.sh ├── pnpm-workspace.yaml ├── turbo.json └── package.json COMMAND_BLOCK: $ pnpm dev Enter fullscreen mode Exit fullscreen mode - feat/marketplace-integration — A new marketplace app with 14 commits, including its own (broken) monorepo migration attempt - feat/automated-outreach — A 110-file, 18-commit feature built entirely against src/ - No 404 page (the proxy catches everything) - No console error (the HTML loads but contains nothing useful) - No network error (requests succeed, they just return the wrong content) - Checked out feat/automated-outreach - Ran the migration script on it (moved files to apps/dashboard/src/) - Committed the migration - Merged feat/marketplace-integration into it - Store file — outreach pagination state gone - Hooks file — outreach board columns gone - Creator list component — column management drawer gone - Workflow labels — outreach-specific labels gone - A pre-migration CI check: If we'd added grep -r '~/components/ui/' apps/dashboard/src/ to CI, broken imports would fail the build instead of reaching dev. - Firebase integration test: A simple test that calls getDatabase(app) would have caught the singleton issue immediately instead of us discovering it in the browser. - Sub-path smoke test: A curl to localhost:3002/marketplace checking for a 200 with non-empty body would have caught the trailing slash issue in seconds. - pnpm breaks singleton libraries. Add resolve.dedupe for any library that shares state between sub-packages. This is invisible until runtime. - CSS tooling lives outside the module system. Use Vite aliases for shared packages that contain styles. exports won't help you. - Sub-path apps fail silently. Five layers must agree. Build a checklist and test with hard refresh. - Never bulk-resolve git conflicts. Read both sides. Diff against both parents after resolving. - Migration is ongoing. Until every old-structure branch is merged, keep the import verification script and run it after every merge. - Apply features onto the migrated base, not the other way around. This single decision turned 45 conflicts into zero.