I had one of those “we need to stop shipping everything from one giant repo” moments after watching a tiny change in our design tokens pipeline unexpectedly bump the version of every component in our system. The monolithic design system made iteration slow: teams hesitated to release, CI took forever, and a single mistake could ripple across multiple products. Splitting the system into smaller, focused repositories fixed those problems — but only after I learned to respect the boundaries between tokens, components, and utilities.

Why split at all?

If you’ve ever maintained a large design system, you know the pain points: long PRs, high blast radius for small changes, tangled dependency graphs, and releases that bundle unrelated changes. Splitting into multiple repositories helps in several concrete ways:

  • Isolate responsibilities so teams can own a focused surface area.
  • Speed up CI by running lightweight pipelines per repo.
  • Ship tokens and low-level utilities independently from UI components.
  • Make semantic versioning more meaningful — you can bump token versions without necessarily releasing a major component change.
  • My goal was to split a bloated repo into three independent repos that still allowed components to work without breaking consumers: Design Tokens, Core Components, and Component Utilities/Primitives.

    Deciding the split — the three-repo model

    Here’s the mental model I used to partition things. It’s simple and maps to real-world responsibilities.

    Repository Contents Owners / Consumers
    design-tokens Colors, spacing, typography scales, parsed tokens (JSON/CSS vars), token transforms Product teams, components, marketing sites
    core-components React/Vue components, Storybook stories, component tests Front-end apps and design system adopters
    primitives Low-level hooks, utility functions, layout primitives (Box, Stack), shared build tooling Components, application-level utilities

    There are other valid splits — e.g., splitting tokens further into platform-specific packages — but this triad gave us clear boundaries and minimal friction when consuming packages.

    Key principles I enforced up front

  • Make dependencies one-directional: tokens → primitives → components. Never the other way around.
  • Keep public APIs small and well-documented. Each repo exposes only what other repos need.
  • Use semantic versioning strictly — and automate changelogs and releases.
  • Preserve backwards compatibility for as long as practical. We adopted deprecation warnings before removing APIs.
  • Step-by-step migration plan I followed

    Breaking a repo is risky. I planned a multi-step migration that let each team adapt and rollback if needed.

  • Audit and categorize: I ran a dependency analysis (using tools like madge and dependency-cruiser) to see import edges. I created a map of which files depended on tokens, utilities, and components.
  • Extract tokens first: Tokens are the most foundational and the easiest to keep backward compatible. I created a new design-tokens repo, published tokens as both JSON and CSS variables, and released a matching package on npm (private registry).
  • Update components to consume tokens as a package: In the monorepo I replaced local token imports with package imports (e.g., @ourorg/design-tokens). I kept the old local token files for a transitional period and used deprecation logs to encourage migration.
  • Extract primitives: Once components consumed tokens from the package, I split out the Box/Stack/layout hooks into the primitives repo and published them as @ourorg/primitives.
  • Cut component repo: Finally, I moved all UI components into core-components and set it to depend on design-tokens and primitives.
  • CI and releases: I introduced independent CI pipelines, automated releases (semantic-release), and cross-repo integration tests.
  • Handling versioning and compatibility

    Semantic versioning became my friend. I defined clear rules for when to bump major, minor, or patch versions. Some practices that helped:

  • Tokens follow a conservative strategy: prefixed with 0.x until stable, then strict semver. Token semantics (like changing color meaning) required a major bump.
  • Primitives use feature flags and deprecation messages: when removing a hook or changing parameters, I deprecated first and removed in a later major.
  • Components declare peerDependencies on tokens/primitives with ranges. For example, [email protected] can depend on design-tokens@^1.5. This avoids forcing consumers to immediately upgrade everything.
  • Keeping components working during the split

    A major fear is breaking components consumed by apps. Here are tactics I used to keep things safe:

  • Alias imports in build tooling: During migration, we used module aliases so both local and package imports resolved correctly. For Webpack/Vite/Rollup this saved immediate breakages.
  • Maintain a compatibility layer: design-tokens exported a tiny compatibility module that re-exported legacy token names and formats for one major cycle.
  • Integration tests across repos: I added a small integration matrix in CI that installed the latest versions of tokens/primitives/components and ran a smoke test (rendering Storybook stories in Jest/Playwright).
  • Monorepo-style workspace locally: Developers used npm/yarn workspaces locally to develop across repos, so changes could be tested together before publishing.
  • CI, releases, and automation

    I automated as much as possible.

  • Independent CI pipelines: Each repo has its own pipeline for linting, unit tests, Storybook build, and package publishing.
  • Cross-repo integration step: A lightweight "system tests" pipeline runs when tokens or primitives publish new majors — it pulls the latest core-components and runs smoke tests. This catches breaking changes before they reach apps.
  • Automated releases: We used semantic-release with conventional commits to generate changelogs and publish npm packages automatically. For private registries, GitHub Actions handled auth and publishing.
  • Developer experience and documentation

    Splitting repos can create friction for contributors. I invested in DX to reduce that friction:

  • Set up clear CONTRIBUTING.md and templates for each repo describing how to run Storybook, run tests, and publish locally.
  • Maintained a central "system README" that shows how packages relate and how to run everything locally using workspace tools.
  • Added explicit versioning badges and compatibility tables in READMEs so consumers can see which versions work together.
  • Common pitfalls I ran into

    These are the mistakes I made (so you don't have to):

  • Underestimating implicit coupling: Some "utils" had implicit dependencies on tokens. I had to untangle these by introducing explicit APIs rather than relying on path hacks.
  • Publishing churn: Early on I published too many versions while fixing things. Using local workspace testing prevented needless publishes.
  • Forgetting to update docs: A change in token names broke marketing sites because docs and examples still referenced the old API. Keep examples in repo snapshots or a docs site with pinned dependencies.
  • When not to split

    Splitting isn't a silver bullet. If your system is small, teams are few, and releases are infrequent, the overhead of managing multiple repos might outweigh benefits. I only moved forward because our release cadence and team size demanded isolation.

    Splitting a design system into three focused repos allowed us to iterate faster, reduce blast radius, and make versioning meaningful without breaking components. The key is to plan migrations as reversible steps, automate tests and releases, and keep clear API contracts between repos. With careful coordination and tooling (workspaces, aliases, CI checks), you can enjoy modular ownership without sacrificing stability — and your teams will thank you for it.