Monorepo Architecture: Managing Large-Scale JavaScript Codebases

Using Turborepo, Nx, and pnpm workspaces to organize shared code, speed up builds, and coordinate releases across multiple packages.
The monorepo pattern—storing multiple related projects in a single version-controlled repository—has become the dominant architecture for organizations with multiple JavaScript/TypeScript projects. Sharing code between a web app, mobile app, and API server without the overhead of publishing and versioning private npm packages is genuinely compelling. But monorepos introduce build complexity, dependency management challenges, and tooling requirements that require deliberate investment. ## Why Monorepo The core problem monorepos solve is code sharing without publishing friction. In a polyrepo (separate repositories) setup, shared code (TypeScript types, utility functions, validation schemas, UI components) must be published as npm packages and versioned independently. A one-line change to a shared type requires a package publish, version bump, and dependency update across consuming packages. This friction accumulates and teams often duplicate code rather than deal with it. In a monorepo, shared code lives in a `packages/` directory and is imported directly. TypeScript project references provide type checking across package boundaries. Changes to shared code are immediately reflected in all consumers—and TypeScript catches breaking changes at compile time. ## Repository Structure ``` apps/ web/ # Next.js web application mobile/ # React Native or Flutter app admin/ # Admin dashboard packages/ ui/ # Shared component library types/ # Shared TypeScript types utils/ # Shared utility functions api-client/ # Type-safe API client shared between web and mobile config/ # Shared ESLint, TypeScript, and build configs tooling/ eslint-config/ typescript-config/ ``` ## Package Managers: pnpm Workspaces pnpm is the recommended package manager for monorepos due to its strict, content-addressable node_modules structure and excellent workspace support. pnpm uses hard links to share packages across the repository, dramatically reducing disk usage compared to npm or yarn workspaces. Configure workspaces in `pnpm-workspace.yaml`: ```yaml packages: - 'apps/*' - 'packages/*' - 'tooling/*' ``` Inter-package dependencies reference local packages with `workspace:*`: ```json { "dependencies": { "@forgeora/ui": "workspace:*", "@forgeora/types": "workspace:*" } } ``` ## Build Orchestration: Turborepo In a naive monorepo setup, running `build` builds every package sequentially even when most haven't changed. Turborepo adds intelligent build orchestration: **Task Pipelines**: Define dependencies between tasks. The `web` app's `build` task must wait for `ui`'s `build` to complete: ```json { "pipeline": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "test": { "dependsOn": ["^build"] }, "lint": {} } } ``` **Remote Caching**: Turborepo can cache task outputs in a remote cache (Vercel Remote Cache or self-hosted). When a colleague runs `turbo build` and gets a cache hit, they download the cached output in seconds rather than rebuilding from scratch. This is transformative for CI: a CI run that took 15 minutes might complete in 2 minutes with cache hits. **Affected Package Detection**: `turbo run test --filter=...[HEAD^1]` runs tests only in packages that have changed since the last commit. For large monorepos, this can reduce CI time by 80%+. ## Nx: Monorepo with Generators and Plugins Nx is a more opinionated monorepo framework that adds: - **Generators**: Scaffold new apps, libraries, and components with `nx generate` - **Affected commands**: `nx affected:test` runs tests only in changed packages - **Dependency graph**: Visual exploration of the dependency graph (`nx graph`) - **Plugin ecosystem**: First-class plugins for Next.js, React Native, NestJS, and more Nx is better suited for large organizations that want convention-over-configuration and opinionated project structure. Turborepo is better for teams that want minimal configuration and just need fast builds. ## TypeScript Project References TypeScript project references enable type-checking across package boundaries and incremental compilation: ```json // apps/web/tsconfig.json { "references": [ { "path": "../../packages/ui" }, { "path": "../../packages/types" } ] } ``` ```json // packages/ui/tsconfig.json { "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true } } ``` TypeScript caches compiled outputs per package and only recompiles packages that have changed, significantly reducing type-check times. ## Versioning and Publishing For internal packages consumed only within the monorepo, versioning is unnecessary—use `workspace:*`. For packages published to npm (open source or shared across organizations), use Changesets for version management and automated changelog generation: 1. Developers add a changeset describing their change with `npx changeset` 2. Changesets CI action accumulates changesets into a "Version Packages" PR 3. Merging the PR automatically bumps versions and publishes to npm ## Common Pitfalls **Circular dependencies**: Package A imports from Package B, which imports from Package A. Circular dependencies break TypeScript project references and bundlers. Use `madge` or `dependency-cruiser` to detect and visualize circular dependencies in CI. **Dependency version conflicts**: Different packages requiring different versions of the same dependency can cause subtle runtime bugs. Use `syncpack` to detect version mismatches and `pnpm`'s strict mode to surface hoisting issues. **IDE performance**: Large monorepos can overwhelm TypeScript Language Server. Use per-package `tsconfig.json` files rather than a single root config, and configure VS Code to use workspace TypeScript versions.
