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.
Folder structure is downstream of architectural decisions. Pick the principles first.
Principles
- Code that changes together lives together. Tests next to components, hooks near consumers.
- Features have explicit boundaries. Each feature exposes a public API via index.ts; everything else is private.
- No circular dependencies between features. If A and B need each other, extract to shared/ or merge.
- Server state lives in one place (React Query). Client state stays local.
- The design system has no business logic. Just UI primitives.
Layout
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 # entryPublic API rule
// 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:
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": ["@/features/*/!(index)*"]
}]
}
}Or use eslint-plugin-boundaries for richer rules.
Routing
// 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
// 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
| Concern | Where |
|---|---|
| Auth | shared/auth + provider in app/ |
| Theme | shared/ui + provider in app/ |
| Analytics | shared/analytics, attached to QueryClient + router events |
| Error boundary | one in app/ for whole app, one per page for graceful fallback |
| Logging | shared/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.