EmpireUI
Get Pro
← Blog7 min read#turborepo#monorepo#react

Turborepo UI Monorepo: Shared Components Across Multiple Apps

Set up a Turborepo monorepo with a shared React + Tailwind component library. Share UI across web, docs, and admin apps without duplicating code.

Multiple connected server rack modules representing a distributed monorepo architecture

Why a Monorepo for UI Components Actually Makes Sense

Honestly, the first time you copy-paste a Button component from your marketing site into your admin dashboard, you've already lost. That's the moment your codebase starts to drift. Two weeks later the button has a different border-radius in each app. A month later they don't even look like the same product.

Turborepo solves this by letting you keep one packages/ui directory and consume it from every app in your workspace. Changes land in one place. Every app gets them on the next build. No manual syncing, no npm publish dance for an internal package.

It's not magic and it's not instant. There's a real setup cost. But for any project with more than two Next.js apps — say a public site, a docs site, and an admin — it pays off within the first week. Let's walk through exactly how to wire it up.

Initializing the Turborepo Workspace

Start with npx create-turbo@2.3.1 my-design-system. The CLI will ask which package manager you want. Pick pnpm — workspace hoisting is cleaner with it and the lockfile stays sane across packages. After init you'll have apps/web, apps/docs, and packages/ui scaffolded out.

The root turbo.json defines your pipeline. By default it chains build → outputs cached. You'll also want a lint and type-check task in there early so CI stays fast.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}

One thing people miss: "^build" means *dependencies first*. So packages/ui always builds before any app that consumes it. Without the caret the order is undefined and you'll see mysterious import errors in CI that don't reproduce locally.

Structuring the Shared packages/ui Package

Your packages/ui needs its own package.json with a name that apps can reference. Use a scoped name like @acme/ui. The key fields are main, module, and exports — get these right or TypeScript will struggle to resolve types.

// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.1",
  "private": true,
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "tsup": "^8.3.0",
    "typescript": "^5.6.2"
  }
}

Use tsup for bundling. It handles ESM and CJS outputs in one shot and generates .d.ts declarations automatically. No webpack config, no vite config, no pain. The --watch flag in dev mode means your component changes hot-reload into consuming apps immediately.

Tailwind v4 Across Multiple Apps — the Right Way

Here's the thing: Tailwind CSS v4.0.2 changed how configuration works. Instead of a tailwind.config.js file per app, configuration lives in your CSS file via @theme and @import. This actually simplifies monorepo setups — you can share a base CSS file from packages/ui/src/styles/base.css and import it in each app.

/* packages/ui/src/styles/base.css */
@import "tailwindcss";

@theme {
  --color-brand-500: oklch(62% 0.25 270);
  --color-brand-600: oklch(55% 0.27 270);
  --radius-card: 12px;
  --shadow-card: 0 4px 24px rgba(0,0,0,0.12);
  --spacing-section: 64px;
}

Each consuming app then does @import "@acme/ui/styles/base" at the top of its root CSS. The design tokens cascade. Change --color-brand-500 once and every app picks it up on next build. If you're building visual styles with things like glassmorphism effects, you can define the token once — rgba(255,255,255,0.15) backdrop tint and blur(12px) — and reference it everywhere.

