One repo, every app

Web, mobile, API, and shared packages in a single monorepo. Here's how I run it with Turborepo, and why one repo beats a folder full of them.

PPrabir Singh·
·6 min read
Navy, charcoal and white shipping containers stacked in a modular grid.

A founder once showed me three repos on a call. Web, mobile, backend. Each with its own copy of the same User type, each slightly out of date, each a small lie waiting to break in production. Nobody wrote it that way on purpose. It grew there, one Friday deploy at a time, the way weeds grow through a footpath.

I run the opposite. One repo. Everything that ships the product lives in the same tree, and the parts that matter, the types and the logic, get written once.

What actually lives in the tree

The layout is boring on purpose. Boring is what you want at 2am when something is on fire and you need to find it fast.

PathWhat it isBuilt with
apps/webThe marketing site and dashboardNext.js or TanStack Start
apps/mobileiOS and Android from one codebaseExpo
apps/apiThe server the other two talk toHono
packages/typesShared types and validationTypeScript, Zod
packages/dbSchema and queriesDrizzle, Postgres
packages/uiShared components and tokensReact, Tailwind
packages/configESLint, TS, Tailwind presetsplain config

Apps consume. Packages provide. The web app never reaches into the mobile app, and neither of them talks to Postgres directly. They go through packages/db. That one rule keeps the whole thing from turning back into spaghetti.

The shared types package is the whole point

Everything else is nice. This is the reason.

I keep one package that owns the shapes the product runs on. The API imports it to validate requests. The web client imports it to type its fetch calls. The Expo app imports the exact same file. There is no fourth copy drifting off in a corner.

// packages/types/src/user.ts
import { z } from "zod";
 
export const userSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1),
  plan: z.enum(["free", "pro"]),
});
 
export type User = z.infer<typeof userSchema>;

Now watch what happens when the server changes. I rename plan to tier, or I add a required field. The moment I save that file, TypeScript lights up red in apps/web and apps/api and apps/mobile at the same time. Not in staging. Not from a bug report three weeks later filed by someone's confused user. On my screen, before I have even opened a browser.

That is the feeling I chase. The compiler doing the nagging so I do not have to remember. It is the same reason I am a die-hard about the language in the first place, which I ranted about properly over in TypeScript or nothing.

Why one repo beats three

Three repos means three deploys to change one field. It means a pull request in the backend that cannot be verified until the frontend PR lands, which cannot be verified until someone bumps a package version, which nobody wants to do on a Friday. The change is small. The ceremony is enormous.

One repo collapses that. The type change, the API handler, the web form, and the mobile screen move together in a single pull request. You read the whole change in one diff. You ship it in one go.

Where Turborepo earns its keep

A monorepo without a task runner gets slow. You do not want to rebuild the mobile app because you edited a comment in the marketing site. Turborepo caches every task by its inputs, so if nothing that feeds a build changed, it replays the old result in milliseconds instead of doing the work again.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {},
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

That ^build says build my dependencies first. So when packages/types changes, everything downstream rebuilds. When it does not, nothing does. Turborepo figures out the graph. I do not sit there guessing. Their docs cover remote caching too, which shares that hit list across your machine and CI, and it is worth the afternoon to set up once.

Be honest, you might not need this yet

One tiny app does not need a monorepo. A single Next.js site with a couple of routes and a database is fine on its own. Reaching for Turborepo there is like buying a forklift to carry one grocery bag. You feel very serious and you have solved nothing.

The math flips the second you have two things that share code. A web app and a mobile app. An API and a client. A dashboard and a public site pulling from the same design tokens. The day the client says "can we add an app version," the shared package is already sitting there, typed and tested, and later takes days instead of a rewrite.

Frequently asked questions

Does a small team need a monorepo?

Team size is not the trigger. Shared code is. A solo founder shipping web plus mobile gets more out of one repo than a ten-person team maintaining a single site does. If two of your apps keep copying the same types back and forth, you already need it, whatever the headcount.

When should I not use one?

Skip it when you have exactly one app and no plans for a second, or when your services are owned by separate teams that deploy on their own clocks and never share a type. A monorepo assumes shared ownership and shared cadence. Force it onto teams that do not have those and you have bought coordination pain for no payoff.

Does a monorepo slow down my builds?

Naively, yes. Everything in one place tempts every tool to look at everything. Turborepo is the fix. It scopes each task to what actually changed and caches the rest, so a repo with five apps often builds faster than five separate repos that each cold-start their own pipeline.

How do I keep it from becoming a mess?

Draw the line between apps and packages and never let an app import another app. Apps sit at the top and consume. Packages sit below and provide. Keep that arrow pointing one direction and the repo stays legible for years.

The version that survives a rewrite

Most codebases rot because the truth lives in four places and only one of them gets updated. A monorepo with a shared types package puts the truth in one place and makes the compiler defend it. You move faster because you delete a whole category of bug before it exists, and the founder who wanted an app "later" gets it in a week. I set this up on day one for anyone building more than a single screen. If that is you, tell me what you are building, and if you want the full stack I reach for, it is all written down on my stack page. One repo, every app, one source of truth.

ShareXLinkedIn
P

Prabir Singh

Full-stack engineer from Mumbai. Twenty years in sales, now I build products that sell. More about me or start a project.

Keep reading