All posts
3 min readBy Anggamonorepo / Expo / Metro / refactor / architecture

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.