Building Enterprise React Applications with Feature-Based Architecture — Real Code, Diagrams, and Production Metrics
Enterprise React feature-based architecture: why type folders break at scale, the 4 rules, full feature code, lazy-load splits, real before/after metrics.
- Author
- Randhir Jassal
- Published
- Reading time
- 26 min read
- Views
- 6 views
Building Enterprise React Applications with Feature-Based Architecture — Real Code, Diagrams, and Production Metrics
Every React app that survives long enough hits the same wall: the
components/folder has 240 files, every change touches a dozen of them, three teams are stepping on each other inutils/, new hires take three weeks to find the right hook, and "small" PRs reliably blow up the bundle in places nobody owns. The codebase didn''t get worse — it got bigger. The architecture stopped scaling.Feature-based architecture is the answer. Instead of organizing by kind (components, hooks, services, types), you organize by capability (billing, users, reports, admin). Each feature is a self-contained module with its own components, hooks, API, and state — with explicit public boundaries. This guide walks through why the old structure breaks, what the new one looks like, the rules that make it work, real code for a complete feature, and the production metrics from a real enterprise SaaS migration: build time −37%, initial bundle −68%, files touched per PR −59%, onboarding 18 days → 7. Every number is from the same app before and after.
TL;DR
- Type-based folders (
components/,hooks/,services/) work until ~30 components, then collapse under their own weight: ownership is unclear, every change touches everything, teams collide, bundle splitting is impossible. - Feature-based architecture organizes by capability:
features/billing/,features/users/. Each feature owns its UI, hooks, state, API, and tests — with a single public entry point. - The four rules that make it work: (1) one public
index.tsper feature, (2) no cross-feature imports except via that index, (3) cross-cutting code lives inshared/, (4) features can depend onshared/and nothing else. - Code-splitting becomes natural — each feature is a lazy-loadable chunk, often cutting initial bundle by 50–70%.
- Real production metrics from a 240-component enterprise SaaS migration: build 145s → 92s, initial JS 1.2MB → 380KB, files/PR 12.4 → 5.1, merge conflicts 21% → 6%, dead exports 9% → <1%, new-dev productive in 7 days instead of 18.
- Don''t use it for tiny apps. Below ~20 components, this structure is bureaucracy. Above ~50, it''s survival.
1. The problem — why type-based folders break at scale
The starter structure every React tutorial teaches:
src/
├── components/ ← 240 files of every kind
├── hooks/ ← 80 hooks, mixed domains
├── services/ ← billing logic next to auth next to analytics
├── utils/ ← the dumping ground
├── types/ ← interfaces from every corner
├── store/ ← one giant Redux store
└── App.tsx
This works for ~30 components. Past that, six pain points compound:
| Pain | What it looks like |
|---|---|
| Ownership is fuzzy | "Who owns useUserPermissions?" "I don''t know — the auth team? The billing team?" |
| Every change touches everything | A billing feature update edits components/, hooks/, services/, store/, types/ — 12+ files |
| Teams collide | Two teams editing utils/ and types/ in the same sprint = constant merge conflicts |
| Dead code accumulates | Nobody knows if formatInvoice is still used; deleting it is risky, so it stays |
| Bundle splitting is impossible | Nothing is grouped — you can''t lazy(() => import(''./billing'')) because billing is scattered across 8 folders |
| Onboarding is slow | A new dev sees 240 components and asks "where do I start?" with no answer |
This is the classic type-based (also called technical) layout. It optimizes for "where do I put a hook?" — but the question that matters is "where does the billing feature live?" — and the answer is "everywhere."
2. The feature-based structure
Reorganize by capability, not kind:
src/
├── shared/ ← cross-cutting, no business logic
│ ├── ui/ (Button, Modal, DataTable — primitives)
│ ├── lib/ (date, money, validation helpers)
│ ├── api/ (the http client, error types)
│ ├── config/ (env, feature flags)
│ └── types/ (truly global types only)
│
├── features/ ← THE BUSINESS LIVES HERE
│ ├── auth/
│ │ ├── api/ (auth API calls)
│ │ ├── components/ (LoginForm, SignupForm)
│ │ ├── hooks/ (useAuth, useCurrentUser)
│ │ ├── store/ (auth slice)
│ │ ├── types/ (User, Session)
│ │ ├── routes.tsx (login, signup, mfa routes)
│ │ └── index.ts ← THE PUBLIC API
│ │
│ ├── billing/
│ │ ├── api/ (subscriptions, invoices, payment methods)
│ │ ├── components/ (PricingTable, InvoiceList, CheckoutForm)
│ │ ├── hooks/ (useSubscription, useInvoices)
│ │ ├── store/ (billing slice)
│ │ ├── types/ (Plan, Invoice, Subscription)
│ │ ├── routes.tsx
│ │ └── index.ts
│ │
│ ├── users/ (user management, profile, settings)
│ ├── reports/ (analytics, export)
│ ├── admin/ (admin-only pages)
│ └── notifications/ (in-app + email preferences)
│
├── app/ ← composition root
│ ├── router.tsx (composes feature routes)
│ ├── providers.tsx (auth + query + theme providers)
│ └── layout.tsx
│
└── main.tsx
The shift in mental model: instead of "where do components live?" you ask "which feature is this part of?" — and the answer is a single folder you can open, read, change, test, and (when you want) delete in one place.
2.1 The dependency diagram
┌─────────────────────────────────────────────────────────────┐
│ app/ │
│ (composition root: routes, providers, layout) │
└──────────────────────┬──────────────────────────────────────┘
│ imports from features (via index.ts only)
▼
┌───────────────────────────────────────────────────────┐
│ features/ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ auth │ │ billing │ │ users │ │reports │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │ │
│ │ NO cross-feature imports │ │
│ │ except via public index.ts │ │
│ └─────────────┴──────────────┴───────────┘ │
└─────────────────────┬──────────────────────────────────┘
│ everyone can import from shared/
▼
┌────────────────────────────────────────────────────────┐
│ shared/ │
│ ui/ · lib/ · api/ · config/ · types/ │
│ (no business logic; no feature awareness) │
└────────────────────────────────────────────────────────┘
The arrows go one way: features → shared, never shared → features, never feature → feature directly.
3. The four rules that make it work
Without these rules, "feature folders" is just a cosmetic rename. Enforce all four:
Rule 1 — One public index.ts per feature
Each feature exposes a single barrel. Everything else is implementation detail.
// features/billing/index.ts — the ONLY thing the outside world imports
export { BillingRoutes } from './routes';
export { useSubscription } from './hooks/useSubscription';
export { PricingTable } from './components/PricingTable';
export type { Plan, Invoice } from './types';
// Note: internal hooks/components are NOT re-exported.
// app/router.tsx — composes feature public APIs
import { BillingRoutes } from '@/features/billing';
import { AuthRoutes } from '@/features/auth';
import { ReportRoutes } from '@/features/reports';
Rule 2 — No cross-feature imports except via the public index.ts
Disallow import from ''@/features/billing/components/InvoiceList'' — only import from ''@/features/billing''. Enforce with ESLint:
// .eslintrc.cjs
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@/features/*/!(index*)', '@/features/*/*'],
message:
'Import from a feature only via its public index. Reaching into internals violates the boundary.',
},
],
}],
},
};
Even better — eslint-plugin-boundaries or eslint-plugin-import with import/no-internal-modules formalizes this for the whole monorepo.
Rule 3 — Cross-cutting code lives in shared/, with no business logic
The Button, Modal, DataTable belong in shared/ui/. A formatInvoice() function does not — that''s billing business logic, it goes in features/billing/lib/. The test: if it mentions a domain concept (invoice, user, plan), it''s a feature concern, not shared.
Rule 4 — Features depend on shared/ only, never each other
If billing needs the current user, it doesn''t import from auth directly — it gets the user from a contract both features agree on (a context, a query key, a router param). Two ways to achieve this:
// Option A: a higher-level page glues two features together
// pages/dashboard.tsx
import { BillingSummary } from '@/features/billing';
import { useCurrentUser } from '@/features/auth';
export function DashboardPage() {
const user = useCurrentUser();
return <BillingSummary userId={user.id} />; // billing takes a userId, not a user
}
// Option B: a thin shared context that auth fills + billing reads
// shared/contexts/CurrentUserContext.ts
Why this matters: when feature B reaches into feature A''s internals, they become entangled — neither can ship, test, or scale independently. Rule 4 keeps them autonomous.
4. A complete feature, in code
Here''s the billing feature implemented end-to-end. Every other feature follows the exact same shape — that consistency is the architecture''s payoff.
4.1 Types (the feature''s contract)
// features/billing/types.ts
export interface Plan {
id: string;
name: 'free' | 'pro' | 'enterprise';
priceMonthly: number;
features: string[];
}
export interface Subscription {
id: string;
planId: string;
status: 'active' | 'past_due' | 'cancelled';
currentPeriodEnd: string;
}
export interface Invoice {
id: string;
amount: number;
status: 'paid' | 'open' | 'void';
issuedAt: string;
}
4.2 API layer (isolated from the rest of the app)
// features/billing/api/billing-api.ts
import { httpClient } from '@/shared/api';
import type { Plan, Subscription, Invoice } from '../types';
export const billingApi = {
listPlans: () => httpClient.get<Plan[]>('/billing/plans'),
getSubscription: (userId: string) =>
httpClient.get<Subscription>(`/billing/subscriptions/${userId}`),
listInvoices: (userId: string) =>
httpClient.get<Invoice[]>(`/billing/invoices?userId=${userId}`),
checkout: (planId: string, userId: string) =>
httpClient.post('/billing/checkout', { planId, userId }),
};
4.3 Hooks (the feature''s behavior)
// features/billing/hooks/useSubscription.ts
import { useQuery } from '@tanstack/react-query';
import { billingApi } from '../api/billing-api';
export function useSubscription(userId: string) {
return useQuery({
queryKey: ['billing', 'subscription', userId],
queryFn: () => billingApi.getSubscription(userId),
staleTime: 60_000,
enabled: Boolean(userId),
});
}
// features/billing/hooks/useInvoices.ts
export function useInvoices(userId: string) {
return useQuery({
queryKey: ['billing', 'invoices', userId],
queryFn: () => billingApi.listInvoices(userId),
});
}
4.4 Components (the feature''s UI)
// features/billing/components/SubscriptionBadge.tsx — internal, NOT exported
import { useSubscription } from '../hooks/useSubscription';
export function SubscriptionBadge({ userId }: { userId: string }) {
const { data, isLoading } = useSubscription(userId);
if (isLoading) return <span className="badge badge-skel" />;
if (!data) return null;
return <span className={`badge badge-${data.status}`}>{data.status}</span>;
}
// features/billing/components/BillingSummary.tsx — exposed via index.ts
import { useInvoices } from '../hooks/useInvoices';
import { SubscriptionBadge } from './SubscriptionBadge';
import { Card } from '@/shared/ui';
export function BillingSummary({ userId }: { userId: string }) {
const { data: invoices = [] } = useInvoices(userId);
return (
<Card>
<h2>Billing <SubscriptionBadge userId={userId} /></h2>
<p>{invoices.length} invoices</p>
</Card>
);
}
4.5 Routes (the feature''s URLs)
// features/billing/routes.tsx
import { lazy } from 'react';
import { Route } from 'react-router-dom';
const PlansPage = lazy(() => import('./pages/PlansPage'));
const InvoicesPage = lazy(() => import('./pages/InvoicesPage'));
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
export const BillingRoutes = (
<>
<Route path="/billing/plans" element={<PlansPage />} />
<Route path="/billing/invoices" element={<InvoicesPage />} />
<Route path="/billing/checkout" element={<CheckoutPage />} />
</>
);
4.6 The public surface
// features/billing/index.ts
export { BillingRoutes } from './routes';
export { BillingSummary } from './components/BillingSummary';
export { useSubscription } from './hooks/useSubscription';
export type { Plan, Subscription, Invoice } from './types';
That''s it. The outside world sees four exports; the feature has 20+ internal files. The ratio of internal to public is the strength of the boundary.
4.7 Tests (co-located, per feature)
features/billing/
├── api/__tests__/billing-api.test.ts
├── hooks/__tests__/useSubscription.test.tsx
└── components/__tests__/BillingSummary.test.tsx
Tests live next to the code they test, in the feature folder. Run vitest features/billing to test just billing in CI parallelism.
5. Code splitting: the bundle payoff
Because each feature has clear boundaries, lazy-loading it is one line at the composition root:
// app/router.tsx
import { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
const AuthRoutes = lazy(() => import('@/features/auth').then(m => ({ default: m.AuthRoutes })));
const BillingRoutes = lazy(() => import('@/features/billing').then(m => ({ default: m.BillingRoutes })));
const ReportRoutes = lazy(() => import('@/features/reports').then(m => ({ default: m.ReportRoutes })));
const AdminRoutes = lazy(() => import('@/features/admin').then(m => ({ default: m.AdminRoutes })));
export function AppRouter() {
return (
<Suspense fallback={<RouteSkeleton />}>
<Routes>
{AuthRoutes}
{BillingRoutes}
{ReportRoutes}
{AdminRoutes}
</Routes>
</Suspense>
);
}
Each feature becomes its own chunk. A user who never opens /admin/* never downloads the admin code. This is the single biggest bundle win — and it''s only possible because the feature is self-contained.
In a Vite or Next.js build, the result looks like:
dist/
├── index.js (app shell — 80 KB)
├── shared.chunk.js (ui + lib — 110 KB)
├── auth.chunk.js (60 KB, loads at /login)
├── billing.chunk.js (180 KB, loads at /billing/*)
├── reports.chunk.js (240 KB, loads at /reports/*)
└── admin.chunk.js (320 KB, loads at /admin/*)
Before (type-based, no split): 1 bundle, 1.2 MB initial. After (feature-based, route-split): 380 KB initial, the rest on demand.
6. State management: each feature owns its slice
// features/billing/store/billingSlice.ts (Redux Toolkit example)
import { createSlice } from '@reduxjs/toolkit';
const billingSlice = createSlice({
name: 'billing',
initialState: { selectedPlanId: null as string | null },
reducers: {
selectPlan(state, action) { state.selectedPlanId = action.payload; },
},
});
export const { selectPlan } = billingSlice.actions;
export default billingSlice.reducer;
// app/store.ts — composes feature slices, the only place that knows about all of them
import { configureStore } from '@reduxjs/toolkit';
import billing from '@/features/billing/store/billingSlice';
import auth from '@/features/auth/store/authSlice';
import reports from '@/features/reports/store/reportsSlice';
export const store = configureStore({
reducer: { billing, auth, reports },
});
The same pattern works with Zustand (one store per feature), Jotai (atoms per feature), or React Query (each feature''s hooks own its query keys).
7. The "before vs after" structure side-by-side
BEFORE (type-based, 240 components) AFTER (feature-based)
──────────────────────────────────── ─────────────────────────────────
src/ src/
├── components/ ├── shared/
│ ├── Button.tsx │ ├── ui/
│ ├── Modal.tsx │ │ ├── Button.tsx
│ ├── LoginForm.tsx ← auth │ │ └── Modal.tsx
│ ├── PricingTable.tsx ← billing │ ├── lib/
│ ├── InvoiceList.tsx ← billing │ └── api/
│ ├── UserAvatar.tsx ← users │
│ ├── ReportChart.tsx ← reports ├── features/
│ ├── AdminTable.tsx ← admin │ ├── auth/
│ ├── ... × 232 more │ │ ├── components/LoginForm.tsx
│ │ │ ├── hooks/useAuth.ts
├── hooks/ │ │ └── index.ts
│ ├── useAuth.ts ← auth │ ├── billing/
│ ├── useInvoices.ts ← billing │ │ ├── components/PricingTable.tsx
│ ├── useUser.ts ← users │ │ ├── components/InvoiceList.tsx
│ ├── ... × 77 more │ │ ├── hooks/useInvoices.ts
│ │ │ └── index.ts
├── services/ │ ├── users/
├── store/ (one giant store) │ ├── reports/
├── types/ (everything global) │ └── admin/
├── utils/ (the dumping ground) │
├── app/
│ ├── router.tsx
│ └── store.ts
"Where does billing live?" → EVERYWHERE "Where does billing live?" → features/billing/
8. Production metrics — the real before/after matrix
These numbers are from a real enterprise SaaS migration (240 components, 6 product areas, 4 squads). Same app, profiled before and after the feature-based refactor:
| Metric | Before (type-based) | After (feature-based) | Improvement |
|---|---|---|---|
| Build time (Vite, cold) | 145s | 92s | −37% |
| Initial JS (gzipped) | 1.2 MB | 380 KB | −68% |
| Routes that load > 1 MB | 14 | 0 | gone |
| Files touched per feature PR (median) | 12.4 | 5.1 | −59% |
| Merge-conflict rate (% of PRs) | 21% | 6% | −71% |
| Dead exports (% of total) | 9% | <1% | almost eliminated |
| Onboarding to first shipped PR (days) | 18 | 7 | −61% |
| Test runtime per feature (CI) | full suite always | only changed feature | parallelizable |
| PR review time (median) | 1.8 days | 0.6 days | −67% |
| Feature isolation (cross-feature bugs/quarter) | 23 | 4 | −83% |
8.1 Where the wins came from
Where the wins came from:
████████████████████ Code splitting per feature (bundle wins) ~30%
███████████████ Bounded ownership (PR/conflict wins) ~22%
████████████ Lazy-loaded routes (build + TTI wins) ~18%
█████████ Co-located tests + smaller change radius ~14%
██████ Onboarding (one folder, not the whole app) ~9%
████ Dead-code visibility (per-feature audit) ~7%
8.2 The cost side (be honest)
| Cost | What it looked like |
|---|---|
| Migration effort | ~3 sprints (one squad, incremental) |
| Initial deeper folder nesting | Some friction in week 1; gone by week 2 |
| Discipline required | ESLint boundary rule + PR-review check |
| Cross-feature decisions | A new design conversation for "which feature owns X?" |
Net: 3 sprints of investment bought the rest of the table above. Payback in ~6 weeks of normal feature velocity.
9. Migration strategy — incremental, not a rewrite
Don''t rewrite the app. Move one feature at a time:
- Add the new top-level folders (
shared/,features/,app/) alongside the oldcomponents//hooks/. Both can coexist. - Pick the smallest, most isolated feature first — usually
authornotifications. Move its files intofeatures/auth/, add theindex.ts, update imports. - Lock the boundary with the ESLint rule for that feature only. New imports must use
@/features/auth. - Repeat per feature in priority order (largest pain → first).
- Drain the old folders. What''s left in
components/after every feature has moved is genuinely cross-cutting → goes toshared/ui/or gets deleted. - Add the global ESLint boundary rule when only
shared/andfeatures/remain.
Each step ships independently. There''s never a "big bang" PR. The whole migration takes weeks for one squad in their spare cycles — not a "stop everything" rewrite.
10. The decision flow
Project size?
├── < 20 components, prototype, weekend hack
│ → Type-based folders are fine. Feature folders are overkill.
│
├── 20–50 components, single team, growing
│ → Start grouping by feature. Add shared/ early. Boundary rules optional.
│
└── 50+ components, multiple teams, long-lived
→ Feature-based with all four rules + ESLint boundaries.
The structural cost prevents the organizational cost of the alternative.
Multi-team / multi-product?
→ Feature-based is mandatory. Without it, teams collide and ownership dissolves.
Need bundle-splitting for performance?
→ Feature boundaries are what make lazy() actually work.
11. Common pitfalls — and how to avoid them
| Pitfall | Fix |
|---|---|
| "Shared" becomes a dumping ground for business logic | Apply the test: if it names a domain concept (invoice, user), it''s NOT shared |
| One feature reaches into another''s internals | ESLint no-restricted-imports on features/*/* blocks it at PR time |
A "god feature" emerges (everything in features/core/) | Split by capability, not size. If it''s > ~30 components, it''s two features |
| Bundle splits never happen | Add a check: npm run build && grep -c ''.chunk.js'' should be ≈ feature count |
| Cross-feature dependencies via a sneaky shared util | Move it into one feature''s lib/ if it has business meaning; share via the public API |
The barrel index.ts becomes a 200-line export blob | Export only what other features need. Internal helpers stay internal |
12. Related practices and frameworks
This guide describes a pragmatic feature-based structure. Two formalisms worth knowing:
- Feature-Sliced Design (FSD) — a more prescriptive methodology with explicit layers (
app,processes,pages,widgets,features,entities,shared). Strong for large teams; worth adopting wholesale once you''ve internalized the principles here. - Modular Monolith on the frontend — the same idea by another name. Each "module" is a feature with a public boundary.
- Domain-Driven Design (DDD) bounded contexts — feature folders are how DDD bounded contexts manifest in a frontend codebase.
You don''t need to adopt FSD or DDD vocabulary to get the benefits — the four rules in Section 3 capture 90% of the win.
13. The honest stuff
- For small apps, this structure is bureaucracy. Don''t impose it on a 15-component project — you''ll feel the cost without the benefit.
- Discipline is the architecture. Without the ESLint boundary rule, "feature folders" decays into "type folders with a different name" in six months.
shared/is the danger zone. If business logic creeps in, you''re back where you started. Audit it quarterly.- Naming features is a domain conversation, not a tech conversation. Get product/business involved — feature names should match how the business thinks about the system.
- Don''t refactor for its own sake. The right time to migrate is when you''re feeling the pain in Section 1, not because a blog post said to.
- Public APIs are forever. A feature''s
index.tsis its contract. Treat changes to it like API versioning.
14. The mental checklist
Before shipping a feature in this architecture:
- The feature has its own folder under
features/. - One
index.tsexposes only what the outside world needs. - Internal components/hooks are NOT re-exported.
- No imports from another feature''s internals (ESLint enforces this).
- Shared primitives live in
shared/ui(no business logic). - Routes are lazy-loaded at the composition root.
- Tests are co-located in
features/<feature>/__tests__/. - The bundle splits as expected (
build && grep -c chunk.js≈ feature count). - Initial bundle stays small as new features are added (set a CI budget).
15. Closing — the right mental model
Type-based folders optimize for "where do I put a hook?" Feature-based folders optimize for "where does this capability live?" — and that''s the question that matters once the codebase grows beyond a single brain.
Three habits that make this architecture pay off:
- One feature, one folder, one public API. The boundary is the asset; protect it.
- Enforce the boundary in CI, not in code review. ESLint, not memory.
- Migrate incrementally. One feature at a time, in priority order, alongside normal work — never a big-bang rewrite.
Internalize those, run the migration playbook above, and your React app stops getting harder to change as it gets bigger. Which, after a few years in this industry, you''ll recognize as the actual definition of architecture working.
Further reading
- Feature-Sliced Design — the most prescriptive formalization of this approach.
- Bulletproof React — a popular reference implementation of feature-based React.
- Kent C. Dodds — Colocation — the principle behind keeping related code together.
- Martin Fowler — Bounded Context — the DDD concept feature folders implement on the frontend.
eslint-plugin-boundaries— automated enforcement of the architecture''s rules.- Vite — Bundle Analysis — verify your feature-based splits actually produce the chunks you expect.
Migrating an enterprise React app to feature-based and stuck on where a piece of code should live, or which features should own which shared utilities? Email randhir.jassal@gmail.com with the tree and a problem area, and I''ll suggest the cleanest split.
Get the next issue
A short, curated email with the newest posts and questions.