# 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:
| Area | Folder | What goes here | Dependency rule |
|---|---|---|---|
| Design system primitives | src/ui/primitives/ | Button, Input, Text, Stack, Icon | No app imports |
| Composed design system components | src/ui/components/ | Modal, Dropdown, DatePicker, DataTable | Can use primitives |
| Feature UI | src/features/*/components/ | Screen-specific components | Can use src/ui/* |
| Feature logic | src/features/*/hooks/ | Hooks for feature state, data | No src/ui imports required |
| App routes | src/app/ | Next.js routes and layouts | Use features and ui |
| Shared utilities | src/lib/ | fetchers, formatters, logging | No React imports ideally |
Export strategy matters as much as folders.
src/ui/index.tsexports stable public UI APIs.- Avoid deep imports like
src/ui/primitives/button/Button.tsxbecause 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/billingimportingfeatures/authcomponents 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.
```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>
);
\}
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>;
\}
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\} />;
\}
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;
\}
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
More in Web Development
All →Next.js Authentication in 2026: NextAuth vs Clerk vs Supabase (What We Use for Client Projects)
A practical comparison of Next.js authentication options in 2026 — NextAuth, Clerk, and Supabase — across UX, security, cost, setup time, and enterprise requirements, with decision matrices for SaaS, internal tools, and B2B portals.
Next.js App Router Migration Checklist (From Pages Router) + Common Pitfalls
A practical, step-by-step Next.js App Router migration plan from Pages Router, including a checklist for routing, data fetching, SEO metadata, deployment, and a troubleshooting guide for common pitfalls.
Tailwind CSS Best Practices: Building Maintainable UIs (Production Patterns for 2026)
A production-focused guide to tailwind CSS best practices: component patterns, custom config, dark mode, responsive strategy, and copy‑pasteable examples for maintainable UIs.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js App Router Migration Checklist (From Pages Router) + Common Pitfalls
A practical, step-by-step Next.js App Router migration plan from Pages Router, including a checklist for routing, data fetching, SEO metadata, deployment, and a troubleshooting guide for common pitfalls.
Tailwind CSS Best Practices: Building Maintainable UIs (Production Patterns for 2026)
A production-focused guide to tailwind CSS best practices: component patterns, custom config, dark mode, responsive strategy, and copy‑pasteable examples for maintainable UIs.
Web Application Security Checklist for 2026 (Next.js + OWASP Top 10)
A practical web application security checklist for 2026, focused on Next.js: OWASP Top 10 coverage, authentication, input validation, CSP, HTTPS, and production-ready headers.