ReactNext.jsDesign SystemsArchitectureTypeScriptUI Engineering

React Component Architecture for Scale: Patterns for a Maintainable Design System

Adrijan Omičević··13 min read
Share

# What You’ll Build in This Guide#

A scalable React component architecture is less about a perfect folder tree and more about repeatable decisions your whole team can apply under time pressure.

This guide lays out a pragmatic architecture for large React and Next.js codebases, focusing on composition, compound components, polymorphic components, theming, and folder conventions. You’ll also get anti-patterns to avoid and a refactoring playbook you can run sprint by sprint.

You’ll leave with a structure that supports a maintainable design system without turning your UI layer into a rigid framework.

# Why React Component Architecture Breaks at Scale#

Most teams start with good intentions and end up with UI entropy: multiple button implementations, inconsistent spacing, and components that only work in one screen.

The cost shows up fast:

  • Slower delivery: Engineers re-implement UI instead of reusing it, or spend time deciphering “which Button is the real one”.
  • Bug multiplication: Fixing an accessibility issue in three versions of the same component is three chances to miss one.
  • Performance regressions: Over-configured components and prop-driven re-render cascades are common in large UI systems.

ℹ️ Note: Design systems fail most often due to governance and architecture, not colors and typography. If your components are hard to compose, teams will bypass them.

If you’re building on Next.js, architecture choices also affect server rendering and data fetching boundaries. When you mix interactivity and layout without clear boundaries, you’re more likely to create unnecessary client bundles and hydration work. For a deeper mental model, see our guide on React Server Components in Next.js.

# Core Principles: The Rules That Keep the System Maintainable#

You can implement the patterns below in multiple ways, but the system holds only if a few rules are consistent.

1) Prefer composition over configuration#

If a component has more than 10 props and half of them are rarely used, it’s usually a sign you’re trying to make one component cover too many layouts.

Composition keeps APIs small, encourages reuse, and avoids variant explosion.

2) Keep “design system components” presentational by default#

Interactive components are harder to keep stable because state flows differ per feature.

Default to presentational components and expose hooks or smaller interactive wrappers when needed.

3) Make the “happy path” the easiest path#

If using the design system requires extra wrappers, tricky props, or reading internal docs, engineers will copy-paste.

Ergonomics is governance.

4) Define boundaries explicitly#

You need boundaries for:

  • styling and theming
  • client versus server components in Next.js
  • domain UI versus design system UI

A boundary is a folder, an export rule, and a dependency rule.

# A Scalable Folder and Export Convention#

A common mistake is a single components/ folder with hundreds of files. It becomes a junk drawer.

Here is a structure that scales while staying practical in Next.js:

AreaFolderWhat goes hereDependency rule
Design system primitivessrc/ui/primitives/Button, Input, Text, Stack, IconNo app imports
Composed design system componentssrc/ui/components/Modal, Dropdown, DatePicker, DataTableCan use primitives
Feature UIsrc/features/*/components/Screen-specific componentsCan use src/ui/*
Feature logicsrc/features/*/hooks/Hooks for feature state, dataNo src/ui imports required
App routessrc/app/Next.js routes and layoutsUse features and ui
Shared utilitiessrc/lib/fetchers, formatters, loggingNo React imports ideally

Export strategy matters as much as folders.

  • src/ui/index.ts exports stable public UI APIs.
  • Avoid deep imports like src/ui/primitives/button/Button.tsx because they create tight coupling and make refactors painful.
  • Keep internal helpers unexported or in internal/ folders.

💡 Tip: Enforce boundaries with ESLint rules. A simple “no cross-feature imports” rule prevents features/billing importing features/auth components and quietly creating cycles.

# Composition Patterns That Scale#

Pattern 1: Slots instead of boolean props#

Instead of hasIcon, showSubtitle, withBadge, use slots.

Bad API designs often lead to conditional spaghetti inside the component and dozens of “almost the same” variants.

