Back to System Design
System Design
hard
mid

How do you structure a large scale React application?

Feature-folder structure (one folder per business domain) with explicit public APIs via index.ts barrel files. Layered concerns: app shell (routing, providers), features (business logic), shared (UI library, utilities). Boundaries enforced by lint plugins. Server state in React Query. Design system as a separate package or shared/ui folder. Tests, types, and styles colocated with components.

9 min read·~5 min to think through

Folder structure is downstream of architectural decisions. Pick the principles first.

Principles

  1. Code that changes together lives together. Tests next to components, hooks near consumers.
  2. Features have explicit boundaries. Each feature exposes a public API via index.ts; everything else is private.
  3. No circular dependencies between features. If A and B need each other, extract to shared/ or merge.
  4. Server state lives in one place (React Query). Client state stays local.
  5. The design system has no business logic. Just UI primitives.

Layout

ts
src/
├── app/                       # app shell — entry, routing, providers
│   ├── routes.tsx
│   ├── providers.tsx          # QueryClient, ThemeProvider, AuthProvider
│   └── error-boundary.tsx

├── features/
│   ├── billing/
│   │   ├── api/               # React Query hooks per resource
│   │   ├── components/        # billing-specific UI
│   │   ├── hooks/             # useInvoice, useBillingForm
│   │   ├── pages/             # route components
│   │   ├── types.ts
│   │   ├── index.ts           # PUBLIC API
│   │   └── README.md          # what this feature owns
│   ├── users/
│   ├── reports/
│   └── ...

├── shared/
│   ├── ui/                    # design system (Button, Card, ...)
│   ├── lib/                   # utilities (formatDate, parseQuery)
│   ├── hooks/                 # cross-cutting hooks (useDebounced, useMediaQuery)
│   └── types/                 # cross-feature shared types

├── styles/                    # global CSS, tokens
└── main.tsx                   # entry

Public API rule

ts
// features/billing/index.ts
export { BillingPage } from './pages/BillingPage';
export { useInvoice } from './hooks/useInvoice';
export type { Invoice } from './types';

Other features import from '@/features/billing' — never from internal paths.

Enforce with ESLint:

json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": ["@/features/*/!(index)*"]
    }]
  }
}

Or use eslint-plugin-boundaries for richer rules.

Routing

tsx
// app/routes.tsx
const Billing = lazy(() => import('@/features/billing').then(m => ({ default: m.BillingPage })));
const Users   = lazy(() => import('@/features/users').then(m => ({ default: m.UsersPage })));

<Routes>
  <Route path="/billing/*" element={<Billing />} />
  <Route path="/users/*"   element={<Users />} />
</Routes>

Each top-level feature is a separate chunk.

Server state pattern

ts
// features/billing/api/invoices.ts
export function useInvoices(filters: Filters) {
  return useQuery({
    queryKey: ['invoices', filters],
    queryFn: () => api.listInvoices(filters),
  });
}

Components never call fetch directly. The hook is the boundary.

Cross-cutting concerns

ConcernWhere
Authshared/auth + provider in app/
Themeshared/ui + provider in app/
Analyticsshared/analytics, attached to QueryClient + router events
Error boundaryone in app/ for whole app, one per page for graceful fallback
Loggingshared/log, used by api/ + components

What goes in shared/

  • Primitives: Button, Input, Dialog, Toast.
  • Utilities: date, currency, string formatting.
  • Hooks: useDebounced, useLocalStorage, useMediaQuery.
  • Types: User, Permission, Tenant — anything that crosses features.

What does NOT go in shared/:

  • Feature-specific components (InvoiceTable, UserCard).
  • Business logic.
  • API clients beyond a base fetch wrapper.

Monorepo vs single repo

For 5+ apps sharing UI, use a monorepo (turbo, pnpm workspaces, Nx). For one app, single repo with feature folders is plenty.

Migration path

Don't rewrite. Build the next feature in the new structure. After ~5 features, copy patterns and migrate the rest.

Anti-patterns

  • Type-based folders at the top (components/, hooks/, utils/) — stops scaling around 15 pages.
  • 'common/' that becomes a god folder.
  • Pages importing from each other's internals.
  • Mega-providers in app/ that re-render on every state change.

Senior framing

Folder structure is the visible expression of module boundaries. The structure should make accidental coupling expensive — that's its job. ESLint rules enforce the structure; without enforcement, the structure rots.

Follow-up questions

  • How do you enforce feature boundaries in CI?
  • When would you split into a monorepo?
  • How does this structure handle a cross-feature dashboard page?

Common mistakes

  • Type-based top-level folders — doesn't scale.
  • Skipping public-API barrels — features import each other's internals.
  • Putting feature-specific components in shared/.

Performance considerations

  • Per-feature chunking reduces initial bundle. Lazy-load feature entries. Pre-fetch on hover for the next likely navigation. Bundle budgets per feature catch regressions.

Edge cases

  • Cross-feature composition pages (dashboards) — model as a separate 'composition' feature.
  • Circular feature dependencies — extract shared concept or merge.
  • Shared state across features — usually means it belongs in shared/auth, shared/tenant, etc.

Real-world examples

  • Shopify admin, Linear, Stripe Dashboard — all publicly documented feature-folder patterns. Linear in particular has written about strict module boundaries and the tooling that enforces them.

Senior engineer discussion

Senior framing: the hard problem at scale is coordination, not framework choice. Architecture exists to make team interaction cheaper. Boundaries enforce who-changes-what; without that, even good frameworks rot.

Related questions