
Monorepos have historically been met with mixed emotions in the JavaScript and TypeScript communities. For a long time, the tooling simply wasn't adequate to handle the scale, complexity, and performance requirements of enterprise-grade applications. Tools like Lerna paved the way, but often left developers grappling with sluggish builds, painful package hoisting issues, and a steep learning curve that discouraged wider adoption.
Fast forward to today, and the landscape has completely shifted. The advent of pnpm workspaces combined with Turborepo has fundamentally upgraded how we structure and maintain modern TypeScript codebases. It is no longer just feasible to house dozens of applications and shared packages under a single Git repository; it is arguably the most efficient way to scale engineering efforts across multiple teams.
This architecture guide details how to build a production TypeScript monorepo, addressing the operational bottlenecks of large repositories: project references, incremental build caching, and multi-package publishing.
Before diving into Turborepo, we need a solid package manager. While npm and Yarn both support workspaces, pnpm has emerged as the clear winner for monorepos due to its unique approach to dependency resolution and disk space management.
Unlike traditional package managers that hoist all dependencies to a root node_modules folder (flattening the dependency tree), pnpm uses a content-addressable store and symlinks. This results in a strict node_modules structure where a package only has access to the dependencies it explicitly lists in its package.json.
This strictness is crucial in a monorepo. It prevents "phantom dependencies" - a notorious issue where package A accidentally relies on a transitive dependency installed by package B. When package B is removed or updated, package A breaks unexpectedly, often only discovered during continuous integration or deployment. With pnpm, phantom dependencies are practically eliminated, ensuring deterministic and reliable builds across all workspaces.
To enable pnpm workspaces, simply create a pnpm-workspace.yaml file at the root of your repository:
packages:
- 'apps/*'
- 'packages/*'
While pnpm manages dependencies, we need a build system to orchestrate tasks across the monorepo. This is where Turborepo shines. Turborepo is a high-performance build system written in Go (and Rust, in newer iterations) that utilizes intelligent caching and parallel execution to drastically reduce build times.
Turborepo understands the dependency graph of your workspace. If app-web depends on packages/ui and packages/utils, Turborepo knows it must build ui and utils before building app-web.
More importantly, it caches the output of these tasks. If you haven't changed packages/utils, Turborepo won't waste CPU cycles rebuilding it; it will instantly replay the cached output from a previous run, skipping the task entirely.
The heart of Turborepo is the turbo.json configuration file. Here is an example of an advanced, production-ready setup:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
Notice the ^build syntax. This tells Turborepo that the build task of a specific package depends on the build task of its dependencies. This simple syntax unlocks topological sorting, ensuring everything builds in the correct mathematical order based on the dependency graph.
One of the trickiest parts of a TypeScript monorepo is configuring tsconfig.json files correctly. We want to share common configurations to avoid drift, while allowing specific packages to override settings (e.g., a React app needs different compiler options than a Node.js CLI tool).
Create a central tsconfig package (e.g., packages/tsconfig) that exports foundational configurations. This prevents duplication and enforces consistency.
// packages/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
},
"exclude": ["node_modules"]
}
You can then provide variations like react-library.json or nextjs.json. In your actual apps and packages, you extend these base configs, keeping the local file clean and focused on overrides:
// apps/web/tsconfig.json
{
"extends": "@my-org/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
Maintaining consistent code style across multiple projects is a hallmark of a well-architected monorepo. Rather than maintaining disparate .eslintrc.js and .prettierrc files in every application and package, you can centralize these configurations just like the TypeScript settings.
Create a packages/eslint-config directory. Inside, define your standard rules, extending popular configurations like eslint-config-turbo or eslint-config-prettier. By exporting these rules as a package, other workspaces can simply extend them in their local configurations. This single-source-of-truth model prevents configuration drift and ensures that a linting fix applied in one package is enforced across the entire repository. Furthermore, by defining a lint script in the root turbo.json, you can execute linting tasks concurrently across all workspaces, drastically reducing the feedback loop during continuous integration.
A modern monorepo relies heavily on internal packages. Instead of duplicating utility functions or UI components across multiple applications, you extract them into dedicated workspaces like packages/utils or packages/ui.
With pnpm workspaces, you link these internally using the workspace:* protocol. In your apps/web/package.json:
{
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:*"
}
}
The workspace:* protocol ensures you are always using the local version of the package. When combined with Turborepo, changes in @my-org/ui will automatically trigger a rebuild of apps/web if necessary, all while respecting the cache. This creates a seamless developer experience where cross-package changes are instantly reflected.
A common architectural decision when setting up these internal packages is whether they should be pre-compiled (built to a dist/ directory) or imported directly as TypeScript source code into the consuming applications.
Approach 1: Pre-compiled Packages
This is the safest and most traditional route. Each package is responsible for building itself using a bundler or compiler like tsc, tsup, or vite. Consumers then import the transpiled .js and .d.ts files. This enforces strict boundaries and guarantees compatibility, but requires a dedicated build step for every internal package.
Approach 2: Source Import (Just-in-Time Compilation)
In this modern approach, application frameworks like Next.js or Vite (in your apps/ directory) are configured to transpile the TypeScript source code of your internal packages directly. This completely eliminates the build step for internal packages, drastically speeding up local development since there is no intermediate build process to wait for. Tools like next-transpile-modules (which is now built directly into Next.js versions 13 and above) make this workflow seamless.
While Source Import is incredibly fast for local development, Pre-compiled packages offer stronger encapsulation and are strictly necessary if you plan to ever publish the packages externally to a registry like npm. A hybrid approach is typically considered best practice: use source imports for internal-only shared code, and pre-compile any libraries intended for public consumption.
As the monorepo grows from tens to hundreds of packages, enforcing architectural boundaries becomes absolutely critical. You do not want the frontend web application directly importing sensitive database connection strings from the backend utilities package.
Tools like eslint-plugin-workspaces or specialized boundary-enforcement rules can ensure that dependencies only flow in a single, expected direction. Combine this strict enforcement with a robust CI/CD pipeline leveraging Turborepo's remote caching, and you have a powerhouse architecture.
Remote caching is perhaps Turborepo's most transformative feature. By sharing the Turborepo cache across your entire engineering team and your Continuous Integration environment, you ensure that if one person (or the CI server) has built a package, no one else ever has to build it again. The time savings at scale are astronomical, turning multi-minute builds into sub-second cache hits.
A monorepo uses `pnpm` workspaces. A developer adds a new utility function to `packages/utils` and updates `apps/web` to use it. When `apps/web` is built, `pnpm` throws an error indicating a missing dependency for a package that `packages/utils` explicitly lists in its `package.json`. What is the most likely cause of this issue?
Building a modern TypeScript monorepo is no longer a daunting task reserved only for giant tech companies with dedicated developer experience teams. By leveraging the strictness of pnpm workspaces and the blistering speed of Turborepo, teams of any size can architect codebases that are highly cohesive, infinitely scalable, and an absolute joy to develop in. Move beyond the simplistic tutorials, embrace these advanced architectural patterns, and watch your engineering velocity soar to new heights.
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

3x your Vitest monorepo unit test speed: vitest.workspace.ts config, eliminating barrel file bottlenecks, happy-dom vs jsdom, and CI worker thread pools.
Read more
Optimizing Turborepo CI pipelines: remote artifact caching on Google Cloud Storage, pruned Docker builds, and dropping build times by 78%.
Read more
Architecting durable distributed state machines in TypeScript with Temporal: activity retries, determinism constraints, and saga orchestration patterns.
Read more