Monorepo Dependency Management: Lessons from Running a Turborepo at Scale
Monorepos solve the code sharing problem: multiple packages in one repository, shared utilities, consistent tooling. But they create a new problem: dependency management across packages becomes significantly more complex than in a single-package project.
After running a Turborepo monorepo with six internal packages for a year, here are the problems I encountered and the patterns that solved them.
The Dependency Problem in Monorepos
In a single-package project: one package.json, one node_modules, one lockfile. Dependencies are straightforward.
In a monorepo: each package has its own package.json, but they share a lockfile. Packages can depend on each other (internal dependencies) and on external npm packages. The questions multiply:
Do all packages use the same version of React? What if package A needs React 18 and package B needs React 19? Does each package have its own node_modules, or do they share? If shared, what happens when two packages need different versions of the same dependency? How do you update a dependency across all packages simultaneously?
Hoisting: How It Works and When It Breaks
Package managers "hoist" dependencies: instead of installing the same package in multiple node_modules directories, they install it once at the root and packages resolve it from there.
This saves disk space and installation time. But it creates problems:
Phantom dependencies: a package uses a dependency that is not in its own package.json but is hoisted from another package's dependencies. It works locally but breaks when the package is published or used independently.
Version conflicts: package A depends on [email protected] and package B depends on [email protected]. The hoisted version is one of them. The other package gets the "wrong" version silently.
Our Package Manager Choice: pnpm
We use pnpm with its workspace protocol. pnpm's strict approach: each package has its own isolated node_modules (via symlinks). Packages cannot access dependencies not declared in their package.json.
This eliminates phantom dependencies: if package A uses lodash without declaring it, pnpm throws an error. You must explicitly declare every dependency in the package that uses it.
The cost: more explicit dependency declarations (you cannot rely on hoisting). The benefit: each package is self-contained and works correctly when consumed independently.
Internal Package Dependencies
Our monorepo has internal packages that depend on each other. The packages/tools package is consumed by the main src/ application. The packages/ui package provides shared components.
Declaring internal dependencies: use the workspace protocol. In the consumer's package.json: "@devai/tools": "workspace:*". This tells pnpm to resolve @devai/tools from the workspace, not from npm.
For TypeScript: configure project references in tsconfig.json. This ensures the TypeScript compiler understands the dependency graph and compiles packages in the correct order.
Versioning Strategy: Unified vs Independent
Unified versioning: all packages share one version number. When any package changes, all versions bump. Simpler to manage, but forces version bumps for unchanged packages.
Independent versioning: each package has its own version. Only changed packages bump. More accurate but requires dependency coordination — when package A updates, consumers of A must update their dependency version.
Our choice: unified versioning for internal packages (they are always consumed together) and independent versioning for published packages (external consumers need stable versions).
The Dependency Update Workflow
Updating external dependencies across a monorepo:
The tool that helps: Renovate Bot (automated dependency update PRs). Configure it with group rules: group all ESLint-related packages into one PR, group all React-related packages into another. This reduces PR noise while keeping dependencies current.
Handling Version Conflicts
When two packages need different versions of the same dependency: pnpm allows both versions to coexist (each package gets the version it declared). But this increases bundle size (two copies of the library) and can cause issues with singletons (two React instances on the same page).
Our policy: align on one version for major shared dependencies (React, TypeScript, Tailwind). Allow version variation for utility packages (lodash, date-fns) only when a major version migration is in progress.
Enforcement: use a tool like syncpack to detect version inconsistencies across packages and enforce a single version where configured.
Build Order and Caching
Turborepo handles build order automatically: it reads the dependency graph and builds packages in the correct topological order. Packages with no dependencies build first. Dependent packages build after their dependencies complete.
Caching: Turborepo caches build outputs. If a package has not changed since the last build: skip it, use the cached output. This dramatically speeds up builds in large monorepos (only changed packages and their dependents rebuild).
Remote caching: share the build cache across team members and CI. Developer A builds package X. Developer B pulls from the same branch and gets the cached output without rebuilding. This saves significant CI time for large monorepos.
TypeScript Configuration
Our tsconfig strategy: a base tsconfig.base.json at the root (shared compiler options), each package extends the base and adds its own paths and references, and project references ensure correct compilation order.
Path aliases: configure @devai/tools, @devai/ui, etc. in the base tsconfig. IDEs resolve imports correctly. Build tools resolve modules correctly. No import path confusion.
Common Mistakes
Mistake 1: circular dependencies between packages. Package A imports from B, B imports from A. This works sometimes but creates: unpredictable initialization order, build tool confusion, and bundler issues. Solution: extract shared code into a third package that both A and B depend on.
Mistake 2: not declaring all dependencies explicitly. Relying on hoisting for dependency resolution. Works locally, fails in CI or when packages are consumed externally. Solution: use pnpm's strict mode or eslint-plugin-import to detect undeclared dependencies.
Mistake 3: over-abstracting into too many packages. Every shared utility does not need its own package. Too many packages mean: more configuration overhead, more build graph complexity, and slower DX. Start with fewer, larger packages and split only when there is a clear boundary.
When NOT to Use a Monorepo
Monorepos add complexity. They are worth it when: packages share significant code, packages are developed by the same team, and deployment/versioning coordination is needed.
They are NOT worth it when: packages are independent (different teams, different deployment cycles), the shared code is minimal (could be an npm package), or the team is small enough that coordination is easy without infrastructure.
Our monorepo exists because: our tools package is consumed by the main app and benefits from co-development. Changes to tools immediately propagate to the app without publish/install cycles. For this use case, the monorepo complexity is worth the development speed.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.