Command Palette

Search for a command to run...

Prototyping to Production: Transitioning Your UI Kit into a Next.js Component Library

Prototyping to Production: Transitioning Your UI Kit into a Next.js Component Library

A step‑by‑step playbook to turn a prototype UI kit into a production‑ready Next.js component library—tokens, headless primitives, Storybook docs, packaging, testing, versioning, and CI/CD.

10 min read·Design Systems

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

  1. When to graduate from prototype to library
  2. Audit your prototype: what to keep, rewrite, or drop
  3. Design tokens: extract, name, and automate
  4. Architecture choices: monorepo vs single‑package
  5. Library tooling: TypeScript, packaging, exports, sideEffects
  6. Next.js integration patterns (App Router, RSC, “use client”)
  7. Headless primitives: Radix UI & shadcn/ui
  8. Variants, theming, and dark mode with Tailwind + CSS variables
  9. Accessibility: keyboard, roles, focus, and reduced motion
  10. Documentation with Storybook (MDX), examples, and usage notes
  11. Testing matrix: unit, a11y, e2e, and visual regression
  12. Versioning & releases: Changesets, semver, and deprecations
  13. CI/CD: build, size budgets, and release pipelines
  14. Reference structure & sample code
  15. Migration strategy for existing apps
  16. 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 a labs/ 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/dynamic when 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 input

Dialog 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-authority
import { 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 init

Button.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-axe to 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 init

CI/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

  1. Freeze new UI in app code. Start adopting the library for new features only.
  2. Alias paths in the app to gradually swap imports (@/components@org/ui).
  3. Replace high‑risk components first (dialogs/menus/tooltips).
  4. Run visual regression against core flows after each swap.
  5. 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.

Read more like this

Prototyping to Production: Transitioning Your UI Kit into a Next.js Component Library | Ruixen UI