Monorepo Design System: Shared Packages, Storybook, Publishing
Set up a production-ready monorepo design system with Turborepo, shared packages, Storybook docs, and npm publishing — from workspace config to first release.
Why Bother With a Monorepo at All?
Short answer: you've probably felt the pain already. You maintain three separate apps, each with its own Button component, its own color tokens, its own slightly-diverged Tailwind config. Something changes in the design — a new brand blue, a border-radius update — and suddenly you're opening PRs across four repositories and praying they all ship at the same time. That's the problem a design-system monorepo solves.
Honestly, the monorepo model clicked for most teams around 2022 when Turborepo 1.0 dropped and made caching actually work at scale. Before that, the tooling overhead was real enough to question whether it was worth it. Now? You get shared packages, a single pnpm install, and build caches that mean you only rebuild what changed. The tradeoff is setup complexity upfront — but you pay that once, not on every cross-repo sync.
The core idea is simple: one Git repository holds multiple packages. A packages/ui directory exports your React components. A packages/tokens directory exports your design tokens as JavaScript objects (and maybe a CSS file). A packages/tailwind-config exports a shared Tailwind preset. Your apps — in apps/web, apps/docs, whatever — all consume those shared packages like ordinary npm dependencies. Changes propagate instantly in development.
Worth noting: this isn't just for large teams. Even a two-person studio shipping a SaaS product and a marketing site benefits enormously. The moment you have more than one frontend that shares a visual language, you want this.
Workspace Setup: pnpm + Turborepo
Start from scratch with pnpm workspaces — they're the cleanest option in 2026 and Turborepo's official docs assume them. Create your root pnpm-workspace.yaml first:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'Then your root package.json declares the dev-only dependencies and your turbo pipeline. Add turbo as a dev dependency and initialize with pnpm dlx turbo init. Your turbo.json is where you define task dependencies — this is what lets Turborepo figure out that build in packages/ui must run before build in apps/web:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"storybook": {
"cache": false,
"persistent": true
}
}
}The ^build syntax means "run build in all dependencies of this package first." That single caret saves you from a whole category of race-condition bugs where your app tries to import from a package that hasn't been compiled yet. Run everything with pnpm turbo dev from the root and Turborepo figures out the rest.
One more thing — lock your Node version. Put a .nvmrc or an engines field in your root package.json with something like "node": ">=20.0.0". Nothing wastes more debugging time than a CI server running Node 18 while your local machine runs Node 22.
Building the Shared UI Package
Your packages/ui directory is the heart of the whole setup. Keep its package.json simple — it's a library, not an app, so it has no bundler config initially. Name it with your org scope: @yourorg/ui. The exports field is what makes it work cleanly in modern Node and bundlers:
{
"name": "@yourorg/ui",
"version": "0.1.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"." : {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}In development, you point straight at the TypeScript source and let the consuming app's bundler (Next.js, Vite) handle the compilation. This means zero build step while iterating — edit a component in packages/ui/src, see it update in apps/web instantly via HMR. That's the real productivity win. You only need a separate build step when you're publishing to npm.
Structure your component exports as a flat barrel file at first: packages/ui/src/index.ts re-exports everything. As you grow past maybe 40 components, switch to path-based exports (@yourorg/ui/button, @yourorg/ui/card) to help tree-shaking. For now, one index.ts is fine and much less friction.
In practice, you'll want to co-locate your design tokens in a separate packages/tokens package rather than baking them into packages/ui. That way a non-React consumer (a Vue app, a native mobile team, a Figma plugin) can import the tokens without pulling in React. Export them as plain JS objects and as CSS custom properties — both formats are useful and the duplication is trivial.
Adding Storybook for the Docs App
Storybook lives in apps/storybook (or apps/docs if you prefer). It's an app that imports from @yourorg/ui just like your production app does. This is the right mental model — Storybook is a consumer of your design system, not part of it. Initialize it with pnpm dlx storybook@latest init inside that directory.
Your Storybook setup needs to understand TypeScript and, if you're using Tailwind, it needs to know about your shared Tailwind config. The .storybook/main.ts should reference a Vite config that includes the @yourorg/tailwind-config preset. Don't copy-paste Tailwind configs between packages — that's exactly the kind of drift you're trying to avoid:
// packages/tailwind-config/index.ts
import type { Config } from 'tailwindcss';
const config: Omit<Config, 'content'> = {
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
500: '#0ea5e9',
900: '#0c4a6e',
},
},
borderRadius: {
'4xl': '2rem', // 32px, our large card radius
},
},
},
plugins: [],
};
export default config;Write stories that test your components in isolation AND in composition. A Button story that renders all 12 variants on one page is useful for visual regression testing. A Card story that nests your Button, Avatar, and Badge components together checks that your 8px spacing grid holds up in real combinations. That second type of story is what catches the bugs that unit tests miss.
Quick aside: enable Storybook's autodocs feature by setting tags: ['autodocs'] in your component stories. It auto-generates an API reference page from your TypeScript props with zero additional documentation work. Pair that with the @storybook/addon-a11y accessibility checker and you've got a genuinely useful docs site without writing a single MDX file. If you want inspiration for what a polished component library looks like, browse Empire UI — the component catalogue there is a good benchmark for what "done" feels like.
Versioning and Changesets
This is where most monorepo setups fall apart. You need a way to version packages independently (your tokens package might be at 2.0.0 while ui is at 0.8.1) while still tying related changes together in a changelog. The answer is Changesets — install it at the workspace root with pnpm add -D @changesets/cli.
The workflow is: when you make a change, run pnpm changeset. It asks you which packages changed, whether it's a patch/minor/major bump, and what the changelog entry should say. This creates a .changeset/some-random-name.md file that you commit alongside your code changes. In CI, the changesets/action GitHub Action turns those files into version bumps and a PR. Merge the PR to trigger the actual npm publish.
# Developer workflow
pnpm changeset # describe what changed
git add .changeset/
git commit -m "chore: add changeset for button variant update"
git push
# CI picks up the changeset file, creates a Version PR
# Merge the Version PR → packages publish to npm automaticallyLook, there are other approaches — lerna, semantic-release, manual versioning. Changesets wins for design systems specifically because it makes changelog authoring a normal part of the PR process rather than an afterthought. Your consumers will actually know what changed and why. That matters when you're asking them to upgrade.
One gotcha: if you have a @yourorg/ui that depends on @yourorg/tokens, and you bump tokens, Changesets can automatically version-bump ui as well. Enable this with "linked" groups in your .changeset/config.json. Without it, you'll have the classic problem where ui@1.2.0 is published but still declares a peer dep on the old tokens@1.0.0.
Publishing to npm With tsup
Pointing at TypeScript source works great in development, but npm consumers need compiled JavaScript. That means adding a build step to packages/ui before publishing. tsup is the right tool in 2026 — it wraps esbuild, handles ESM + CJS dual output, generates .d.ts files, and requires almost no config.
// packages/ui/tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
});Update your package.json to point at the dist/ output for npm consumers while keeping the src/ pointer for local workspace development. The trick is the exports field with separate conditions:
"exports": {
"." : {
"development": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist", "src"]The "development" condition is resolved by bundlers like Vite and Next.js when NODE_ENV=development — so your local workspace and Storybook still get the raw TypeScript source with instant HMR, while npm install @yourorg/ui in a real consumer project gets the pre-compiled output. That's the best of both worlds.
That said, don't forget to add a prepublishOnly script that runs your build: "prepublishOnly": "pnpm build". Changesets will call this automatically before publishing. Publish once in dry-run mode (pnpm changeset publish --dry-run) to make sure the output looks right before going live. You can also look at how Empire UI structures its own component library for reference on what a well-organised design system export looks like.
CI Pipeline and Caching
Your GitHub Actions workflow doesn't need to be fancy. The key is letting Turborepo's remote cache work in CI so you're not rebuilding packages that haven't changed on every PR. Turborepo Cloud is free for small teams; alternatively you can self-host the cache with a simple S3 bucket and the --api flag.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build lint
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}With remote caching enabled, a PR that only touches apps/web won't rebuild packages/ui or packages/tokens — Turborepo fetches the cached artifacts from the last time those packages built successfully. On a mid-sized design system this saves 3-4 minutes per CI run. That compounds fast.
Add a separate workflow for Storybook deployment — push to main, build Storybook, deploy to Vercel or Cloudflare Pages. This gives your design team a live URL for every version of the component library without any manual steps. Pair it with Chromatic (Storybook's visual regression service) if you want automatic screenshots on every PR. Knowing whether a CSS change broke 14 components visually — before merge — is worth the ~$50/month.
FAQ
Either works. Turborepo has a gentler learning curve and its config is a single JSON file. Nx has more built-in generators and is better if you need fine-grained per-project caching rules. For a design system monorepo, Turborepo is usually the faster path.
Start with one package — it's dramatically simpler to version and consume. Split into per-component packages only if you're shipping to external teams who need to install a single button without pulling in 200 other components.
Export a shared Tailwind preset from a packages/tailwind-config package and reference it in each app's tailwind.config.ts via presets. Never copy the theme config — that defeats the point of a monorepo.
Yes. Add your packages/ui to the transpilePackages array in next.config.js so Next.js compiles the TypeScript source directly. You get full HMR and no separate build step in development.