Building a Design System
- react
- design
- typescript
- webdev
- frontend
- designsystems
- softwareengineering
- nextjs
In the previous article we laid out a folder structure that scales past the "everything in components/" phase of a Next.js app. That structure has a gap on purpose: a ui/ or design-system/ boundary that we didn't fill in, because a design system deserves its own decision-making, not a folder created by convention alone.
This is part of the React & Next.js Complete Guide series, and it sits at an interesting point in that arc. Everything before it was about writing correct React. This one is about writing React that stays consistent across a team, a codebase, and time, which is a different problem with different failure modes.
I want to be direct about scope here. A design system is more than a component library you install from npm, and more than a Figma file of color swatches. The real thing is the layer that makes fifteen engineers building fifty features produce one product, not fifty slightly different ones. Getting there is mostly organizational discipline expressed through code.
What you're actually building
A design system has three layers, and teams get into trouble when they blur them together.
Design tokens are the raw values: colors, spacing, radii, font sizes, shadows. They're data, not components. Primitives are the smallest usable components built on those tokens: Button, Input, Badge, Stack. Patterns are compositions of primitives that solve a recurring UI problem: a DataTable, a FormField that wires a label, input, and error message together, a PageHeader.
The mistake I see most often is teams jumping straight to patterns because that's where the visible product work is. A DataTable component gets built for one feature, then copied and tweaked for the next three, and by the fifth feature nobody agrees on how sorting should behave. Patterns without solid primitives underneath them just move the inconsistency up a layer instead of removing it.
Tokens deserve a separate article (the next one in this series covers them properly), so for this piece assume they exist and focus on primitives and patterns, since that's where most of the day-to-day engineering decisions live.
Start smaller than you think
The instinct on a new SaaS product is to design the whole system upfront: every variant, every size, every state, documented before a single feature ships. Resist that. A design system designed in a vacuum, before real screens have been built against it, tends to guess wrong about what the product needs, and you end up reworking an API that dozens of call sites already depend on.
The better sequence is to build two or three real features first, using plain components with no attempt at reusability. Then look at what actually repeated. If three features independently built a bordered card with a header and an action slot, that's a real pattern worth extracting, because you have evidence, not a guess.
This is the same "defer complexity until it's earned" instinct that applies to caching and to premature microservices. A design system is infrastructure, and infrastructure built ahead of demonstrated need tends to be wrong in ways that are expensive to unwind, because other components already depend on the wrong shape by the time you notice.
Building primitives with a variant API
Once you've identified a real primitive, the API design matters more than the implementation. The pattern I reach for is a variant-based component using class-variance-authority (cva) with Tailwind, because it gives you a typed, declarative surface for every visual state instead of a pile of boolean props.
// packages/ui/src/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "./utils/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-slate-900 text-white hover:bg-slate-800",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
destructive: "bg-red-600 text-white hover:bg-red-500",
ghost: "hover:bg-slate-100",
},
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 buttonVariants>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
Two decisions here matter more than the syntax. First, variant and size are closed sets, defined once, typed by VariantProps. Any engineer using Button gets autocomplete for the exact set of supported states instead of guessing at class names. Second, the component still accepts className, which is deliberately an escape hatch. A design system that refuses to let call sites adjust spacing or layout in context ends up fighting every engineer who has a legitimate one-off need, and they'll reach for !important or a wrapper div to work around it. Let the primitive own color, typography, and interaction states; let the call site own layout and placement.
Enforcing consistency without slowing people down
A design system that exists but isn't used is worse than not having one, because it adds a maintenance burden without delivering the consistency it promised. Enforcement has to be closer to "make the right thing the easy thing" than "add a linter that blocks PRs."
A few mechanisms that hold up in practice:
- Storybook (or a similar tool) as the source of truth, so engineers can see every variant and state without reading source. A component that isn't in Storybook effectively doesn't exist for discovery purposes.
- An ESLint rule that flags raw HTML elements where a primitive exists, for example warning on a bare
<button>insideapps/in favor of importingButtonfrom the shared package. This catches drift at review time instead of relying on someone noticing in a PR. - Visual regression testing (Chromatic, Percy, or a Playwright-based screenshot diff) on the primitives package specifically. Primitives change rarely but affect everything, so a regression there is high blast radius; catching it before merge is worth the CI time.
// .eslintrc rule excerpt
{
"rules": {
"no-restricted-syntax": [
"warn",
{
"selector": "JSXOpeningElement[name.name='button']",
"message": "Use <Button> from @acme/ui instead of a raw <button>."
}
]
}
}
The tradeoff worth naming: rules like this generate noise in legacy code you haven't migrated yet. Scope the ESLint rule to specific directories (new features, or apps/web/src/features/**) rather than the whole repo, and let older code migrate opportunistically. A rule that surfaces a wall of pre-existing violations on day one tends to get disabled out of frustration, and then you've lost the enforcement entirely.
Governance: who can change a primitive, and how
The technical part of a design system is the easier half. The harder part is deciding who owns changes to a widely-imported primitive, and what happens when a feature team wants a variant that doesn't fit the existing set.
Treat the design system package like any other shared dependency with a real ownership model, not a free-for-all. A lightweight version that works for most mid-sized teams:
- One team or a rotating set of maintainers reviews PRs that touch
packages/ui, even if any engineer can propose changes. - New variants require a real usage, not a hypothetical one. A feature that needs a new
Buttonvariant ships it alongside that feature's PR, reviewed in context, rather than pre-approved in isolation. - Breaking changes to a primitive's props get a deprecation path: keep the old prop working with a console warning for a release or two, migrate call sites, then remove it. Treat it with the discipline of a public API, because from the perspective of feature teams, it is one.
Versioning the package internally, even inside a monorepo, using changesets or a similar tool, makes this concrete: consumers see exactly what changed between versions instead of discovering a visual regression after the fact.
Common production pitfalls
A few failure patterns show up repeatedly once a design system has been running for a year or more.
Token sprawl through one-off overrides. Every className="text-[#2b2f38]" scattered through feature code is a color that isn't a token, so a future rebrand or dark-mode pass has to hunt it down manually instead of changing one value.
Forking instead of extending. When a primitive doesn't quite fit, the fast path is copying it into the feature folder and tweaking it. Do this twice and you have two Button implementations drifting apart, with nobody sure which is canonical. A primitive that doesn't fit is a signal its API needs a new variant, not that the feature needs its own copy.
Over-configurable components. The opposite failure: a Card with a dozen boolean props trying to cover every layout anyone has asked for. At that point the component just hides complexity behind a prop interface harder to reason about than the JSX it replaced. A component needing that many knobs is usually two or three components pretending to be one.
Treating the design system as frontend-only. The contracts it encodes (what states a button supports, what an empty state looks like, how errors render) are product decisions, not just visual ones. Building it without input from whoever owns product and UX decisions produces a system that's internally consistent but wrong for the product.
Key takeaways
- A design system is tokens, primitives, and patterns as three distinct layers; don't build patterns before the primitives underneath them are solid.
- Extract real components from features that already exist rather than designing a system speculatively before any screens are built.
- Variant-based APIs (`cva` or similar) give primitives a typed, discoverable surface while still allowing layout-level overrides through `className`.
- Enforce usage with Storybook, scoped lint rules, and visual regression tests on the primitives package specifically, since it has the widest blast radius.
- Give the shared package real governance: reviewers, a deprecation path for breaking changes, and a rule that new variants ship with a real use case.
- Watch for token sprawl, component forking, and over-configured components; they're the most common ways a design system quietly stops working.
Frequently asked questions
Do I need a design system if I'm using shadcn/ui or another headless component library?
Those libraries give you unstyled or lightly styled primitives you own the source for, which is a good starting point, not a finished design system. You still need to pick a token set, wrap the primitives in your product's variant conventions, and build the patterns your product needs on top.
How many components should a design system start with?
Fewer than you'd guess. A first pass covering `Button`, `Input`, `Badge`, `Card`, and basic layout primitives (`Stack`, `Container`) covers most early-stage SaaS screens. Add a component when a real feature needs it and the pattern has shown up more than once.
Where should a design system live in a monorepo?
As its own package (`packages/ui` or similar) with a clear public export surface, so app code imports from the package name rather than reaching into internal files. That also makes it easy to version and run its own build, lint, and test pipeline independent of the apps that consume it.
How do I migrate an existing app to a new design system without a big-bang rewrite?
Introduce the new primitives alongside the old ones, migrate feature by feature as you touch that code anyway, and scope the ESLint rule to new or migrated directories so you're not fighting the entire legacy codebase at once. Incremental migration tied to work already happening tends to actually finish; a parallel rewrite usually doesn't.
Should design tokens live in code or in a design tool like Figma?
Both, but one has to be the source of truth. Most teams that get this right define tokens in code and generate Figma tokens from that, rather than keeping two systems aligned by hand. The next article in this series covers this in detail.
What's the difference between a component library and a design system?
A component library is the code artifact: the actual `Button`, `Input`, and `Card` implementations. A design system also includes the tokens underneath it, the documentation, the governance model, and the enforcement mechanisms that keep everything consistent as more people contribute.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.