Managing Monorepo Dependencies with Turborepo

Managing shared dependencies across monorepo packages requires a combination of native package manager workspaces and Turborepo’s task orchestration. While package managers like pnpm, Yarn, or npm handle dependency hoisting, version resolution, and linking, Turborepo optimizes the build pipeline by understanding dependency graphs and caching artifacts. This guide explains how development teams structure their workspaces, synchronize third-party libraries, handle internal packages, and leverage Turborepo to eliminate redundant work.

The Separation of Concerns: Workspaces vs. Turborepo

To manage shared dependencies effectively, teams must understand that Turborepo is not a package manager. It does not install packages, resolve dependency trees, or hoist node modules. Instead:

  • The Package Manager (pnpm, Yarn, npm): Responsible for installing external dependencies, hoisting shared packages to the root node_modules, and symlinking internal packages together via workspace protocols.
  • Turborepo: Reads the package dependency graph established by the package manager. It uses turbo.json to orchestrate build, lint, and test pipelines in the correct topological order and caches the outputs.

Structuring Internal Shared Packages

Teams typically organize internal shared code—such as UI libraries, ESLint configurations, TypeScript configs, and utility functions—into dedicated packages within a packages/ directory.

Internal packages are assigned their own package.json with a unique name (often scoped, like @repo/ui or @repo/utils). Consuming applications within the monorepo (such as @repo/web) declare internal packages in their dependencies using workspace protocols:

{
  "name": "@repo/web",
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*"
  }
}

The workspace:* protocol (supported by pnpm and Yarn) ensures that the consuming app always links to the local version of the code inside the monorepo, rather than looking for a published package on an external registry.

Enforcing Pipeline Order with Turborepo

When one package depends on another, Turborepo must build them in the correct sequence. Teams configure this relationship in the root turbo.json file using the dependsOn property with the ^ prefix:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    }
  }
}

The ^build task tells Turborepo that before running build for any package, it must first successfully run build for all internal dependencies of that package. Turborepo builds these dependencies in parallel whenever possible, caches the build artifacts, and skips rebuilding them in subsequent runs if the underlying files have not changed.

Synchronizing External Dependency Versions

A common challenge in monorepos is version drift, where different packages rely on conflicting versions of external packages, such as react or typescript. Teams resolve this using specific management strategies:

  1. Strict Root Catalogs (pnpm Catalogs): Modern tools like pnpm provide catalogs, allowing teams to define shared dependency versions once in a root configuration and reference them across child packages without hardcoding version strings.
  2. Linting Dependencies with Syncpack: Teams integrate tools like syncpack into their CI pipelines. Syncpack inspects all package.json files in the monorepo, alerts developers when mismatched versions exist, and automatically aligns them.
  3. Peer Dependencies for Shared UI: For internal design systems or shared UI libraries, teams define common runtimes (like react and react-dom) as peerDependencies. This avoids bundling duplicate runtime instances into client applications.

Just-in-Time (JIT) Compilation

To speed up internal dependency management, modern teams often skip the build step for internal packages altogether. Instead of compiling shared UI or utilities to a dist/ folder, internal packages export raw TypeScript source files directly through the package.json exports field:

{
  "name": "@repo/ui",
  "exports": {
    "./button": "./src/button.tsx"
  }
}

The consuming framework (such as Next.js or Vite) compiles the raw source files on demand during the application build. This eliminates the need for separate compile steps for internal packages, significantly reducing Turborepo pipeline complexity and local iteration times.