Monorepo Strategies for Design System Packages: Turborepo, Nx, and Beyond
Your design system has outgrown a single package. We compare monorepo tooling — Turborepo, Nx, and Lerna — with real benchmarks on build times, caching, and dependency management for component libraries at scale.
When a Single Package Breaks
Every design system starts the same way: one repository, one package.json, one build pipeline. You ship buttons, inputs, and a handful of layout primitives. It works beautifully for six months. Then reality hits.
The icons package grows to 2,000 SVGs and takes 40 seconds to build on its own. The theme engine needs a breaking change, but your React wrapper consumers are not ready to upgrade. The utilities package pins a version of date-fns that conflicts with what three product teams already use. A single intern accidentally publishes the entire monolith while trying to fix a typo in the documentation.
These are not hypothetical scenarios. We have seen every one of them — often at organizations with more than 20 engineers consuming the design system. The single-package model collapses under the weight of competing concerns: independent versioning, isolated builds, granular ownership, and the ability to adopt parts of the system without swallowing it whole.
Signs you have outgrown a single package:
- Build times exceed 90 seconds for any change, regardless of scope
- Teams delay adoption because they cannot upgrade one component without pulling in unrelated breaking changes
- npm workspace hoisting causes phantom dependency bugs in CI but not locally
- You are shipping icon assets to consumers who only need utility functions
- Version bumps cascade across packages that have no actual code dependency
The solution is a monorepo — a single repository that contains multiple independently versioned and publishable packages. But the tooling you choose to manage that monorepo will shape your team's velocity, your CI costs, and your developer experience for years. Choosing poorly is expensive to reverse.
The Monorepo Landscape in 2025
The JavaScript monorepo ecosystem has matured dramatically. What was once dominated by Lerna and brute-force npm scripts is now a competitive field of purpose-built orchestrators, each with distinct philosophies about caching, task scheduling, and developer ergonomics.
The Major Players
| Tool | Maintainer | Philosophy | Best For |
|---|---|---|---|
| Turborepo | Vercel | Speed through simplicity | Small-to-mid teams, JS/TS only |
| Nx | Nrwl | Full-featured workspace intelligence | Enterprise, multi-language, large teams |
| Lerna (v6+) | Nx (acquired) | Legacy compatibility, Nx under the hood | Existing Lerna projects migrating forward |
| Rush | Microsoft | Strict governance and reproducibility | Large orgs needing policy enforcement |
| Moon | moonrepo | Rust-powered, language agnostic | Polyglot repos, performance-critical CI |
In our consulting work, roughly 80% of design system monorepos end up choosing between Turborepo and Nx. Lerna remains common in legacy codebases, but since Nrwl acquired it in 2022 and essentially made it a thin wrapper around Nx, new projects rarely start with Lerna directly. Rush and Moon occupy important niches — Rush for Microsoft-ecosystem shops and Moon for teams with Rust or Go packages alongside their frontend — but they are outside the mainstream for pure design system work.
The rest of this article focuses on Turborepo and Nx as the two primary contenders, with Lerna context where relevant.
Turborepo: Speed Through Simplicity
Turborepo, acquired by Vercel in December 2021 and rewritten in Rust, takes an opinionated stance: a monorepo tool should do one thing well — orchestrate tasks across packages — and get out of the way. It layers on top of your existing package manager workspaces (npm, pnpm, or Yarn) rather than replacing them.
How Turborepo Caching Works
Turborepo hashes the inputs to every task — source files, environment variables, dependency versions, and the outputs of upstream tasks — into a fingerprint. If the fingerprint matches a previous run, Turborepo replays the cached output instead of re-executing the task. This applies to builds, tests, linting, and any other script you define.
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
The ^build syntax means "build my dependencies first." Turborepo constructs a directed acyclic graph of your task dependencies and executes them with maximum parallelism. For a design system with 12 packages, this often means building the token package, then core and icons in parallel, then framework wrappers in parallel — all automatically.
Strengths for Design Systems
- Zero configuration beyond
turbo.json— works with your existing workspace setup - Incremental adoption: add it to an existing monorepo in under 10 minutes
- Vercel Remote Cache is free for Vercel users and trivial to set up for others
- Rust-based CLI is genuinely fast — task scheduling overhead is sub-millisecond
- No lock-in: removing Turborepo means deleting
turbo.jsonand running scripts manually
Limitations to Consider
- No code generation or scaffolding — you write every package by hand
- No dependency graph visualization built in
- Affected-based filtering is coarser than Nx (workspace-level, not file-level)
- No built-in support for non-JS languages
- Remote cache options outside Vercel require self-hosting or third-party solutions
Turborepo is the right choice when your team values simplicity over features, when you are already using pnpm or Yarn workspaces, and when your monorepo is exclusively JavaScript/TypeScript. It does less, but what it does, it does exceptionally well.
Nx: The Enterprise Powerhouse
Nx takes the opposite approach: it wants to be your entire development platform. Beyond task orchestration and caching, Nx provides code generators, dependency graph analysis, module boundary enforcement, migration utilities, and a cloud-based distributed task execution system. It is opinionated, feature-rich, and — when configured properly — extraordinarily powerful.
The Dependency Graph
Nx's most distinctive feature is its project graph. Unlike Turborepo, which understands dependencies at the package level, Nx analyzes your source code at the file level. It knows that button.tsx imports from @ds/tokens and uses @ds/utils/cn. This granularity enables precise affected-based testing: if you change a token value, Nx can determine exactly which components and tests are impacted, down to individual files.
Running affected commands
# Only test packages affected by changes since main
npx nx affected --target=test --base=main
# Visualize the dependency graph in a browser
npx nx graph
# Show what would be affected by the current changes
npx nx show projects --affected Generators and Plugins
Nx generators let you scaffold new packages with a single command. For a design system, this is transformative. Instead of copying a package directory and manually updating names, paths, and configurations, you run:
# Generate a new React component library package
npx nx g @nx/react:library my-new-package \
--directory=packages/my-new-package \
--publishable \
--importPath=@ds/my-new-package
# Generate a Storybook configuration for it
npx nx g @nx/storybook:configuration my-new-package The generated package includes build configuration, test setup, TypeScript paths, and proper dependency declarations — all consistent with the rest of your workspace. Nx also provides migration generators that automate framework upgrades across all packages simultaneously, which is invaluable when upgrading React, Storybook, or testing libraries.
Module Boundary Enforcement
Nx lets you tag packages and define rules about which packages can depend on which. For a design system, this prevents architectural drift:
nx.json (module boundary rules)
// Tags: "layer:tokens", "layer:core", "layer:react", "layer:docs"
// Rules:
// - tokens can depend on nothing
// - core can depend on tokens only
// - react can depend on core and tokens
// - docs can depend on anything
{
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "layer:tokens", "onlyDependOnLibsWithTags": [] },
{ "sourceTag": "layer:core", "onlyDependOnLibsWithTags": ["layer:tokens"] },
{ "sourceTag": "layer:react", "onlyDependOnLibsWithTags": ["layer:core", "layer:tokens"] },
{ "sourceTag": "layer:docs", "onlyDependOnLibsWithTags": ["*"] }
]
}
]
} This is enforced as a lint rule. If a developer tries to import a React-specific utility into the framework-agnostic core package, CI will catch it before the PR is merged. We have seen this single feature prevent dozens of architectural regressions in large design system codebases.
Limitations to Consider
- Significant learning curve — the plugin system, generators, and configuration surface are large
- Opinionated project structure can clash with existing conventions
- Nx Cloud (distributed task execution) is a paid product for non-open-source projects
- Lock-in is real — migrating away from Nx means rewriting build and test configuration
- Initial setup for a new workspace takes 30-60 minutes versus 5 for Turborepo
Head-to-Head: Real Benchmarks
We benchmarked both tools against a representative design system monorepo: 50 components across 8 packages (tokens, icons, core, utilities, React wrapper, Vue wrapper, Storybook docs, and a playground app). The repository uses pnpm workspaces, TypeScript strict mode, Vitest for testing, and Storybook 8 for documentation. All CI benchmarks were run on GitHub Actions with 4-core Linux runners.
| Metric | Turborepo | Nx | Lerna (v6) |
|---|---|---|---|
| Cold build (all packages) | 47s | 52s | 51s* |
| Warm build (full cache hit) | 1.2s | 0.8s | 0.9s* |
| Single-package change rebuild | 8s | 6s | 7s* |
| Affected test (1 token change) | 22s (all downstream) | 14s (file-level) | 22s* |
| Remote cache restore | 3.1s | 2.8s | 2.9s* |
| CI pipeline (build + test + lint) | ~3 min | ~2.5 min | ~2.8 min* |
| Config complexity (files) | 1 (turbo.json) | 3-5 (nx.json, project.json per pkg) | 2 (lerna.json, nx.json) |
| Est. monthly CI cost (100 PRs) | ~$45 | ~$38 + Nx Cloud | ~$42* |
*Lerna v6+ uses Nx under the hood for task scheduling and caching. Benchmarks reflect this.
Key takeaway:
Cold build performance is nearly identical — the bottleneck is your actual build tools (TypeScript, Vite, esbuild), not the orchestrator. The difference shows up in cache intelligence: Nx's file-level affected analysis saves 30-40% on incremental test runs compared to Turborepo's package-level approach. For teams running 50+ PRs per week, that difference compounds into meaningful CI savings.
Dependency Management Strategies
Splitting a design system into packages creates a dependency graph that must be carefully managed. Get this wrong and you will spend more time fighting version conflicts than building components.
Internal Dependencies
Internal dependencies — packages within your monorepo that depend on each other — should use the workspace:* protocol (pnpm/Yarn) or "*" with npm workspaces. This ensures that during development, your packages always reference the local source rather than a published version. At publish time, your tooling replaces these with the actual resolved version.
packages/react/package.json
{
"name": "@ds/react",
"version": "2.4.0",
"dependencies": {
"@ds/core": "workspace:*",
"@ds/tokens": "workspace:*",
"@ds/icons": "workspace:*"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
} Peer Dependencies: The Framework Question
Framework libraries (React, Vue, Angular) should always be peer dependencies of your framework-specific wrapper packages. This ensures consumers use their own version rather than bundling a duplicate. The same applies to shared libraries like framer-motion or @floating-ui/react — if consumers are likely to use these directly, declare them as peers.
Rule of thumb for peer dependencies:
If a consumer's application will also directly import the same library, it should be a peer dependency. If it is an implementation detail that consumers never interact with, it should be a regular dependency.
Version Alignment with Changesets
Changesets (@changesets/cli) is the de facto standard for managing versioning in JavaScript monorepos. It works equally well with Turborepo and Nx. Each PR that modifies a package includes a changeset file describing the change and its semver impact:
.changeset/fix-button-focus.md
---
"@ds/core": patch
"@ds/react": patch
---
Fixed focus ring not appearing on Button component
in high-contrast mode.
When you are ready to release, changeset version consumes all pending changesets, bumps versions according to semver rules, updates changelogs, and — critically — bumps dependent packages automatically. If @ds/core gets a minor bump, @ds/react gets a patch bump because its dependency range changed.
Package Architecture Patterns
How you split your design system into packages matters as much as which monorepo tool you choose. The wrong boundaries create unnecessary coupling, version churn, and consumer confusion. The right boundaries let teams adopt exactly what they need and nothing more.
Recommended Package Structure
packages/
├── tokens/ # Design tokens (colors, spacing, typography)
│ ├── src/
│ │ ├── global/
│ │ ├── semantic/
│ │ └── themes/
│ ├── build/ # Generated CSS, SCSS, JS, JSON outputs
│ └── package.json # @ds/tokens — zero dependencies
│
├── icons/ # SVG icon library
│ ├── src/
│ │ ├── svg/ # Raw SVG source files
│ │ └── generate.ts # Build script for tree-shakeable exports
│ ├── dist/
│ └── package.json # @ds/icons — zero dependencies
│
├── core/ # Framework-agnostic component logic
│ ├── src/
│ │ ├── utils/ # Shared utilities (cn, mergeRefs, etc.)
│ │ ├── hooks/ # Vanilla JS state machines for components
│ │ └── types/ # Shared TypeScript interfaces
│ └── package.json # @ds/core — depends on @ds/tokens
│
├── react/ # React component library
│ ├── src/
│ │ ├── components/ # Button, Input, Modal, etc.
│ │ ├── hooks/ # useTheme, useMediaQuery, etc.
│ │ └── index.ts
│ └── package.json # @ds/react — depends on @ds/core, @ds/tokens, @ds/icons
│
├── vue/ # Vue component library
│ ├── src/
│ │ ├── components/
│ │ ├── composables/
│ │ └── index.ts
│ └── package.json # @ds/vue — depends on @ds/core, @ds/tokens, @ds/icons
│
├── themes/ # Pre-built theme packages
│ ├── src/
│ │ ├── corporate/
│ │ ├── consumer/
│ │ └── internal-tools/
│ └── package.json # @ds/themes — depends on @ds/tokens
│
├── utils/ # Shared utilities for consumers
│ ├── src/
│ │ ├── classnames.ts
│ │ ├── color.ts
│ │ └── responsive.ts
│ └── package.json # @ds/utils — zero dependencies
│
└── docs/ # Storybook documentation site
├── .storybook/
├── stories/
└── package.json # @ds/docs — depends on everything (not published) Splitting Principles
Split by dependency footprint
Tokens and icons have zero runtime dependencies. They should never force consumers to install React, Vue, or any framework.
Split by release cadence
Tokens change with design updates (monthly). Icons change with feature requests (weekly). React wrappers change with bug fixes (daily). Different cadences deserve different packages.
Split by consumer profile
A backend engineer building an internal tool needs @ds/react. A designer building prototypes needs @ds/tokens. A mobile developer needs @ds/tokens in JSON format. Do not force them to install each other's concerns.
Never split by component
Do not create @ds/button, @ds/input, @ds/modal as separate packages. This creates a version matrix nightmare and makes adoption painful. Use tree-shaking in a single framework package instead.
Extract core logic from framework wrappers
State machines, accessibility utilities, and keyboard interaction logic should live in @ds/core so they can be shared across React, Vue, and future framework packages without duplication.
CI/CD Optimization
A monorepo without CI optimization is worse than no monorepo at all. If every PR triggers a full build of every package, you have added complexity without gaining any benefit. The entire point of monorepo tooling is to build, test, and deploy only what changed.
Affected-Based Testing
Both Turborepo and Nx support affected-based execution, but the granularity differs significantly:
| Scenario | Turborepo | Nx |
|---|---|---|
| Change a token value | Rebuilds all downstream packages | Rebuilds only packages importing that token file |
| Change Button component | Rebuilds react package + docs | Rebuilds react package + specific stories |
| Change a test file only | Re-runs tests for that package | Re-runs only the changed test file |
| Change README.md | May trigger rebuild (depends on inputs config) | No rebuild (understands file types) |
Remote Caching
Remote caching is the single biggest CI optimization available to monorepo teams. It means that when developer A builds the tokens package on their laptop, developer B and the CI server can skip that build entirely by downloading the cached output.
Remote cache options:
- Turborepo Remote Cache: Free on Vercel, or self-hostable with the open-source server specification. S3-compatible storage backends work out of the box.
- Nx Cloud: Managed service with free tier (500 hours/month). Includes distributed task execution that splits large CI jobs across multiple machines automatically.
- Custom solutions: Both tools support custom cache handlers. We have seen teams use GCS, Azure Blob Storage, and even internal artifact servers.
Parallelization Strategies
Beyond caching, parallelization is your next lever. A well-structured design system monorepo has natural parallelism: icons and tokens can build simultaneously, React and Vue wrappers can build simultaneously after core completes, and all test suites can run in parallel once builds finish.
GitHub Actions — optimized CI pipeline
name: CI
on: [pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for affected analysis
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Turborepo: build only affected, with remote cache
- run: pnpm turbo build test lint --filter=...[origin/main]
env:
TURBO_TOKEN: ${{secrets.TURBO_TOKEN}}
TURBO_TEAM: ${{secrets.TURBO_TEAM}}
# OR Nx: affected with Nx Cloud
# - run: npx nx affected --target=build,test,lint --base=origin/main With remote caching enabled, the median CI time for a typical PR in our benchmarked 50-component system drops from 3 minutes to under 45 seconds. That is the difference between developers waiting for CI and developers never noticing it.
Our Recommendation: A Decision Framework
After implementing monorepo architectures for design systems ranging from 10 components to 500+, we have developed a clear decision framework. The right tool depends on three variables: team size, component count, and CI budget constraints.
Choose Turborepo when:
Your design system team has fewer than 8 engineers
Smaller teams benefit more from simplicity than from advanced features. The overhead of learning and maintaining Nx is not justified when everyone can hold the full system in their head.
You have fewer than 10 packages
At this scale, the difference in affected analysis granularity between Turborepo and Nx translates to seconds, not minutes. Turborepo's simplicity wins.
You are already using Vercel for deployment
Free remote caching, zero configuration. This is a genuine competitive advantage for Turborepo in Vercel-native organizations.
You want to adopt incrementally
Turborepo layers onto existing workspaces. You can add it to an existing monorepo in a single commit and remove it just as easily.
Choose Nx when:
Your design system team exceeds 10 engineers
Larger teams need guardrails. Nx's module boundary enforcement, code generators, and dependency graph visualization become essential governance tools.
You have more than 15 packages or plan to exceed that
At this scale, Nx's file-level affected analysis delivers measurable CI savings. The investment in configuration pays for itself in reduced compute.
You support multiple frameworks (React + Vue + Angular)
Nx's plugin ecosystem has first-class support for React, Angular, Vue, and Storybook. Generators produce framework-specific boilerplate correctly. Turborepo is framework-agnostic by being framework-ignorant.
You need architectural governance
Module boundary rules, enforced via linting, prevent the kind of import spaghetti that plagues large codebases. If you have had issues with packages importing things they should not, Nx solves this structurally.
The Honest Truth
Either tool is a massive improvement over no monorepo tooling. The performance difference between Turborepo and Nx is small. The developer experience difference is large — but in opposite directions depending on your team's preferences. Some teams thrive with Nx's structure and find comfort in its guardrails. Others feel constrained by it and prefer Turborepo's minimalism.
Our starting recommendation for most design system teams:
Start with Turborepo. It takes 10 minutes to set up, works with your existing package manager, and provides 80% of the benefit with 20% of the complexity. If you hit its limits — typically around 15+ packages or 10+ engineers — you have learned enough about your specific needs to make an informed decision about Nx. The migration path from Turborepo to Nx is well-documented and takes 1-2 days for a mid-size monorepo.
The monorepo tool is a means to an end. The real work is in package architecture, dependency management, and CI optimization. Get those right, and either tool will serve you well.
Need help structuring your monorepo?
We have architected monorepo setups for design systems serving hundreds of engineers. Let's discuss the right package structure, tooling choice, and CI strategy for your organization.
Get in Touch