VERSION: 1.0.0 · SEP 2026 · COMPLEXITY: INTERMEDIATE

One repo, five apps, twenty-nine packages

How a single repository holds a multi-tenant SaaS: five apps, twenty-nine packages, and the deployment lessons.

One repo, five apps, twenty-nine packages
⬡
Source as the single truth

Packages export raw TypeScript. The Next.js apps compile them through transpilePackages, so there is no dist/ folder to keep in sync.

⟳
Each deploy gets only what it needs

The API and worker are bundled with esbuild into one file, and each Vercel and Railway service builds just its own piece of the repo.

This article covers the technical decisions behind building this system. Each section explores a different layer of the architecture.

In short: Bizph is a business platform with twelve connected products behind one login. It lives in a single repository: five apps and twenty-nine shared packages, about 184,000 lines of TypeScript including tests. The key decisions were sharing code as raw TypeScript with no per-package build step, bundling the Node services with esbuild, and giving each deployed piece only the files it needs.

The shape of the repo

REPO.TREE
apps/
  site      marketing site (Next.js)
  web       the tenant workspace (Next.js)
  admin     the operator console (Next.js)
  api       GraphQL API (Node)
  worker    background jobs (Node)
packages/
  core  db  auth  config  ui  graphql  storage  observability  ...
  crm  invoice  cashflow  hr  payroll  schedule  tasks
  inventory  events  files  dispatch  reservations  pos  ...

Every product is its own package, with HR split across two (hr and payroll). Shared concerns such as identity, authentication, database access, the design system and logging each live in their own package too, while permissions and entitlements sit in core. The tooling is pnpm workspaces for dependencies and Turborepo for running tasks in the right order.

Why one repository

The products aren't independent. They share people, companies, employees and permissions, and they react to each other's events: a recorded invoice payment ends up in Cashflow. With separate repos, I would have been publishing and versioning internal packages before a single customer existed. In one repo, a change to a shared package and every app that uses it lands in one commit and is checked together.

One rule keeps that from turning into a tangle: a product never reads another product's private tables. Products are meant to talk only through shared identity services, explicit service contracts, and events. In practice that is mostly outbox events, plus a couple of documented direct dependencies.

No build step per package

Twenty-nine packages, each with its own build, would have been mostly ceremony. Instead, the packages export their raw TypeScript source. The Next.js apps compile those packages themselves through transpilePackages, and types are checked with tsc --noEmit. The source is the single truth, and the shared packages have no dist/ folder to keep in sync.

The catch: plain Node can't run TypeScript

Next.js can transpile raw TypeScript, but node dist/index.js can't. The API and the worker are therefore bundled with esbuild into one file that inlines the whole internal package graph. Two kinds of dependency stay outside the bundle:

→Native modules, such as sharp, which re-encodes uploaded organization logos. They ship compiled binaries that can't be bundled, so they are installed normally.
→A library with genuinely dynamic requires: the logger, Pino. It broke the bundle until I marked it external.

A small trap I'd warn others about

For real Node to resolve relative imports, the API, the worker and the shared packages need explicit .js extensions, even though the files on disk are .ts. TypeScript understands that convention. But webpack, which Next.js uses for production builds, took the specifiers literally and couldn't find the files. The fix was one setting in the apps that import the shared packages, webpack's extensionAlias, plus a comment explaining why it exists so nobody deletes it later.

One config, validated

All five apps read one shared root .env.local, loaded by dotenv-cli in their dev scripts (the Next.js apps use it for build and start as well). The values pass through schemas that separate browser-safe variables from server-only secrets, and with NODE_ENV=production the API and the worker refuse to start if a required one is missing. On the hosting platforms, that file doesn't exist. dotenv-cli skips a missing file quietly and the real environment variables take over, which I checked, so the same commands run in development and in production.

Deploying pieces of one repo

→Vercel, for the three Next.js apps. Each is its own project pointing at its own folder. Turborepo, run from that folder, builds only that app. I confirmed it with a dry run: for the marketing site, exactly one task had a real command, and its dependencies had nothing to build.
→Railway, for the API and the worker. These are two services, and this is where I almost went wrong. The dashboard suggested setting the service's root directory to the app's own folder. That would have broken the build, because the bundle pulls code from packages/, and a service limited to apps/api can't see it. The Root Directory stays empty, with custom build and start commands pointing at the right app, and watch paths so a change to the marketing site doesn't rebuild the API.
→The worker has no health check on purpose. It has no web server, so a health check would never get an answer and the deploy would be marked failed. The API's check, by contrast, verifies the database: my first deploy failed exactly because I hadn't set the database connection yet, and that failure was the check doing its job.

The tradeoffs

→Raw-TypeScript packages mean every new consumer must know how to compile them: the Next apps use transpilePackages, and Node needs a bundle. A new kind of app costs setup time.
→Twenty-nine packages is a lot of surface. The rules about boundaries only help if they're written down and followed.
→A monorepo makes everything easy to change together, which is also how a careless change reaches every app at once. The test suite carries a lot of weight here: about 376 test files (254 unit and 122 integration, the integration ones running against a real PostgreSQL) plus 40 end-to-end specs.

What I'd tell my past self

1.Decide early how shared code gets built. It affects every app.
2.Give each deployed thing the smallest set of files it needs, and know which ones need the whole repo.
3.Write down the odd fixes as you make them. In six months you won't remember why.

Bizph is live at bizph.app and pre-launch, with no paying customers yet. I built it solo, with AI assistance.

#monorepo#typescript#nextjs#pnpm#turborepo#deployment
← PREV_LOGNEXT_LOG →