One gotcha: Tailwind v4 scans for class names using its content detection. In a monorepo each app's Tailwind process only scans files it can see. You need to make sure your app's content paths include the packages/ui/src/** directory, otherwise classes used only in shared components get purged in production builds.

Consuming the Shared Package in a Next.js App

In each app's package.json, add the shared package as a workspace dependency: "@acme/ui": "workspace:*". Run pnpm install at the root and pnpm handles the symlink automatically. Now you can import like any npm package.

// apps/web/app/page.tsx
import { Button, Card, Badge } from "@acme/ui";
import type { ButtonProps } from "@acme/ui";

export default function HomePage() {
  return (
    <main className="p-8 flex flex-col gap-6">
      <Card className="rounded-[12px] p-6 shadow-card">
        <Badge variant="outline">New</Badge>
        <h1 className="text-2xl font-bold mt-4">Welcome</h1>
        <Button variant="primary" size="md">
          Get started
        </Button>
      </Card>
    </main>
  );
}

That rounded-[12px] matches the --radius-card token we set in base.css. You can also apply it as rounded-card if you wire up the CSS variable to the Tailwind theme. Keeping the 12px value in one place means when design decides it should be 8px — and they always do — you change it once.

Want a consistent dark-mode toggle across all apps? Define the data-theme switching logic in packages/ui/src/hooks/useTheme.ts and export it. Every app gets the same behavior. No divergence.

Turborepo Remote Caching — Don't Skip This

Local caching is nice. Remote caching is why you actually want Turborepo. With Vercel's remote cache (free for hobby, included in pro), every CI run checks whether the exact inputs — source files, env vars, lock file — have been seen before. If they have, it downloads the outputs instead of rebuilding.

Run npx turbo login then npx turbo link. That's it. Your CI pipeline goes from 4 minutes to 18 seconds on cache hits. The first push after a branch diverges still does the full build, but every subsequent push on that branch — and any other branch that shares the same packages/ui state — is instant.

Is it a dependency on Vercel? Yes. Self-hosted alternatives exist: turborepo-remote-cache is an open-source server you can run on any VPS. Same HTTP API, drop-in replacement. Worth knowing if you're on a different host.

Storybook in the Monorepo for Component Development

Add Storybook directly inside packages/ui. Run npx storybook@8.4.0 init from that directory. It'll detect the tsup setup and configure itself. You get a dedicated environment to develop components in isolation without spinning up any actual app.

The real benefit here is that you write stories once and they document the component for every team using it. A Button.stories.tsx file with all variants — primary, outline, ghost, sizes sm, md, lg — acts as living documentation. No separate style guide to maintain.

For visual consistency, pair Storybook with a gradient generator workflow to test how your components look across different background contexts. It's easy to build a Card component that looks great on white but falls apart on a dark gradient. Storybook lets you catch that before it ships.

Box Shadows, Borders, and Visual Tokens That Travel Well

One underrated part of a shared component library is visual tokens — specifically the ones for depth and dimension. Shadow values are the hardest to keep consistent across apps because developers just eyeball them. Define them once in your base theme and reference by name.

A reasonable starting point: --shadow-sm: 0 1px 3px rgba(0,0,0,0.08), --shadow-md: 0 4px 16px rgba(0,0,0,0.12), --shadow-lg: 0 8px 32px rgba(0,0,0,0.16). Need interactive states? Add --shadow-focus: 0 0 0 3px rgba(99,102,241,0.4) for accessible focus rings. The box shadow CSS guide covers the underlying mechanics if you want to go deeper on building these values.

Same story for border-radius. Don't scatter rounded-lg and rounded-xl everywhere — you'll never know what the actual pixel values are when design asks. --radius-sm: 6px, --radius-md: 10px, --radius-lg: 16px. Wire them up as Tailwind v4 theme values and use rounded-md knowing exactly what it means.

FAQ

Do I need to publish @acme/ui to npm to use it across apps in the monorepo?

No. With workspace:* in your package.json and pnpm workspaces (or yarn/npm workspaces), pnpm creates a symlink in node_modules pointing directly to your local packages/ui directory. No publish step needed. CI works the same way as long as you run the install from the monorepo root.

How do I handle Tailwind CSS purging when classes are only used in packages/ui?

In each app's CSS entry file, add a @source directive pointing to the shared package: @source "../../packages/ui/src";. In Tailwind v4, this tells the scanner to include those files when generating the final CSS. Without it, classes used only in shared components get dropped in production.

Can I use the Turborepo remote cache without deploying to Vercel?

Yes. The turborepo-remote-cache project (github.com/ducktors/turborepo-remote-cache) is an open-source HTTP server that implements the same cache API. Run it on any VPS or container, set TURBO_API, TURBO_TOKEN, and TURBO_TEAM env vars, and your builds will use it exactly like Vercel's cache.

What's the right way to handle CSS-in-JS libraries like Stitches or vanilla-extract in packages/ui?

Both work in a Turborepo setup. The catch is that vanilla-extract requires a build step that outputs static CSS files, which tsup supports via the @vanilla-extract/esbuild-plugin. Stitches is zero-build but requires React context, so consuming apps need to wrap with the Stitches provider. Tailwind CSS with CSS variables is honestly simpler for most teams — fewer runtime dependencies.

Should Storybook live in packages/ui or in its own app inside apps/?

Either works, but putting it inside packages/ui keeps it co-located with the components and makes it easier to import stories directly from source without a build step. If you want Storybook deployed as a standalone site (e.g. design.yourcompany.com), move it to apps/storybook and treat it like any other Next.js or Vite app in the workspace.

How do I share TypeScript config across all packages without duplicating tsconfig.json?

Create a packages/typescript-config package with base tsconfig files — tsconfig.base.json, tsconfig.nextjs.json, tsconfig.react-library.json. Each app or package extends the relevant one: "extends": "@acme/typescript-config/nextjs.json". This is how the official Turborepo starter does it and it's the cleanest approach.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

nx vs Turborepo: Monorepo Tooling for Component LibrariesStylelint Configuration: Catch CSS Errors Before They Reach ProdTailwind CSS Mastery: Every Utility, Plugin, and Pattern in One GuideReact Monorepo With Turborepo: Shared Components, Configs, Packages