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
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:
sharp, which re-encodes uploaded organization logos. They ship compiled binaries that can't be bundled, so they are installed normally.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
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 tradeoffs
transpilePackages, and Node needs a bundle. A new kind of app costs setup time.What I'd tell my past self
Bizph is live at bizph.app and pre-launch, with no paying customers yet. I built it solo, with AI assistance.