This guide walks you through building a production-ready component design system using shadcn/ui and Tailwind CSS v4. Follow every step and you will have a fully token-driven, accessible, and themeable UI library integrated into a Next.js 15 monorepo — ready for real products — in roughly three to four hours.
What You'll Build
- A shadcn/ui component registry wired to a custom Tailwind CSS v4 design token layer
- A multi-theme system (light, dark, and brand) that switches at runtime without a flash of unstyled content
- A shared
packages/uipackage inside a Turborepo monorepo, consumable by any app in the workspace - An automated Storybook 8 documentation site that reflects live token changes instantly
- A component variant strategy using
class-variance-authority(CVA) that scales to 50+ components without code duplication
Prerequisites
- Node 20+ and pnpm 9 installed locally
- Working knowledge of React and TypeScript
- A Next.js 15 project or willingness to scaffold one fresh
- Basic familiarity with Tailwind CSS — no prior shadcn/ui experience required
- A GitHub repository (optional, but recommended for CI integration)
Step 1: Scaffold the Turborepo Monorepo
A monorepo lets you share the design system across multiple apps without publishing to npm. This mirrors how teams at companies like Atlassian and Shopify manage internal component libraries as of 2026.
pnpm dlx create-turbo@latest my-design-system --package-manager pnpm
cd my-design-system
pnpm install
Turborepo creates a apps/ folder and a packages/ folder. You will put components in packages/ui and your Next.js app in apps/web.
Why use Turborepo instead of a plain workspaces setup?
Turborepo's remote caching means your CI pipeline skips builds that have not changed. On a 20-component library, this reduces cold CI times by roughly 60% compared to a plain pnpm workspaces setup.
Common pitfall: Do not rename the default packages/ui directory unless you update every package.json reference. Turborepo resolves packages by name, not path.
Step 2: Install shadcn/ui Into the Shared Package
As of shadcn/ui v3 (released Q1 2026), the CLI supports direct installation into a non-app package directory using the --cwd flag. This is the officially recommended pattern for monorepos.
cd packages/ui
pnpm dlx shadcn@latest init --cwd .
The CLI will ask several questions. Use these answers for a production setup:
- Style: New York (ships tighter padding, better for dashboards)
- Base color: Neutral (easiest to override with custom tokens)
- CSS variables: Yes
- Tailwind config: Let the CLI generate it — you will extend it in Step 3
Add two components to verify the setup works:
pnpm dlx shadcn@latest add button card --cwd .
Expected result: You should see src/components/ui/button.tsx and src/components/ui/card.tsx inside packages/ui.
Step 3: Set Up a Design Token Layer With Tailwind CSS v4
Tailwind CSS v4 (stable since early 2025) replaces the JavaScript config file with a pure CSS-based token system using @theme. This is a significant shift — your tokens now live in CSS, not JavaScript, which makes them consumable by non-Tailwind tools like Framer or raw CSS.
Open packages/ui/src/globals.css and replace the default Tailwind theme block with your own:
@import "tailwindcss";
@theme {
--color-brand-primary: oklch(55% 0.22 260);
--color-brand-secondary: oklch(70% 0.15 210);
--color-surface: oklch(98% 0 0);
--color-surface-dark: oklch(12% 0 0);
--font-sans: "Inter Variable", ui-sans-serif, system-ui;
--font-mono: "JetBrains Mono", ui-monospace;
--radius-sm: 0.375rem;
--radius-md: 0.625rem;
--radius-lg: 1rem;
--spacing-page: 1.5rem;
}
Why use OKLCH instead of hex values?
OKLCH is perceptually uniform. That means a 10% lightness shift looks the same across all hues — critical when generating accessible contrast ratios programmatically. Chrome, Safari, and Firefox all support OKLCH natively as of 2024. You no longer need a polyfill for your target markets (AU, SG, CA, US).
Pro tip: Use the oklch.com picker to convert your existing brand hex codes without guesswork. Shoot for a lightness delta of at least 0.45 between foreground and background tokens to pass WCAG 2.2 AA contrast.
Step 4: Build a Multi-Theme System Without FOUT
Flash of unstyled themes is the most common production bug in multi-theme design systems. Fix it by injecting the theme class server-side in your Next.js root layout before the client bundle loads.
In apps/web/app/layout.tsx:
import { cookies } from "next/headers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
{children}
);
}
Then create a server action that writes the theme cookie on toggle:
"use server";
import { cookies } from "next/headers";
export async function setTheme(theme: "light" | "dark" | "brand") {
cookies().set("theme", theme, { path: "/", maxAge: 60 * 60 * 24 * 365 });
}
Expected result: Theme persists across page reloads with zero flash, even on the first server-rendered HTML response.
Common pitfall: Do not use localStorage alone for theme storage in Next.js App Router. It only runs client-side, which means the server renders the wrong theme class on initial load and causes a hydration mismatch.
Step 5: Create a CVA Variant Strategy That Scales
class-variance-authority (CVA) lets you define component variants declaratively. This keeps variant logic out of template markup and makes your components self-documenting.
Replace the default shadcn button with a CVA-powered version:
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
intent: {
primary: "bg-brand-primary text-white hover:bg-brand-primary/90",
secondary: "bg-brand-secondary text-white hover:bg-brand-secondary/90",
ghost: "hover:bg-surface text-foreground",
destructive: "bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "h-8 px-3",
md: "h-10 px-4",
lg: "h-12 px-6",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {}
export function Button({ className, intent, size, ...props }: ButtonProps) {
return (
);
}
When should you skip CVA and use plain Tailwind classes?
Skip CVA for single-use layout components like page wrappers or section containers — they rarely need variants. CVA pays off once a component needs two or more independent axes of variation (like intent + size + loading state).
Step 6: Wire Storybook 8 for Live Documentation
Storybook 8 added native Vite 6 support and the Storybook Test Runner, which replaces the older @storybook/addon-interactions workflow. Install it directly into the packages/ui directory.
cd packages/ui
pnpm dlx storybook@latest init --builder vite
Create a story for your button:
// src/components/ui/button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
const meta: Meta = {
component: Button,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj;
export const Primary: Story = { args: { intent: "primary", children: "Save changes" } };
export const Ghost: Story = { args: { intent: "ghost", children: "Cancel" } };
export const Large: Story = { args: { size: "lg", children: "Get started" } };
Run Storybook:
pnpm --filter ui storybook
Expected result: Storybook opens at http://localhost:6006 with auto-generated prop docs and three button variants rendered side by side.
Pro tip: Add the @storybook/addon-a11y plugin to surface WCAG violations directly inside Storybook. Teams at Lenka Studio use this to catch contrast failures before they ever reach a design review.
Step 7: Expose the Package to Your Next.js App
Update the package.json in apps/web to reference the local UI package:
{
"dependencies": {
"@my-design-system/ui": "workspace:*"
}
}
Run pnpm install from the root, then import your components:
import { Button } from "@my-design-system/ui";
export default function HomePage() {
return ;
}
Common pitfall: If TypeScript cannot resolve the package, check that packages/ui/package.json has a "exports" field pointing to ./src/index.ts. Without it, TypeScript resolves to the wrong entry point.
If you are managing multiple apps across different teams, consider pairing this setup with a design handoff workflow — the kind of structured process that keeps designers and developers in sync as the system grows. Teams that invest in this early, or work with a studio like Lenka Studio to establish it, avoid the component sprawl that kills most design systems by the six-month mark.
Step 8: Add a Turborepo Build Pipeline
Update turbo.json at the monorepo root to ensure the UI package always builds before dependent apps:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"storybook:build": {
"dependsOn": ["^build"],
"outputs": ["storybook-static/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Now pnpm turbo build from the root builds both the UI package and all apps in the correct order, with remote caching enabled automatically.
Frequently Asked Questions
Does this work with React 18 or do I need React 19?
This setup works with both React 18 and React 19. shadcn/ui v3 targets React 18 as the minimum. React 19 server actions are used in Step 4 for theme persistence, but you can replace those with a cookie library and a regular API route if staying on React 18.
Can I use this design system with Remix or Vite instead of Next.js?
Yes. The packages/ui package is framework-agnostic — it exports plain React components. The only Next.js-specific code is in Step 4 (the server action for theme switching). Replace that with a standard HTTP endpoint or a Remix action and the rest of the system works identically.
How is this different from just using shadcn/ui directly in a single app?
A single-app install ties your components to one codebase. The monorepo approach lets multiple apps (e.g. a marketing site and a SaaS dashboard) share the same tokens, variants, and accessibility fixes. Changes to a token propagate everywhere on the next build, rather than requiring manual copy-paste across repos.
What if Tailwind CSS v4 breaks my existing v3 utilities?
Tailwind provides a @tailwindcss/upgrade codemod that migrates most v3 class names automatically. Run pnpm dlx @tailwindcss/upgrade in your app directory and review the diff. The most common breaking change is that arbitrary value syntax has been tightened — check your w-[...] and h-[...] usages first.
How do I publish this package to npm for use outside the monorepo?
Add a tsup build step to packages/ui that bundles the components into ESM and CJS formats, then run pnpm publish from that directory. Set "private": false in the package's package.json and configure .npmignore to exclude story files and test fixtures before publishing.
Next Steps
You now have a production-ready shadcn/ui design system with design tokens, multi-theme support, CVA-based variants, and live Storybook documentation — all inside a Turborepo monorepo. From here, consider:
- Adding a Chromatic visual regression test step to your CI pipeline to catch unintended UI changes across components automatically
- Connecting your Tailwind
@themetokens to a Figma Variables library so designers and engineers share a single source of truth (the Lenka Studio design-to-code pipeline article walks through this in detail) - Setting up a Changesets workflow to version and changelog the UI package as it grows
- Expanding your token set to cover typography scales, motion durations, and elevation shadows — the three token categories most teams add in month two
If you are building a design system for a product team and want expert guidance on architecture, token strategy, or bridging design and engineering — get in touch with the Lenka Studio team. We help SMBs across Australia, Singapore, Canada, and the US build scalable UI foundations without starting from scratch.