TSX
```typescript
type CardProps = \{
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children: React.ReactNode;
\};
 
export function Card(props: CardProps) \{
  return (
    <div className="rounded-xl border p-4">
      \{props.header ? <div className="mb-3">\{props.header\}</div> : null\}
      <div>\{props.children\}</div>
      \{props.footer ? <div className="mt-3">\{props.footer\}</div> : null\}
    </div>
  );
\}
Plaintext
 
This API scales because new needs get implemented as composed UI, not extra props.
 
### Pattern 2: “Layout primitives” to reduce ad-hoc CSS
 
If every screen defines its own spacing, your UI will drift. Layout primitives make spacing consistent and speed up development.
 
Common primitives include Stack, Inline, Grid, Container. They should be boring and predictable.
 
If you’re using utility CSS, keep it disciplined. This pairs well with [Tailwind CSS best practices](https://samioda.com/en/blog/tailwind-css-best-practices), especially around consistent spacing scales and avoiding arbitrary values.
 
### Pattern 3: Controlled and uncontrolled components with a single mental model
 
For inputs, dropdowns, tabs, and modals, support both patterns:
 
- controlled: parent owns state
- uncontrolled: component owns state, parent listens
 
Make the API explicit with `value` and `defaultValue`, `open` and `defaultOpen`.
 
| Component | Controlled props | Uncontrolled props | Required callbacks |
| --- | --- | --- | --- |
| Tabs | `value` | `defaultValue` | `onValueChange` |
| Modal | `open` | `defaultOpen` | `onOpenChange` |
| Input | `value` | `defaultValue` | `onChange` |
 
This reduces custom wrappers and makes components usable in both simple and complex screens.
 
## Compound Components: A Practical Approach
 
Compound components give you a clean API for complex UI structures without pushing a giant prop surface.
 
The idea is: one parent coordinates shared state and context, and child components consume it.
 
### Example: Tabs as compound components
 
```tsx
```typescript
import * as React from "react";
 
type TabsContextValue = \{
  value: string;
  setValue: (v: string) => void;
\};
 
const TabsContext = React.createContext<TabsContextValue | null>(null);
 
export function Tabs(props: \{ defaultValue: string; children: React.ReactNode \}) \{
  const [value, setValue] = React.useState(props.defaultValue);
  return <TabsContext.Provider value=\{\{ value, setValue \}\}>\{props.children\}</TabsContext.Provider>;
\}
 
export function TabsList(props: \{ children: React.ReactNode \}) \{
  return <div className="flex gap-2">\{props.children\}</div>;
\}
 
export function Tab(props: \{ value: string; children: React.ReactNode \}) \{
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("Tab must be used within Tabs");
  const active = ctx.value === props.value;
 
  return (
    <button
      type="button"
      className=\{active ? "font-semibold" : "opacity-70"\}
      onClick=\{() => ctx.setValue(props.value)\}
    >
      \{props.children\}
    </button>
  );
\}
 
export function TabsPanel(props: \{ value: string; children: React.ReactNode \}) \{
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("TabsPanel must be used within Tabs");
  if (ctx.value !== props.value) return null;
  return <div className="pt-4">\{props.children\}</div>;
\}
Plaintext
 
Usage stays readable and adaptable:
 
- add icons to `Tab` content without new props
- add badges, keyboard handling, or analytics without changing every call site
 
### When compound components are worth it
 
Use them when you have:
 
- shared state across multiple subparts
- a need for flexible layout and custom content
- repeated “component within component” patterns
 
Do not use them when a simple prop-driven component would do. Overusing context adds indirection and makes debugging harder.
 
> **⚠️ Warning:** A common pitfall is exporting the internal context or relying on context in leaf components far away from the parent. Keep compound groups tight and co-located to avoid implicit dependencies.
 
## Polymorphic Components Without API Chaos
 
Polymorphic components let consumers choose the rendered element, typically via an `as` prop. This reduces duplication like ButtonLink, ButtonAnchor, ButtonRouterLink.
 
A good polymorphic approach must keep types safe and avoid leaking implementation details.
 
### Example: A typed polymorphic Button
 
```tsx
```typescript
import * as React from "react";
 
type PropsOf<E extends React.ElementType> = React.ComponentPropsWithoutRef<E>;
 
type ButtonProps<E extends React.ElementType> = \{
  as?: E;
  variant?: "solid" | "outline";
\} & Omit<PropsOf<E>, "as" | "color">;
 
export function Button<E extends React.ElementType = "button">(
  props: ButtonProps<E>
) \{
  const \{ as, variant = "solid", className, ...rest \} = props;
  const Comp = as ?? "button";
  const base = "inline-flex items-center justify-center rounded-md px-3 py-2";
  const styles = variant === "solid" ? "bg-black text-white" : "border";
  return <Comp className=\{[base, styles, className].filter(Boolean).join(" ")\} \{...rest\} />;
\}
Plaintext
 
Usage examples:
 
- `<Button onClick={...}>Save</Button>`
- `<Button as="a" href="/pricing">Pricing</Button>`
 
### Guardrails for polymorphism
 
Polymorphism is powerful but easy to misuse. Keep it narrow:
 
- Use it for primitives like Button, Text, Box.
- Avoid `as` on complex components like DataTable or Modal.
- Enforce required accessibility props per element in documentation and tests.
 
## Theming: Tokens First, Styling Second
 
Theming is usually the difference between a “component library” and a real design system.
 
A scalable approach:
 
1) define design tokens
2) map tokens to CSS variables
3) let components use tokens, not raw colors
 
### Token model that scales
 
| Token type | Example token | Why it matters |
| --- | --- | --- |
| Color semantic | `--color-bg-surface` | Enables light and dark themes without rewriting components |
| Typography | `--font-size-sm` | Keeps text consistent across screens |
| Spacing | `--space-4` | Prevents arbitrary spacing drift |
| Radius | `--radius-md` | Aligns corners across the system |
| Shadow | `--shadow-sm` | Controls elevation consistently |
 
### Minimal CSS variables setup
 
```css
```css
:root \{
  --color-bg-surface: #ffffff;
  --color-fg: #111111;
  --space-4: 16px;
  --radius-md: 10px;
\}
 
[data-theme="dark"] \{
  --color-bg-surface: #0b0b0b;
  --color-fg: #f5f5f5;
\}
Plaintext
 
Then components reference tokens:
 
- background uses `--color-bg-surface`
- text uses `--color-fg`
- padding uses `--space-4`
 
This works with Tailwind as well, but the discipline must be enforced. If your UI uses one-off hex values or arbitrary spacing, theming will always be partial.
 
For performance, keep theming changes cheap by using CSS variables rather than rerendering component trees. This also aligns with broader [website performance optimization](https://samioda.com/en/blog/website-performance-optimization) practices, because it reduces layout thrash and JS-driven style updates.
 
## Anti-Patterns That Kill Maintainability
 
Anti-patterns are valuable because they show what to stop doing immediately.
 
### Anti-pattern 1: “God components” with dozens of props
 
Symptoms:
 
- multiple booleans that interact in weird ways
- “variant” prop with 10 options
- many props pass straight into class name logic
 
Refactor direction:
 
- split into primitives plus composed wrappers
- move layout decisions to composition slots
 
### Anti-pattern 2: Styling by copy-pasting class strings
 
If the same class list appears in 10 components, your design system already exists, but it’s implicit and ungoverned.
 
Refactor direction:
 
- extract primitives
- introduce tokens for spacing and colors
- standardize on a single source of truth for shared styles
 
### Anti-pattern 3: Mixing data fetching and UI rendering inside reusable components
 
A reusable component that calls `fetch` or reads from a feature store becomes tightly coupled to one domain.
 
Refactor direction:
 
- design system components accept data via props
- feature layer owns data fetching
- in Next.js App Router, push data fetching up to server components and pass props down
 
### Anti-pattern 4: “Wrapper components” that only rename props
 
A wrapper that simply maps `primary` to `variant="solid"` adds indirection and multiplies files.
 
Refactor direction:
 
- improve the base component API
- create composed components only when they add behavior or structure, not renaming
 
> **🎯 Key Takeaway:** If you can’t explain why a component exists in one sentence, it likely doesn’t belong in the design system.
 
## A Team Refactoring Playbook You Can Run in Sprints
 
Most teams cannot pause feature work for a full rewrite. The goal is to steadily improve architecture while shipping.
 
### Step 1: Inventory and measure duplication
 
Start with facts, not opinions:
 
- list component names that exist multiple times: Button, Modal, Card, Input
- count how many call sites each variant has
- identify UI bugs repeated across screens, especially accessibility and spacing
 
A simple heuristic: if you have 3 plus implementations of the same UI pattern, consolidate.
 
### Step 2: Define “public UI” and freeze it
 
Pick one export surface for UI usage, for example `src/ui/index.ts`, and commit to using only that in new code.
 
Then treat internal files as refactorable.
 
### Step 3: Create primitives first, then compose
 
Build or consolidate:
 
- Button
- Text
- Input
- Stack and Inline
- Icon
 
Only after primitives are stable, build composed components like Dropdown, Modal, Toast.
 
This prevents building complex components on top of unstable foundations.
 
### Step 4: Migrate incrementally with codemods and lint rules
 
You can migrate screen by screen. Enforce progress by preventing new usages of legacy components.
 
Practical enforcement ideas:
 
- eslint rule: ban imports from `src/components/legacy`
- eslint rule: enforce `src/ui` import path
- CI check: fail if new files are added to `legacy/`
 
### Step 5: Introduce theming without rewriting everything
 
Add CSS variables and map old colors gradually:
 
- start with background and text tokens
- then borders, shadows, and focus rings
- lastly semantic tokens like success, warning, danger
 
The “all at once” approach is what kills momentum.
 
### Step 6: Add documentation that answers one question per component
 
Documentation should be short and practical:
 
- what problem it solves
- typical usage
- accessibility requirements
- which props are stable and which are advanced
 
If you don’t have a docs site yet, start with `README.md` per component folder. It is better than tribal knowledge.
 
### Step 7: Add tests where failures are expensive
 
You don’t need 100 percent coverage. Focus on the components that can break many screens:
 
- Button: disabled state, focus styles
- Modal: keyboard and focus trap behavior
- Form components: labels, errors, aria attributes
 
Keep UI tests small and stable. Snapshot testing large trees tends to be noisy.
 
## Next.js-Specific Architecture Notes for Design Systems
 
### Keep most UI server-compatible
 
In Next.js App Router, a component becomes a client component if it uses hooks or event handlers. If your design system makes everything client-only, you will ship more JS than necessary.
 
A practical rule:
 
- primitives like Text, Box, Card should be server-compatible
- interactive components like Dropdown, Dialog, Tabs can be client components
- isolate interactivity in leaf components
 
This makes a measurable difference to bundle size and hydration cost, especially on content-heavy pages.
 
### Avoid implicit client boundaries
 
If a parent component is client-only, all children become client-rendered too. In design systems, that can accidentally pull large UI trees into the client bundle.
 
Keep interactive wrappers small and allow server-rendered layouts to compose them.
 
## Key Takeaways
 
- Use composition and slots to keep component APIs small and prevent variant explosion.
- Apply compound components for complex UI patterns that share state across subparts, and keep the context boundary tight.
- Use polymorphic components only for primitives, with strict guardrails to avoid a type and accessibility mess.
- Implement theming with tokens and CSS variables so themes change without rerendering component trees.
- Enforce folder boundaries and public exports with lint rules to stop cross-feature coupling and legacy creep.
- Refactor incrementally with an inventory, primitives-first consolidation, migration rules, and targeted tests.
 
## Conclusion
 
A scalable React component architecture is a set of constraints your team agrees to follow, not a one-time refactor. Start by defining boundaries, consolidate primitives, and standardize composition patterns so new features automatically align with the design system.
 
If you want help auditing your current component library, defining a migration plan, or implementing a token-based theme in a Next.js codebase, Samioda can help you ship a maintainable design system without pausing delivery. Reach out and we’ll propose a practical refactor roadmap tailored to your repo and team size.

FAQ

Share
A
Adrijan OmičevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.