# What You’ll Build and Why It Matters#
A design system in a Next.js product is not a Figma library. It is a versioned UI package with tokens, components, accessibility defaults, documentation, and tests that enforce consistency.
This guide shows an end-to-end approach for building a Next.js design system Storybook Tailwind Radix stack that scales: tokens and theming, Radix-based components, Storybook documentation, integration into a product codebase, and governance through linting and visual regression.
If you want deeper background on tokens and architecture, read these first:
# Architecture Overview: Monorepo, Packages, and Release Flow#
The fastest way to keep a design system usable is to make it easy to consume and hard to misuse. A monorepo with workspace packages typically hits that balance.
Recommended project structure#
| Path | Purpose | Notes |
|---|---|---|
apps/web | Next.js product app | Uses @acme/ui components only |
packages/ui | Design system components | Radix primitives, Tailwind styling, exported API |
packages/tokens | Tokens and Tailwind preset | Central source of truth for colors, spacing, radii |
packages/eslint-config | Consistency rules | Restrict raw HTML, enforce import boundaries |
packages/storybook | Storybook host | Can live inside packages/ui or as separate app |
This structure supports:
- Independent versioning of
@acme/ui - Shared tokens across apps
- Centralized lint rules
- Storybook as the single documentation site
Tooling choices that work well in 2026#
| Concern | Recommended | Why |
|---|---|---|
| Workspaces | pnpm | Fast installs, strict dependency isolation |
| Build system | Turborepo | Caching and parallel builds for CI |
| Versioning | Changesets | Predictable releases and changelog generation |
| Visual regression | Chromatic or Playwright screenshots | Catch UI drift before deploy |
| A11y automation | Storybook a11y + testing-library | Finds common WCAG issues early |
🎯 Key Takeaway: Treat the design system as a product: version it, test it, document it, and enforce it in CI.
# Step 1: Design Tokens as the Source of Truth#
Tokens must be usable by both Tailwind utilities and component styles. The practical approach is to define tokens in CSS variables, then map Tailwind to those variables.
Token strategy: CSS variables plus Tailwind mapping#
Use semantic tokens like --color-bg, not raw values like --blue-500. Semantic tokens support theming and reduce refactors.
Example token file in packages/tokens/src/styles/tokens.css:
:root {
--color-bg: 255 255 255;
--color-fg: 17 24 39;
--color-primary: 59 130 246;
--radius-sm: 6px;
--radius-md: 10px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
}
[data-theme="dark"] {
--color-bg: 17 24 39;
--color-fg: 243 244 246;
--color-primary: 96 165 250;
}Use RGB triplets so Tailwind can apply opacity via rgb(var(--token) / alpha).
Tailwind preset in the tokens package#
Create packages/tokens/src/tailwind-preset.ts:
import type { Config } from "tailwindcss";
export const preset: Config = {
theme: {
extend: {
colors: {
bg: "rgb(var(--color-bg) / <alpha-value>)",
fg: "rgb(var(--color-fg) / <alpha-value>)",
primary: "rgb(var(--color-primary) / <alpha-value>)",
},
borderRadius: {
sm: "var(--radius-sm)",
md: "var(--radius-md)",
},
spacing: {
1: "var(--space-1)",
2: "var(--space-2)",
3: "var(--space-3)",
},
},
},
};Publish tokens.css and the Tailwind preset from @acme/tokens. Your product app and @acme/ui both depend on it.
💡 Tip: Keep the token surface area small. Most teams need fewer than 10 semantic color tokens per theme to move fast without constant token debates.
Loading tokens in Next.js#
In apps/web/src/app/layout.tsx, import the compiled CSS from tokens:
import "@acme/tokens/tokens.css";
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" data-theme="light">
<body className="bg-bg text-fg">{children}</body>
</html>
);
}Set data-theme based on user preference or system setting, but keep it deterministic for SSR.
# Step 2: Build Components on Radix UI Primitives#
Radix UI handles the hard parts: focus traps, keyboard navigation, ARIA attributes, and escape key behavior. Tailwind handles the visual layer. Your design system should wrap Radix in a consistent API.
Component conventions that scale#
Define a few rules and enforce them:
- Export only from a single entrypoint, for example
@acme/ui. - Each component folder includes
index.ts,*.tsx, and*.stories.tsx. - Use a
cnhelper to merge Tailwind classes. - Avoid exposing Radix internals unless needed.
Example cn helper in packages/ui/src/lib/cn.ts:
export function cn(...classes: Array<string | undefined | false>) {
return classes.filter(Boolean).join(" ");
}Example component: Button#
A Button sets the baseline for all components: sizes, states, focus ring, and disabled behavior.
import * as React from "react";
import { cn } from "../lib/cn";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md";
};
export function Button({
className,
variant = "primary",
size = "md",
...props
}: ButtonProps) {
const base =
"inline-flex items-center justify-center rounded-md font-medium " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary " +
"disabled:opacity-50 disabled:pointer-events-none";
const variants: Record<string, string> = {
primary: "bg-primary text-white hover:opacity-90",
secondary: "bg-fg/10 text-fg hover:bg-fg/15",
ghost: "bg-transparent text-fg hover:bg-fg/10",
};
const sizes: Record<string, string> = {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
};
return (
<button
className={cn(base, variants[variant], sizes[size], className)}
{...props}
/>
);
}This gives you:
- A consistent focus ring across the product
- Predictable sizing tokens
- Strict variants that prevent one-off styling
Example component: Dialog using Radix#
Dialog is where Radix pays off immediately. Focus management bugs are expensive, and they tend to ship unnoticed.
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "../lib/cn";
export function Modal({
title,
children,
open,
onOpenChange,
}: {
title: string;
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-1/2 w-[90vw] max-w-lg -translate-x-1/2 -translate-y-1/2",
"rounded-md bg-bg p-6 shadow-lg focus-visible:outline-none"
)}
>
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
<div className="mt-3">{children}</div>
<Dialog.Close className="mt-6 inline-flex h-10 px-4 items-center rounded-md bg-fg/10">
Close
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}⚠️ Warning: Do not remove Radix default behaviors like focus trapping or escape-to-close to match a “pixel-perfect” prototype. Those behaviors are accessibility requirements, and removing them increases support costs and legal risk.
# Step 3: Accessibility Defaults That Don’t Rely on Developer Discipline#
Most accessibility regressions come from inconsistent defaults: missing labels, broken focus styles, and incorrect semantics. Your design system should reduce decisions developers need to make.
A11y checklist baked into components#
| Component area | Default | Why it matters |
|---|---|---|
| Focus styles | focus-visible:ring-2 | Keyboard users need a clear focus indicator |
| Labels | Require aria-label when no visible label | Screen readers need accessible names |
| Disabled state | disabled:pointer-events-none plus opacity | Prevents accidental clicks and clarifies state |
| Hit targets | Minimum height h-10 for buttons | Improves usability on mobile |
| Dialogs and menus | Radix primitives | Correct roving tabindex and focus management |
Practical rule: “No icon-only buttons without a label”#
Make this easy by offering an IconButton component that requires a label prop.
import * as React from "react";
import { Button } from "./button";
export function IconButton(
props: Omit<React.ComponentProps<typeof Button>, "children"> & {
label: string;
icon: React.ReactNode;
}
) {
const { label, icon, ...rest } = props;
return (
<Button aria-label={label} {...rest}>
{icon}
</Button>
);
}This prevents one of the most common accessibility failures in production UIs.
# Step 4: Storybook as Documentation, Playground, and Test Runner#
Storybook is where a design system becomes usable across teams. It reduces Slack questions, makes review faster, and acts as the UI test harness.
Storybook setup essentials#
Install dependencies in the Storybook host package, then ensure it imports tokens.
In .storybook/preview.ts:
import "@acme/tokens/tokens.css";
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
controls: { expanded: true },
a11y: { test: "todo" },
},
};
export default preview;A good story is a usage example, not a snapshot#
A story should:
- Show variants and sizes
- Provide controls for key props
- Demonstrate “real” content, not lorem ipsum only
Example button.stories.tsx:
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
const meta: Meta<typeof Button> = {
title: "Core/Button",
component: Button,
args: { children: "Continue", variant: "primary", size: "md" },
};
export default meta;
export const Primary: StoryObj<typeof Button> = {};
export const Secondary: StoryObj<typeof Button> = {
args: { variant: "secondary" },
};
export const Disabled: StoryObj<typeof Button> = {
args: { disabled: true },
};ℹ️ Note: For many teams, Storybook replaces a separate design system website. The time saved in onboarding and UI review often exceeds the initial setup cost within the first 4 to 8 weeks.
Storybook interaction tests#
Interaction tests catch behavior issues like “Escape closes modal” and “Tab stays within dialog.” These issues are expensive to catch via manual QA only.
Keep the test small and focused:
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
import { Modal } from "./modal";
const meta: Meta<typeof Modal> = { title: "Overlays/Modal", component: Modal };
export default meta;
export const OpensAndCloses: StoryObj<typeof Modal> = {
render: () => <Modal title="Title" open={true} onOpenChange={() => {}}>Body</Modal>,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Title")).toBeInTheDocument();
},
};# Step 5: Integrate the Design System into a Next.js Product Codebase#
You have two integration goals:
- 1Make adoption easy
- 2Make bypassing difficult
Integration checklist#
| Area | What to do | What it prevents |
|---|---|---|
| Imports | Use @acme/ui only | Random component copies |
| Tokens | Load @acme/tokens/tokens.css in root layout | Color drift across apps |
| Tailwind | Use tokens preset in app config | Hardcoded values in utilities |
| SSR | Keep theme deterministic on server | Hydration mismatch |
| Migration | Replace UI incrementally | Big bang rewrites that stall |
In apps/web/tailwind.config.ts, consume the preset:
import type { Config } from "tailwindcss";
import { preset } from "@acme/tokens/tailwind-preset";
export default {
presets: [preset],
content: ["./src/**/*.{ts,tsx}", "../../packages/ui/src/**/*.{ts,tsx}"],
theme: { extend: {} },
} satisfies Config;Migration pattern that works in real teams#
Replace high-churn UI first:
- Buttons and links
- Form controls
- Modals and dropdowns
- Tables and data display
This reduces bug count faster because these components appear on many screens and have many states.
# Step 6: Enforce Consistency with Linting and Import Boundaries#
Consistency does not scale with code review alone. Automated enforcement is cheaper and more reliable.
ESLint rules to discourage bypassing#
Use no-restricted-imports to block direct Radix imports in product code, forcing @acme/ui wrappers:
module.exports = {
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@radix-ui/react-dialog",
message: "Use Modal from @acme/ui instead of importing Radix directly.",
},
],
},
],
},
};Add restrictions for raw HTML elements when appropriate. For example, disallow raw button in app code to ensure focus styles and variants are consistent. Apply this carefully to avoid slowing down legitimate cases.
💡 Tip: Make the rule opt-in per folder. Enforce it strictly in
apps/web/src/featuresfirst, and allow exceptions inapps/web/src/playgroundor migration folders.
Tailwind class consistency rules#
If you allow arbitrary Tailwind usage, you will get inconsistent UI. The goal is not to ban Tailwind, but to limit it to approved tokens and patterns.
Common enforcement options:
- Restrict specific classes like
text-red-500in app code, requiring semantic tokens instead. - Require
bg-bg,text-fg, and semantic colors rather than palette colors.
This is where the Tailwind best practices approach pays off: fewer custom utility overrides and fewer one-off class strings.
# Step 7: Visual Regression Testing and CI Gates#
Unit tests miss UI issues like spacing, colors, and z-index layering. Visual regression tests catch those reliably.
Two practical options#
| Option | Setup effort | Ongoing cost | Best for |
|---|---|---|---|
| Chromatic | Low | Paid at scale | Teams that want speed and hosted diffs |
| Playwright screenshots | Medium | Compute time in CI | Teams that want full control and self-hosting |
A realistic baseline is to snapshot:
- Button variants
- Form fields states
- Modal and popover layering
- Dark mode themes
Minimal Playwright screenshot example#
Create a story route and snapshot the rendered story iframe. Keep it simple and small per test.
import { test, expect } from "@playwright/test";
test("Button primary visual", async ({ page }) => {
await page.goto("http://localhost:6006/?path=/story/core-button--primary");
await expect(page).toHaveScreenshot("button-primary.png", { fullPage: true });
});This catches changes in padding, border radius, and color tokens immediately.
⚠️ Warning: Visual regression only works if environments are deterministic. Pin fonts, set a consistent viewport, and avoid time-based UI in stories.
# Step 8: Versioning, Releases, and Backwards Compatibility#
A design system that cannot ship safely will be ignored. Versioning and changelogs are what make adoption predictable.
Use semantic versioning with Changesets#
A practical policy:
- Patch releases: bug fixes, internal refactors
- Minor releases: new components, new props with defaults
- Major releases: breaking API, token rename, behavior change
Release checklist per change:
- Updated stories
- Updated docs in Storybook
- Visual snapshots updated and approved
- Changeset file added
Example changeset file:
---
"@acme/ui": minor
---
Add secondary variant to Button and document usage in Storybook.Deprecation strategy for real products#
Breaking changes create churn. Prefer deprecations with a timeline:
- Keep old prop working for 1 to 2 minor versions
- Add a lint rule warning for deprecated usage
- Provide codemods for mechanical migrations
If the design system is used across multiple apps, this approach can save days per quarter.
# Key Takeaways#
- Define tokens as semantic CSS variables and map Tailwind to them so themes and utilities stay consistent.
- Wrap Radix UI primitives with your own API to standardize accessibility, focus styles, and variants.
- Use Storybook as documentation plus a test harness with a11y checks and interaction tests, not just a gallery.
- Integrate via workspace packages and Tailwind presets, then migrate high-churn UI first to get fast ROI.
- Enforce usage with lint rules and import boundaries, and catch UI drift with visual regression in CI.
- Version the design system with Changesets and semantic versioning, using deprecations to avoid breaking product teams.
# Conclusion#
A production-grade design system in Next.js is a pipeline: tokens drive Tailwind, Radix provides accessible behavior, Storybook documents and tests, and CI enforces consistency through linting and visual regression.
If you want help designing the token model, building Radix-based components, or setting up Storybook plus CI gates for a Next.js product, contact Samioda and we can implement a versioned design system that your team can ship confidently.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →The React Code Review Checklist We Use: Performance, Accessibility, and Maintainability
A practical React code review checklist focused on performance, accessibility, and maintainability, with examples, automation tips, and a copy-paste template.
Next.js Multi‑Region Deployments: Patterns for Lower Latency on Vercel and Cloudflare
A practical guide to Next.js multi-region deployment in 2026: edge rendering, regional SSR, and data locality patterns on Vercel and Cloudflare, including database caveats and a latency validation checklist.
Next.js Monorepo with Turborepo + pnpm Workspaces: A Practical Setup Guide for 2026
Set up a production-ready Next.js monorepo using Turborepo and pnpm workspaces, with shared packages, linting, testing, and CI caching—plus the trade-offs and pitfalls to avoid.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Building a React Design System with Design Tokens: Tailwind CSS + Radix UI + TypeScript
A practical 2026 guide to building a React design system with Tailwind and Radix using design tokens, accessible primitives, theming, and reusable packages across multiple apps.
The React Code Review Checklist We Use: Performance, Accessibility, and Maintainability
A practical React code review checklist focused on performance, accessibility, and maintainability, with examples, automation tips, and a copy-paste template.
Next.js Monorepo with Turborepo + pnpm Workspaces: A Practical Setup Guide for 2026
Set up a production-ready Next.js monorepo using Turborepo and pnpm workspaces, with shared packages, linting, testing, and CI caching—plus the trade-offs and pitfalls to avoid.