From four repos to a monorepo
Consolidating four byte-identical app repos into one shared core plus thin app shells, the expo-router and Metro problems it surfaced, and validating the migration with real bundles.
For most of this project, four separate repos with near-identical code was fine. Every shared change got copied to the other three, fast and simple. Then the copy-paste bit me: a stray whitespace difference in one file caused an automated patch to silently skip one app. That is the moment a workaround becomes a liability. Time for a monorepo.
What is actually shared
The first job was measuring, not guessing. Comparing the four apps, only five kinds of file differed per app: the color palette, the strings, the achievement titles, a level-types constant, and the 100 level JSONs. Everything else, 33 source files including all the screens, was byte-identical. About 95% shared.
So the target shape:
packages/core/src/ ← all shared code (store, services, components, data, screens)
apps/<name>/
src/app/ ← the route tree (more on this below)
src/constants/palette.ts, src/i18n/strings.ts,
src/data/achievements.ts, src/data/levelTypes.ts, src/data/levels/*.json
app.json, assets/, configs
The resolution trick
Shared code references per-app modules through the same @/ import alias used
everywhere. So @/constants/palette inside a shared screen needs to resolve to
the consuming app's palette. The solution is an overlay: @/ resolves to the
app's src first, then falls back to packages/core/src. TypeScript supports
this natively with array paths; Metro gets a small custom resolver that tries
app/src then core/src for any @/ import. Per-app assets resolve through a
separate @/assets/* alias pointing at each app's own assets.
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName.startsWith('@/') && !moduleName.startsWith('@/assets/')) {
const f = overlay(moduleName.slice(2)); // try app/src, then core/src
if (f) return { type: 'sourceFile', filePath: f };
}
return context.resolveRequest(context, moduleName, platform);
};The expo-router wrinkle
The one thing that cannot simply move into core is the route tree. Expo Router is
file-based; it scans each app's app/ directory to build routes. If the routes
live in core, the app finds none.
The fix is re-export stubs. Each app keeps the route file tree, but every route file is one line:
export { default } from '@/screens/Play';The real screen lives once in packages/core/src/screens. The app's stub
satisfies expo-router's file scan; the overlay resolves the import to core.
Navigation works because it is by route path, not by import, so moving the screen
body changes nothing about how router.push('/streak') behaves.
Validate with a bundle, not a vibe
This is the part I want to underline. Typecheck passing does not prove a Metro
setup works. Reanimated worklets, expo-router's require-context, the New
Architecture, and a custom resolver all interact at bundle time, not at type-check
time. So the gate was a real expo export: produce an actual JavaScript bundle.
The first attempt found two real issues a typecheck would never catch on its own: a shared file that imported levels with a relative path (which would have grabbed core's missing levels instead of the app's), and a couple of relative asset requires that broke when the screens moved. Once those were aliased, both tts90an and ttsgenz produced a clean 7.6 MB Android bundle with the right per-app assets. That is the proof the architecture holds.
Do it safely
The whole migration happened in a brand-new repository while the four originals stayed untouched. They are still what ships, still the backup. A botched refactor costs nothing live, which is exactly the safety net that lets you move confidently.
The lesson
Do not consolidate on reflex; consolidate when the duplication actually hurts and you have measured what is shared. Then respect the framework's constraints (expo-router wants files where it wants them), make the resolution explicit, and prove it with a real build artifact. A monorepo is not a virtue. It is a tool you reach for when copy-paste starts lying to you.
That is the series. One idea became four apps, four apps became a business with ads and subscriptions and a loyalty program, and four repos became one. If you are building small things and shipping them, I hope some of this saved you a few of the hours it cost me.