•8 min read

Building a Modern TypeScript Monorepo

Building a Modern TypeScript Monorepo

The Evolution of the Monorepo

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 revolutionized 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.

In this guide, we will dive deep into building a modern TypeScript monorepo. We will skip the basic "Hello World" examples and explore the advanced techniques necessary for a robust, production-ready architecture. This guide goes beyond the basics to address real-world challenges encountered when deploying monolithic repositories at scale.

Audio Briefing
0:00 / 0:00

Why pnpm Workspaces?

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/*'
Advertisement

Enter Turborepo: High-Performance Build Systems

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.

Configuring turbo.json

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.

Advanced TypeScript Configuration

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).

The Base Configuration Strategy

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"]
}

Unified Linting and Formatting Strategy

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.

Advertisement

Internal Packages and Code Sharing

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.

To Build or Not to Build?

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.

Enforcing Boundaries and Tooling

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.

Conclusion

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.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement