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.
The Problem: Three Branches, One Migration
After the migration landed on chore/monorepo-migration, we had two feature branches still built against the old flat src/ structure:
-
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 againstsrc/
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.
Adding a Second App to the Monorepo
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.
The workspace setup
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
apps/
├── dashboard/ # localhost:3002
└── marketplace/ # localhost:3003, hosted at /marketplace
// apps/marketplace/package.json
{
"name": "@acme/marketplace",
"scripts": {
"dev": "vite --port 3003"
}
}
// Root package.json
{
"scripts": {
"dev": "turbo dev",
"dev:dashboard": "pnpm --filter @acme/dashboard dev",
"dev:marketplace": "pnpm --filter @acme/marketplace dev"
}
}
turbo dev starts both apps in parallel. Simple enough. The complexity is in sub-path hosting.
The five layers of sub-path configuration
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.
Layer 1: Vite base
// apps/marketplace/vite.config.js
export default defineConfig({
base: "/marketplace/",
server: { port: 3003 },
});
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
// apps/marketplace/src/index.jsx
<BrowserRouter basename="/marketplace">
<App />
</BrowserRouter>
Without this, the router doesn't recognize /marketplace/some-page as a valid route. All navigation fails silently.
Layer 3: Dev proxy
// apps/dashboard/vite.config.js
server: {
proxy: {
"/marketplace": {
target: "http://localhost:3003",
changeOrigin: true,
},
},
}
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:
function marketplaceRedirect() {
return {
name: "marketplace-redirect",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url === "/marketplace") {
req.url = "/marketplace/";
}
next();
});
},
};
}
One character — / — was the difference between a working app and a blank screen.
Layer 5: Asset references in index.html
<!-- apps/marketplace/index.html -->
<link rel="icon" href="/marketplace/favicon.png" />
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.
The debugging principle
Sub-path apps fail silently. When something is misconfigured:
- 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)
You get a blank white screen and have to check all five layers manually. We now have a checklist:
□ 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)
Integrating Feature Branches: The Wrong Way
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.
What we tried first
- Checked out
feat/automated-outreach - Ran the migration script on it (moved files to
apps/dashboard/src/) - Committed the migration
- Merged
feat/marketplace-integrationinto it
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.
The resolution that silently broke everything
Under time pressure, we resolved all 45 conflicts by bulk-checking out files from the marketplace branch:
# "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
The app started. No conflict markers. Looked clean.
Then at runtime:
SyntaxError: does not provide an export named 'getOutreachStatus'
What actually happened
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:
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;
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:
- 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
Total: 26 files with silently dropped code. All discovered only at runtime.
The lesson
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:
| Conflict type | Resolution |
|---|---|
| File exists only in branch A | Take A's version |
| File exists only in branch B | Take B's version |
| File modified in both branches | Read both versions, merge manually |
And after resolving, always diff against both parents:
# 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/
Integrating Feature Branches: The Right Way
We scrapped the broken merge and started over. The approach that works:
Step 1: Start from the migrated base
git checkout -b feat/automated-outreach upstream/chore/monorepo-migration
This gives us the clean monorepo structure — apps/dashboard/, packages/ui/, all the Vite config — without any feature code.
Step 2: Get the feature PR's final file state
Instead of cherry-picking commits (which carry merge history and conflict potential), we extract the final state of every changed file:
# 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)
Step 3: Copy with path mapping
Every file in the PR uses src/ paths. We need them under apps/dashboard/src/:
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
This copies the final version of each file — all 18 commits squashed into the end result — and places it in the monorepo structure.
Step 4: Fix imports
The copied files still have old-style imports. Run the verification script:
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' \
{} +
Step 5: Add new dependencies
The outreach feature introduced three new packages:
pnpm add --filter @acme/dashboard react-markdown remove-markdown turndown
Step 6: Commit and merge
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
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.
Why this works
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:
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)
When you apply features onto the migrated base:
Base → Migration → Feature A applied → Feature B merged → 0 conflicts
The second approach treats migration as infrastructure and features as content. Infrastructure is shared (one copy). Content is separate (no overlap).
The Import Safety Net
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:
import { Button } from "~/components/ui/button"; // broken
Instead of:
import { Button } from "@acme/ui/components/button"; // correct
We ran Step 24 of the migration script five times over the course of the integration. It's designed to be run repeatedly:
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
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.
The Complete Workflow
Here's the process we now follow for integrating any pre-migration feature branch:
# 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
What We Learned
Things that broke silently
| Issue | Symptom | Time to diagnose |
|---|---|---|
CSS imports ignoring exports
|
ENOENT on shared stylesheet | 30 min |
| Firebase singleton under pnpm | "Service database is not available" | 4 hours |
| Trailing slash in sub-path | Blank white screen, no errors | 2 hours |
git checkout --theirs dropping code |
Missing exports at runtime | 3 hours |
| Old imports after merge | Vite import analysis failure | 10 min (once we had Step 24) |
Things that could have been caught earlier
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/marketplacechecking for a 200 with non-empty body would have caught the trailing slash issue in seconds.
The production-level principles
pnpm breaks singleton libraries. Add
resolve.dedupefor 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.
exportswon'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.
Final State
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
One command starts everything:
$ pnpm dev
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.
Top comments (0)