When "Just Build It" Stops Working
Small frontends don't need architecture. A components/ folder and a pages/ folder will carry you surprisingly far. But as teams and codebases grow, the absence of deliberate architecture creates compounding pain: import cycles, ambiguous ownership, components that grew to 2000 lines, "which store module does this logic belong in?"
The goal of frontend architecture is not to create bureaucracy — it's to provide guardrails that make the right decision the easy decision.
The Problem with "Components and Utils"
The most common folder structure is also the worst-scaling one:
src/
├── components/ ← Everything UI-related
├── hooks/ ← All custom hooks
├── utils/ ← Helper functions
├── store/ ← State management
├── types/ ← TypeScript types
└── pages/ ← Pages/routes
What's wrong:
- A single
components/folder might have 200+ files — no guidance on which components depend on what - Cross-component imports create cycles: UserCard imports from ProductCard imports from UserCard
- No co-location: UserCard.tsx, useUser.ts, userTypes.ts, userStore.ts are in four different folders
- No clear boundaries between "global" and "feature-specific" code
Feature-Sliced Design (FSD)
FSD provides a consistent structure based on the layer and purpose of code:
src/
├── app/ # App-wide setup: providers, router, global styles
├── pages/ # Route-level components (compose features)
├── widgets/ # Large composite blocks (Header, Sidebar, Feed)
├── features/ # User interactions with business value
│ ├── auth/
│ ├── user-profile/
│ └── product-cart/
├── entities/ # Business entities (User, Product, Order)
│ ├── user/
│ ├── product/
│ └── order/
└── shared/ # Reusable: UI kit, utils, API clients
├── ui/
├── api/
├── lib/
└── config/
The dependency rule: layers can only import from layers below them. pages imports from widgets and features. features imports from entities and shared. shared imports nothing from above.
// ✅ Valid imports (downward):
// pages/ProductPage.tsx imports from widgets/ProductCard
// features/auth/LoginForm.tsx imports from entities/user
// entities/user/api.ts imports from shared/api
// ❌ Invalid imports (upward) — violations these are bugs:
// shared/ui/Button.tsx imports from features/auth ← NEVER
// entities/user imports from features/auth ← NEVER
Feature Slice Structure
Each feature slice is self-contained:
features/auth/
├── ui/ # UI components specific to auth
│ ├── LoginForm.tsx
│ ├── RegisterForm.tsx
│ └── AuthProvider.tsx
├── model/ # State, types, hooks
│ ├── authStore.ts # Zustand/Redux slice
│ ├── useAuth.ts # Custom hooks
│ └── types.ts
├── api/ # API calls for this feature
│ └── authApi.ts
└── index.ts # Public API — export only what others need
// features/auth/index.ts — explicit public API
export { LoginForm } from './ui/LoginForm'
export { AuthProvider } from './ui/AuthProvider'
export { useAuth } from './model/useAuth'
export type { User, AuthState } from './model/types'
// Everything NOT exported here is private to the feature
// Other features can't import AuthStore internals directly
Design Tokens: The Foundation of Consistent UI
// tokens.ts — single source of truth for visual properties
export const tokens = {
color: {
// Primitives (raw values)
gray: {
50: '#F9FAFB',
100: '#F3F4F6',
500: '#6B7280',
900: '#111827',
},
blue: {
500: '#3B82F6',
700: '#1D4ED8',
},
red: {
500: '#EF4444',
},
// Semantic (purpose-based — what tokens actually mean)
text: {
primary: '#111827', // gray-900
secondary: '#6B7280', // gray-500
disabled: '#D1D5DB',
inverse: '#FFFFFF',
error: '#EF4444',
},
background: {
default: '#FFFFFF',
subtle: '#F9FAFB',
elevated: '#FFFFFF',
},
border: {
default: '#E5E7EB',
strong: '#9CA3AF',
focus: '#3B82F6',
},
interactive: {
primary: '#3B82F6',
primaryHover: '#2563EB',
primaryActive: '#1D4ED8',
},
},
spacing: {
1: '4px',
2: '8px',
3: '12px',
4: '16px',
6: '24px',
8: '32px',
12: '48px',
16: '64px',
},
typography: {
fontFamily: {
sans: "'Inter', system-ui, sans-serif",
mono: "'Fira Code', 'Consolas', monospace",
},
fontSize: {
xs: '12px',
sm: '14px',
base: '16px',
lg: '18px',
xl: '20px',
'2xl': '24px',
'3xl': '30px',
},
fontWeight: {
regular: 400,
medium: 500,
semibold: 600,
bold: 700,
},
lineHeight: {
tight: 1.25,
normal: 1.5,
relaxed: 1.75,
},
},
borderRadius: {
sm: '4px',
md: '8px',
lg: '12px',
full: '9999px',
},
shadow: {
sm: '0 1px 2px rgba(0, 0, 0, 0.05)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
},
}
// CSS custom properties (automatically generated from tokens)
export function generateCSSVariables(): string {
return `
:root {
--color-text-primary: ${tokens.color.text.primary};
--color-text-secondary: ${tokens.color.text.secondary};
--color-interactive-primary: ${tokens.color.interactive.primary};
--spacing-4: ${tokens.spacing[4]};
/* ... */
}
`
}
Component API Design
// ❌ Implicit API — unclear what's valid
interface ButtonProps {
variant?: string // What are the valid values?
size?: string // Same problem
color?: string // Unlimited combinations — maintenance nightmare
onClick?: () => void
disabled?: boolean
className?: string // Escape hatch that lets you break anything
}
// ✅ Explicit, constrained API
interface ButtonProps {
variant: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
// No className — use the variant system instead
// No style — forces use of the design system
// Children only: React.ReactNode
children: React.ReactNode
}
function Button({ variant, size = 'md', disabled, loading, ...props }: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size })} // cva or class-variance-authority
disabled={disabled || loading}
{...props}
>
{loading ? <Spinner /> : props.children}
</button>
)
}
State Architecture Decision Framework
Server state (from API) → React Query / SWR
- Caching, background refetching,
optimistic updates
Local UI state → useState + useReducer
- Form state, modal open/close,
UI-only toggles
Shared client state (cross-component) → Zustand / Jotai
- User preferences, cart, selected items
- Should be rare — most state is local
URL state → URL search params
- Filter values, pagination, sort order
- Benefits: shareable links, back button
Form state → React Hook Form / Formik
- Complex forms with validation
Global server session → React Context (rarely)
- Current user, feature flags
- Only when SSR is involved
// Example: Correct state placement
function ProductList() {
// Server state: React Query manages caching, loading, error
const { data: products, isLoading } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
})
// URL state: filters survive page refresh and are shareable
const [searchParams, setSearchParams] = useSearchParams()
const filters = {
category: searchParams.get('category') ?? 'all',
sort: searchParams.get('sort') ?? 'newest',
page: Number(searchParams.get('page') ?? 1),
}
// Local UI state: panel open/close doesn't need URL
const [filtersOpen, setFiltersOpen] = useState(false)
// Global state: cart is shared, but from Zustand
const addToCart = useCartStore(state => state.addItem)
// ...
}
Micro-Frontends: When to Consider Them
Micro-frontends split a large frontend into independently deployable pieces, each owned by a different team:
// Module Federation (Webpack/Vite)
// Host app (shell) that loads remote apps at runtime
// webpack.config.js (host)
new ModuleFederationPlugin({
remotes: {
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
},
})
// Use in host:
const CheckoutFlow = React.lazy(() => import('checkout/CheckoutFlow'))
const ProductCatalog = React.lazy(() => import('catalog/ProductCatalog'))
When to use micro-frontends:
- 10+ frontend developers on the same codebase
- Teams need independent deployment pipelines
- Different sections use different frameworks
- Clear, stable domain boundaries exist
When NOT to use them:
- 1-3 developer teams (coordination overhead > benefit)
- No clear domain boundaries
- Performance is a concern (multiple bundle loads)
- You're starting a greenfield project
The most important architecture decision is establishing clear rules before the codebase grows. Retrofitting architecture into a 100,000-line codebase is far more painful than starting with sensible conventions.
→ Explore and navigate complex JSON structures with the JSON Viewer.