The first React app I worked on had three folders: components/, pages/, utils/. It worked great for about six months. Then we hit 30,000 lines of code, four engineers, and a sprawling components/ folder where nobody could find anything. Every PR moved files around. Refactoring became a hobby instead of a side effect.
The problem wasn't React. The problem was that the structure was organised by what kind of file something was, not by what feature it belonged to. Once we flipped that, half the friction disappeared. This article is the version of that lesson I wish I'd read earlier.
Type Folders Vs Feature Folders
Two competing philosophies, and only one of them scales:
type-based feature-based
src/ src/
components/ features/
Button.jsx checkout/
UserCard.jsx search/
ProductTable.jsx billing/
CheckoutForm.jsx shared/
hooks/ ui/
useUser.js hooks/
useCart.js
utils/
formatPrice.js
The type-based version looks tidier on day one. By month six, finding "everything related to checkout" means opening eight folders and grepping. A change to checkout touches components/CheckoutForm.jsx, hooks/useCart.js, utils/formatPrice.js — and you have to remember where each one lives.
Feature-based folders flip the question. Each feature owns its components, hooks, types, tests, and styles in one place:
src/features/checkout/
components/
CheckoutForm.jsx
OrderSummary.jsx
hooks/
useCart.js
api/
checkoutClient.js
types.ts
index.ts ← the public API
Now "where does the checkout code live?" has one answer. New engineers can find work without a map. Deleting a feature is one folder away. The whole shape rewards local thinking.
Co-location Is The Real Win
Co-location means: tests, styles, and small helpers live next to the file they belong to.
features/billing/components/
InvoiceTable.jsx
InvoiceTable.test.jsx
InvoiceTable.module.css
invoice-utils.js
There's no tests/ folder. No styles/ folder. If you're editing InvoiceTable.jsx, every related file is one keystroke away. When you delete the component, you delete the test in the same commit instead of leaving an orphaned file in __tests__/components/.
The rule that earned its keep: if a file is only used by one other file, it should sit next to that file. The moment a second feature needs it, it moves up to the shared layer.
The Shared Layer
The other half of the structure is the layer below features:
src/shared/
ui/ ← design system primitives (Button, Modal, Input)
hooks/ ← cross-feature hooks (useDebounce, useMediaQuery)
lib/ ← network client, logger, analytics, formatters
types/ ← cross-feature types
Everything in shared/ is allowed to be imported by anything. Everything in features/ is allowed to import from shared/ and from itself. Features should not import from other features. That's the single rule that keeps the dependency graph clean.
If features/checkout needs something from features/cart, that's a smell. Either the thing belongs in shared/, or cart should expose it through its public API and you re-think whether checkout and cart are really separate features.
Public APIs Per Feature
Every feature has an index.ts (or index.js) that re-exports only what's meant to be used from outside:
// features/checkout/index.ts
export { CheckoutForm } from './components/CheckoutForm';
export { useCart } from './hooks/useCart';
export type { Cart, CartItem } from './types';
External code imports from features/checkout, never from features/checkout/components/CheckoutForm. This single rule lets you reorganise the internals of a feature without breaking anything outside it. It's the React equivalent of a module's public interface.
Tools like ESLint's no-restricted-imports, or path-based rules in eslint-plugin-boundaries, can enforce this automatically.
Routing Mirrors The Feature Folders
If you're using Next.js App Router, React Router, or TanStack Router, the route layer is a thin shell that imports from features:
src/app/ ← Next.js App Router
checkout/
page.tsx ← imports { CheckoutForm } from '@/features/checkout'
search/
page.tsx ← imports { SearchPage } from '@/features/search'
Pages in the route folder should be small — load data, render the feature component, optionally handle errors. The actual UI lives in the feature. That separation makes it possible to render the same feature from multiple routes (e.g. /cart and /checkout/cart) without duplication.
What "Big" Means In Practice
A few rough thresholds I've seen hold up across projects:
- Up to ~5,000 lines: type-based folders are fine. Don't over-engineer.
- 5k–50k lines: feature folders start paying off. Move now or refactor later.
- 50k+ lines: feature folders are mandatory. ESLint boundary rules become genuinely useful. Some teams adopt the full Feature-Sliced Design scheme (
app→pages→widgets→features→entities→shared, where each layer can only import from the layers below it) — the most thought-out version of this idea.
Don't migrate prematurely. A three-week refactor on a 2,000-line app pays off only if you're sure it'll grow. But once it's growing, the cost of not migrating doubles every year.
Naming Matters More Than You Think
A few conventions that scale well:
- PascalCase for components —
CheckoutForm.jsx. Easy to spot at a glance. - camelCase for hooks and utils —
useCart.js,formatPrice.ts. Hooks always start withuse. - Kebab-case for routes —
/order-history. Matches URLs. - Avoid
index.jsxfor components. It's allowed, but<CheckoutForm />is much easier to find in stack traces than<index />. - Singular for entity types, plural for collections.
Cart,CartItem,cartItems.
These look small. They become important when you're skimming a stack trace at 2 a.m. Future-you will thank present-you.
A Worked Example: The Search Feature
A real-world feature, structured the way it would land in a 50k-line app:
features/search/
components/
SearchInput.jsx
SearchResults.jsx
SearchFilter.jsx
SearchInput.test.jsx
hooks/
useSearchQuery.js
useSearchHistory.js
api/
searchClient.js
types.ts
utils/
highlightMatch.js
index.ts
The public index.ts exports SearchPage and useSearchQuery — that's all. Internals are free to refactor. Tests live next to the components they test. Each file has one job. A new engineer asked to "make the search faster" goes to one folder, reads four files, and ships.
What This Doesn't Solve
Folder structure is a precondition, not a substitute. It won't fix:
- Tangled state management (covered in the state articles).
- Components that try to do everything at once (split them).
- Circular dependencies inside a feature (audit imports).
- Cross-feature coordination (lift it into a shared "orchestrator" layer or a parent route).
But getting the layout right makes all four problems easier to address, because the boundaries are visible. You can see when a feature is reaching into another's internals — it's right there in the import path.
The Smallest Set Of Rules
If you take five rules from this:
- Group by feature, not by type.
- Co-locate tests, styles, and helpers with the code they belong to.
- Each feature has a public
index.ts. External code imports only from there. - Features depend on
shared/. Features do not depend on each other. - The route layer is thin. The feature does the work.
That's most of the structural advice that's actually useful. Everything else — naming, exact folder names, whether you keep types in a types.ts or co-locate them — is taste. The five rules above survive style preferences and team rotations.





