Most teams start with prototype components—fast, flexible, and a little messy. Moving to production means repeatability: design tokens, consistent APIs, a11y, docs, tests, and versioned releases. This deep guide shows how to convert a prototype UI kit into a Next.js‑friendly component library that scales across apps.
What you’ll get: Architecture decisions, token pipelines (Figma → CSS vars), headless primitives (Radix/shadcn), Tailwind mapping, component patterns, packaging (ESM/CJS), Storybook, testing (unit/a11y/e2e/visual), semantic versioning, and CI/CD.
Table of Contents
- When to graduate from prototype to library
- Audit your prototype: what to keep, rewrite, or drop
- Design tokens: extract, name, and automate
- Architecture choices: monorepo vs single‑package
- Library tooling: TypeScript, packaging, exports, sideEffects
- Next.js integration patterns (App Router, RSC, “use client”)
- Headless primitives: Radix UI & shadcn/ui
- Variants, theming, and dark mode with Tailwind + CSS variables
- Accessibility: keyboard, roles, focus, and reduced motion
- Documentation with Storybook (MDX), examples, and usage notes
- Testing matrix: unit, a11y, e2e, and visual regression
- Versioning & releases: Changesets, semver, and deprecations
- CI/CD: build, size budgets, and release pipelines
- Reference structure & sample code
- Migration strategy for existing apps
- FAQ
When to graduate from prototype to library
Signals that it’s time to formalize:
- You have two or more apps reusing the same components.
- Visual drift, inconsistent props, and copy‑pasta fixups are common.
- A11y and cross‑browser issues keep reappearing.
- New engineers ask “which button is the real one?”
Goal: one versioned package with documented APIs, a token contract, and tests—so any app can adopt it with confidence.
Audit your prototype: what to keep, rewrite, or drop
Create a quick inventory with screenshots and usage counts.
- Keep: clear, stable components (e.g., Buttons, Inputs) with minimal side effects.
- Rewrite: modals, menus, tooltips, and complex composites—replace hand‑rolled behavior with headless primitives.
- Drop: one‑off marketing components or rarely used patterns; keep them in the app layer.
Pro tip: Add a “Ready state” label:
draft → beta → stable. Only publish stable components to the library root; beta ones live in alabs/namespace until proven.
Design tokens: extract, name, and automate
Start with semantic tokens for color, spacing, radius, typography, and motion. Export from Figma Variables or a JSON source, then generate CSS variables used by both the library and consuming apps.
tokens.json (excerpt)
{
"color": {
"bg": {
"default": { "light": "#fff", "dark": "#0b0c0f" },
"raised": { "light": "#fafafa", "dark": "#121316" }
},
"fg": {
"default": { "light": "#111", "dark": "#f3f4f6" },
"muted": { "light": "#555", "dark": "#cbd5e1" }
},
"brand": { "primary": { "light": "#2563eb", "dark": "#60a5fa" } }
},
"radius": { "sm": 6, "md": 10, "lg": 14, "full": 9999 },
"space": { "1": 4, "2": 8, "3": 12, "4": 16, "5": 24, "6": 32 }
}Build script → CSS variables
// scripts/build-tokens.ts
import fs from "node:fs";
const t = JSON.parse(fs.readFileSync("tokens.json", "utf8"));
function walk(o: any, p: string[] = [], out: Record<string, string> = {}) {
for (const [k, v] of Object.entries(o)) {
const np = [...p, k];
if (typeof v === "object" && v) {
if ("light" in v && "dark" in v) {
const n = `--${np.join("-")}`;
out[`${n}-light`] = v.light;
out[`${n}-dark`] = v.dark;
} else walk(v, np, out);
} else out[`--${np.join("-")}`] = String(v);
}
return out;
}
const vars = walk(t);
const css = [":root{"];
for (const [k, val] of Object.entries(vars)) {
if (k.endsWith("-light")) css.push(`${k.replace("-light", "")}:${val};`);
else if (!k.endsWith("-dark")) css.push(`${k}:${val};`);
}
css.push("}");
css.push(`[data-theme="dark"]{`);
for (const [k, val] of Object.entries(vars)) {
if (k.endsWith("-dark")) css.push(`${k.replace("-dark", "")}:${val};`);
}
css.push("}");
fs.writeFileSync("src/styles/tokens.css", css.join(""));Tailwind mapping
// tailwind.config.ts (library dev + consumer apps)
export default {
darkMode: ["class", '[data-theme="dark"]'],
content: ["./src/**/*.{ts,tsx,mdx}"],
theme: {
extend: {
colors: {
bg: {
DEFAULT: "var(--color-bg-default)",
raised: "var(--color-bg-raised)",
},
fg: {
DEFAULT: "var(--color-fg-default)",
muted: "var(--color-fg-muted)",
},
brand: { DEFAULT: "var(--color-brand-primary)" },
},
borderRadius: {
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
full: "var(--radius-full)",
},
},
},
};Architecture choices: monorepo vs single‑package
- Monorepo (pnpm workspaces + Changesets): best if you’ll ship multiple packages (
tokens,icons,ui). - Single package: simpler to start; scale later.
pnpm workspace (example)
# pnpm-workspace.yaml
packages:
- packages/*
- apps/*Repo layout
/packages
/ui
/tokens
/apps
/docs (Storybook or Next.js site)
Library tooling: TypeScript, packaging, exports, sideEffects
Use ESM‑first builds with per‑component subpath exports and CSS extracted.
package.json (library)
{
"name": "@org/ui",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/button/index.d.ts",
"import": "./dist/button/index.js",
"require": "./dist/button/index.cjs"
},
"./dialog": {
"types": "./dist/dialog/index.d.ts",
"import": "./dist/dialog/index.js",
"require": "./dist/dialog/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"sideEffects": ["*.css"],
"files": ["dist"],
"peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}tsup config (simple)
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/button/index.ts", "src/dialog/index.ts"],
format: ["esm", "cjs"],
dts: true,
splitting: true,
treeshake: true,
sourcemap: true,
clean: true,
minify: true,
external: ["react", "react-dom"],
});Keep modules pure at import time: no DOM access or CSS injection in module scope.
Next.js integration patterns (App Router, RSC, “use client”)
- Default to RSC‑safe modules (no effects at module scope).
- Mark interactive components with
"use client"at the component file only. - Use parallel/intercepting routes for shareable modals and sheets.
- Provide examples for
next/dynamicwhen SSR must be disabled for a widget.
// consumer app usage
import { Button } from "@org/ui/button";
import "@org/ui/styles.css";Headless primitives: Radix UI & shadcn/ui
Replace hand‑rolled behavior with Radix primitives; style via Tailwind and tokens. If you want ownable source, generate components via shadcn/ui and adapt to your tokens.
pnpm add @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-tooltip
pnpm dlx shadcn@latest init && pnpm dlx shadcn add button dialog inputDialog pattern
"use client";
import * as Dialog from "@radix-ui/react-dialog";
export function Modal({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="rounded-md bg-brand px-4 py-2 text-white">
Open
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content
className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-bg p-6 shadow-xl focus:outline-none"
aria-describedby={undefined}
>
<Dialog.Title className="text-lg font-semibold text-fg">
{title}
</Dialog.Title>
<div className="mt-3">{children}</div>
<Dialog.Close asChild>
<button className="mt-5 rounded border border-fg/20 px-3 py-2">
Close
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Variants, theming, and dark mode with Tailwind + CSS variables
Use class-variance-authority for consistent variants; point Tailwind to tokens; switch themes via data-theme or class.
pnpm add class-variance-authorityimport { cva, type VariantProps } from "class-variance-authority";
const button = cva(
"inline-flex items-center justify-center rounded-md font-medium focus-visible:outline-2 focus-visible:outline-offset-2 transition-colors",
{
variants: {
variant: {
primary: "bg-brand text-white hover:bg-brand/90",
secondary: "border border-fg/20 text-fg hover:bg-fg/5",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={button({ variant, size, className })} {...props} />;
}Theme toggle
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
const s = localStorage.getItem("theme");
if (s) document.documentElement.setAttribute("data-theme", s);
}, []);
const toggle = () => {
const next =
document.documentElement.getAttribute("data-theme") === "dark"
? "light"
: "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
setDark(next === "dark");
};
return (
<button
onClick={toggle}
className="rounded-md border border-fg/20 px-3 py-2"
>
{dark ? "Light" : "Dark"} mode
</button>
);
}Accessibility: keyboard, roles, focus, and reduced motion
Bake a11y into primitives and docs:
- Use native elements where possible; add ARIA only when necessary.
- Ensure Tab/Shift+Tab order; return focus after closing dialogs.
- Respect
prefers-reduced-motion; prefer transform/opacity animations. - Provide visible focus (
:focus-visible), role/label contracts in prop types.
Documentation with Storybook (MDX), examples, and usage notes
Use Storybook as your dev environment + docs site.
pnpm dlx storybook@latest initButton.stories.mdx (excerpt)
import { Meta, Story, Canvas, ArgsTable } from "@storybook/blocks";
import { Button } from "./Button";
<Meta title="Primitives/Button" of={Button} />
# Button
Primary call‑to‑action with `primary | secondary` variants and `sm | md | lg` sizes.
<Canvas>
<Story
name="Playground"
args={{ children: "Get Started", variant: "primary", size: "md" }}
/>
</Canvas>
<ArgsTable of={Button} />Document design tokens, a11y expectations, and dos/don’ts next to each component.
Testing matrix: unit, a11y, e2e, and visual regression
- Unit: Vitest/Jest + RTL for behavior.
- a11y:
jest-axeto catch violations. - E2E: Playwright for keyboard flows, route‑bound modals.
- Visual: Storybook screenshots for light/dark and zoom.
pnpm add -D vitest jsdom @testing-library/react jest-axe axe-core @storybook/test-runner// Button.a11y.test.tsx
import { render } from "@testing-library/react";
import { axe } from "jest-axe";
import { Button } from "./Button";
test("has no a11y violations", async () => {
const { container } = render(<Button>Submit</Button>);
expect(await axe(container)).toHaveNoViolations();
});Versioning & releases: Changesets, semver, and deprecations
- Use Changesets to track changes and auto‑generate changelogs.
- Follow semver: bugfix = patch, additive = minor, breaking = major.
- Mark deprecations in release notes; provide codemods for major migrations when possible.
pnpm add -D @changesets/cli
pnpm changeset initCI/CD: build, size budgets, and release pipelines
GitHub Action (excerpt)
name: ci
on: [push, pull_request]
jobs:
build_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "pnpm" }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build && pnpm test
release:
needs: build_test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "pnpm" }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm changeset version
- run: pnpm publish -r --access public
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}Add size budgets with size-limit to prevent bloat.
Reference structure & sample code
ui-system/
├─ packages/
│ ├─ ui/
│ │ ├─ src/
│ │ │ ├─ styles/
│ │ │ │ └─ tokens.css
│ │ │ ├─ button/
│ │ │ │ ├─ Button.tsx
│ │ │ │ └─ index.ts
│ │ │ ├─ dialog/
│ │ │ │ ├─ Dialog.tsx
│ │ │ │ └─ index.ts
│ │ │ ├─ index.ts
│ │ │ └─ types.ts
│ │ ├─ tsup.config.ts
│ │ └─ package.json
│ └─ tokens/
│ ├─ tokens.json
│ └─ scripts/build-tokens.ts
├─ apps/
│ └─ docs/ (Storybook or Next.js docs site)
└─ pnpm-workspace.yaml
Button.tsx (final) already shown in Variants, theming….
Dialog.tsx shown above in Headless primitives.
Migration strategy for existing apps
- Freeze new UI in app code. Start adopting the library for new features only.
- Alias paths in the app to gradually swap imports (
@/components→@org/ui). - Replace high‑risk components first (dialogs/menus/tooltips).
- Run visual regression against core flows after each swap.
- When 80% migrated, schedule a final cleanup and remove old components.
Codemods: provide simple transforms for prop renames or API changes between prototype and library versions.
FAQ
Do I need a monorepo to publish a library?
No—but workspaces simplify shared tooling, tokens, and docs apps.
Styled kit or headless primitives?
Headless (Radix) + your tokens/theme is more maintainable in the long run; styled kits are faster for first delivery but harder to deeply rebrand.
How do I avoid breaking every app on release?
Adopt semver, publish beta tags for large changes, and provide codemods or migration guides.
Can I support multiple brands?
Yes—keep components token‑only and ship brand themes as token packs. Toggle via data-theme or load per‑brand CSS variables.
Next steps
- Extract tokens and generate CSS variables.
- Replace hand‑rolled dialogs/menus with Radix and wire Tailwind to tokens.
- Add Storybook, jest‑axe, and Playwright; set size budgets.
- Publish a 0.x beta, integrate into one Next.js app, and iterate from real usage.
