TL;DR
- The classic
components/hooks/utilsstructure often breaks down as the codebase and team grow - Feature-based architecture organizes code by domain responsibility, not by technical type
- Each feature should expose a small public API, while keeping implementation details internal
- A lightweight
features/core/layer can hold reusable domain-aware slices shared across features - Full features should stay isolated from sibling features
- The key rule is simple: avoid deep imports and keep dependency direction explicit
The Problem With the “Standard” Structure
A lot of React projects start the same way:
src/
components/
hooks/
utils/
pages/
store/
At first, it feels natural. But after a few months, especially in a team environment, the structure starts working against you.
The components/ folder becomes a mix of reusable UI primitives, domain-specific widgets, and half-page composites. hooks/ collects everything from generic helpers to business-critical orchestration. utils/ becomes a dumping ground. Refactoring one feature often means touching files spread across five or six unrelated directories.
The real issue is not the folder names themselves. The issue is that the codebase is organized by technical role instead of domain ownership.
That usually creates a few recurring problems:
- feature logic is scattered across the tree
- boundaries between reusable and domain-specific code become blurry
- onboarding gets slower because developers need to learn the whole project structure before changing one feature
- hidden dependencies accumulate over time
- deleting or isolating a feature becomes risky
A structure like this may be acceptable for a small app or a solo prototype. But once the application grows, it often stops being a source of clarity and starts becoming a source of entropy.
How a Codebase Typically Evolves
It helps to see this as a progression rather than a one-time decision.
Stage 1 — The classic structure starts to crack
Imagine a users domain. In a classic structure, its pieces live like this:
src/
components/
UserAvatar.tsx ← domain UI buried among generic components
Button.tsx
Modal.tsx
hooks/
useCurrentUser.ts ← domain hook mixed with generic helpers
useDebounce.ts
store/
userSlice.ts ← domain state alongside unrelated slices
cartSlice.ts
utils/
formatUserName.ts ← domain util in a generic dumping ground
At some point, the orders feature needs UserAvatar. Since there is no clear boundary, it imports it directly:
import { UserAvatar } from '@/components/UserAvatar'
That import works today. But now orders is coupled to the internal path of user-related code. If the file moves, or UserAvatar gains a domain-specific dependency — orders breaks too.
Stage 2 — Extract the first feature
The fix is to move everything belonging to users into one place:
features/
└── users/
├── components/
│ └── UserAvatar/
├── hooks/
│ └── useCurrentUser/
├── store/
├── types/
└── index.ts ← exports only what consumers should use
Now orders imports through the public API:
import { UserAvatar } from '@/features/users'
The internal layout of users is now free to change without breaking anything outside.
Stage 3 — Cross-feature reuse forces a new layer
After a few more features are migrated, a new problem appears.
UserAvatar is now also needed in catalog. And PermissionGate — originally written inside orders — is needed in both catalog and users.
The naive fix would be direct sibling imports:
// Inside catalog — avoid this
import { UserAvatar } from '@/features/users'
import { PermissionGate } from '@/features/orders'
That creates hidden coupling between features. catalog now depends on orders for a UI component. The dependency graph starts getting tangled.
The right move is to extract these shared domain pieces into features/core/:
features/
core/
userAvatar/
components/
index.ts
permissions/
components/
hooks/
index.ts
users/
catalog/
orders/
Now catalog and orders import from features/core/ instead:
import { UserAvatar } from '@/features/core/userAvatar'
import { PermissionGate } from '@/features/core/permissions'
This is the key insight: features/core/ is not a layer you design on day one. It emerges naturally once real cross-feature reuse appears and you want to avoid sibling feature dependencies.
The Core Idea
The principle behind feature-based architecture is straightforward:
Group code by domain, not by technical role.
That means everything related to a feature lives together:
- UI
- hooks
- state
- types
- API calls
- feature-specific helpers
Instead of asking:
Is this a hook or a component?
you start asking:
Which domain does this belong to?
That shift changes the way the project evolves. A feature stops being spread across the codebase and becomes a coherent slice with its own internal structure and boundaries.
A Practical Top-Level Structure
A reasonable React application often ends up with five top-level layers.
The dependency flow always moves downward — layers can only import from layers below them, never above:
flowchart TD
App["app/"] --> Pages["pages/"]
Pages --> Features["features/"]
Features -.-> Core["features/core/"]
Features --> Shared["shared/"]
Core --> Shared
Here is how it maps to folders:
src/
├── app/
├── pages/
├── features/
│ ├── core/
│ └── ...
└── shared/
app/
Application bootstrap and composition only:
- providers
- router setup
- theme setup
- global styles
- app-level initialization
This layer should stay infrastructural. It is not the right place for domain logic.
pages/
Route-level composition.
This layer acts as a bridge between the router and the feature modules. Its responsibility is to orchestrate multiple features and prepare page-level components that are attached to the routing layer.
Pages should usually stay thin. They may work with route params, page layout, and feature composition, but they should not become a second business layer containing domain logic that belongs in features/.
A typical page can stay this simple:
import { useParams } from 'react-router-dom'
import { PageLayout } from '@/shared/ui/PageLayout'
import { ProductDetails } from '@/features/catalog'
import { Recommendations } from '@/features/core/recommendations'
export function ProductPage() {
const { id } = useParams()
return (
<PageLayout>
<ProductDetails productId={id} />
<Recommendations context="product" referenceId={id} />
</PageLayout>
)
}
features/
The main domain layer.
This is where business capabilities live:
- auth
- orders
- catalog
- profile
- reports
- notifications
Each feature owns its own logic and internal implementation details.
features/core/
Not everything belongs in shared/, and not everything should live inside a single feature.
features/core/ is a lightweight domain-oriented layer for reusable business slices that are too specific for shared/, but still broad enough to be used across multiple features.
If your team dislikes the name core, that is fine. Names like domain-shared/ or common-domain/ can express the same idea. The important part is the rule behind the layer, not the label.
Typical examples might include:
- domain-aware UI fragments
- permission-related building blocks
- reusable business components
- small domain hooks or selectors
- common feature contracts and lightweight models
This layer must stay lightweight. It should not contain page orchestration, route wiring, or dependencies on full feature implementations.
shared/
Cross-cutting building blocks with no domain ownership:
- design-system primitives
- API client
- low-level utilities
- generic hooks
- global types
A good rule of thumb: if the code starts knowing too much about a business domain, it probably does not belong in shared/.
Why features/core/ Exists
By this point, the pattern should be familiar: once a domain-aware module needs to be reused by multiple features, but is still too business-specific for shared/, a separate cross-feature layer becomes useful.
That is the role of features/core/: a controlled dependency boundary for reusable business slices that should not stay inside one concrete feature, but should also not leak into shared/.
What a Feature Looks Like
Inside a feature, you can still organize code by role — but only inside that feature boundary.
features/
└── users/
├── components/
├── containers/
├── hooks/
├── store/
├── types/
└── index.ts
That gives you two benefits at once:
- the feature remains self-contained
- the code inside the feature is still easy to navigate
A common interpretation might look like this:
-
containers/— orchestration, data loading, state wiring, side effects -
components/— presentational UI with explicit props -
hooks/— feature-level logic and derived behavior -
store/— local feature state or query keys -
types/— feature-owned contracts -
index.ts— public API of the feature
This is not the only valid internal layout, but it is a practical one because it keeps the feature cohesive without flattening everything into one giant folder.
Public API Matters More Than Folder Names
The most important part of this architecture is not whether you use containers/ or components/.
It is this:
A feature should expose a small, intentional public surface.
That is what index.ts is for.
Instead of importing internals directly:
import { UserAvatar } from '@/features/users/components/UserAvatar/UserAvatar'
you import through the feature’s public API:
import { UserAvatar } from '@/features/users'
This gives you a real boundary.
It means:
- consumers do not depend on internal file layout
- internals can be refactored more safely
- the feature communicates what is public and what is private
- accidental coupling becomes easier to spot
The goal is not “barrel files everywhere” as a stylistic preference.
The goal is to define module boundaries.
Example Structure
Here is a realistic example:
src/
├── app/
│ ├── App.tsx
│ ├── Router.tsx
│ └── providers/
│ ├── QueryProvider.tsx
│ ├── ThemeProvider.tsx
│ └── StoreProvider.tsx
│
├── pages/
│ ├── catalog/
│ │ └── CatalogPage.tsx
│ ├── product/
│ │ └── ProductPage.tsx
│ └── cart/
│ └── CartPage.tsx
│
├── features/
│ ├── core/
│ │ ├── permissions/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── types/
│ │ │ └── index.ts
│ │ ├── statusBadge/
│ │ │ ├── components/
│ │ │ ├── types/
│ │ │ └── index.ts
│ │ ├── contracts/
│ │ │ ├── types/
│ │ │ └── index.ts
│ │ └── index.ts
│ │
│ ├── users/
│ │ ├── components/
│ │ │ └── UserMenu/
│ │ ├── containers/
│ │ │ └── UserProfile/
│ │ ├── hooks/
│ │ │ └── useCurrentUser/
│ │ ├── types/
│ │ ├── store/
│ │ └── index.ts
│ │
│ ├── catalog/
│ │ ├── components/
│ │ ├── containers/
│ │ ├── hooks/
│ │ ├── types/
│ │ └── index.ts
│ │
│ └── orders/
│ ├── shared/
│ ├── checkout/
│ ├── payment/
│ ├── history/
│ └── index.ts
│
└── shared/
├── api/
├── config/
├── lib/
├── types/
└── ui/
The key point is not the exact naming. The key point is that orders, users, and catalog are treated as domain slices, not as scattered file fragments, while features/core/ holds reusable domain-aware pieces that are safe to share.
Dependency Rules Are What Make This Work
A feature-based structure only scales if dependency direction stays disciplined.
A useful mental model is this:
-
componentsshould not know about feature orchestration -
hooksshould not import UI -
typesshould stay lightweight and dependency-poor - cross-feature imports should go through public APIs or
features/core/ -
shared/should not absorb domain code
Within a feature, a container importing hooks and components is fine:
import { useOrderSummary } from '../hooks'
import { OrderSummaryView } from '../components'
import type { Order } from '../types'
But a component reaching into store, router, or data-fetching logic is often a sign that the layer boundary is getting blurry.
That said, “components must never use hooks” is too absolute.
A presentational component may still reasonably use UI-level hooks like:
useIduseMemouseRef- measurement hooks
- accessibility hooks
A better rule is:
Components should avoid domain side effects, data loading, store access, and cross-feature orchestration.
That keeps the architecture pragmatic instead of dogmatic.
These internal dependency rules are not meant to be absolute laws. They are a practical baseline that teams can adapt depending on the complexity of their UI, state, and data flow model.
Isolating Features From Each Other
One of the most important architectural rules is this:
Full feature modules should not know about sibling features.
In practice, that means:
-
features/orders/should not import fromfeatures/catalog/ -
features/catalog/should not import fromfeatures/users/ -
features/users/should not import fromfeatures/orders/
Direct feature-to-feature dependencies create hidden coupling and make the import graph harder to control.
This rule applies to sibling top-level features. Subfeatures inside one bounded feature, such as features/orders/checkout and features/orders/payment, are a different case because they still belong to the same domain slice.
If some reusable domain logic or UI must be shared across multiple features, it should move into features/core/, not into a sibling feature.
That gives you a cleaner dependency direction:
-
features/xxxmay import fromshared/ -
features/xxxmay import fromfeatures/core/ -
features/core/*may import fromshared/ -
features/core/*must not depend on full features -
features/xxxmust not import sibling features directly
In other words, full features stay isolated, and cross-feature reuse goes through a lightweight core domain layer.
Guardrails for Keeping features/core/ Clean
This is the most sensitive part of the model.
If left unmanaged, features/core/ can easily turn into a second shared/ or a new dumping ground for everything that feels “kind of reusable.”
That is exactly what should be avoided.
A few guardrails help keep the layer healthy:
- only move code into
features/core/when it is genuinely reusable across multiple features - keep it lightweight and domain-aware, but not orchestration-heavy
- do not place page assembly, route wiring, or full feature flows there
- do not let
features/core/depend on concrete feature implementations - if a module still carries too much feature-specific behavior, keep it inside the feature
A useful test is this:
If a module cannot be reused by at least two features without dragging feature-specific orchestration with it, it probably does not belong in
features/core/.
This layer should solve cross-feature reuse, not hide poor boundaries.
For example, a price formatter that only makes sense inside catalog should usually stay in features/catalog/, even if another team is tempted to reuse it once. A single reuse request is not enough reason to turn a feature detail into a cross-feature dependency.
In practice, this layer survives only when teams protect it with clear ownership and strict code review. Without that discipline, features/core/ can degrade into an unstructured catch-all faster than almost any other part of the codebase.
Inside features/core/, it is perfectly fine to reuse the same internal folder anatomy as in regular features, such as components/, hooks/, store, or types.
The important distinction is that core/ should not behave like one large feature-level container. Instead, it should consist of small, focused, reusable domain slices, each with its own local structure and public API.
For example, this is usually a good direction:
features/
core/
permissions/
components/
hooks/
types/
index.ts
statusBadge/
components/
types/
index.ts
contracts/
types/
index.ts
This keeps core/ modular and explicit. The problem is not repeating folder names like components/ or hooks/. The real risk is turning core/ into a monolithic catch-all such as core/components, core/hooks, or core/types filled with unrelated code.
Large Features and Subfeatures
Not every feature needs subfeatures.
In fact, most features should start flat.
But once a feature becomes large enough to contain several distinct areas, splitting it into subfeatures helps keep the import graph clearer.
For example:
features/
└── orders/
├── shared/
├── checkout/
├── payment/
├── history/
└── index.ts
This is useful when those areas:
- have different flows
- own different UI and hooks
- evolve independently
- share some internal primitives, but not everything
A structure like this can reduce the chance of circular dependencies and boundary leakage, especially if the root feature API stays small and the subfeatures do not import each other’s internals.
I would avoid claiming that this makes cycles “physically impossible.” It does not. But it does make the dependency graph easier to reason about and significantly reduces the likelihood of accidental coupling when paired with import restrictions.
Import Rules That Age Well
A practical rule set looks like this:
Good
import { UserAvatar } from '@/features/users'
import { CheckoutStepper } from '@/features/orders/checkout'
import { PermissionGate } from '@/features/core/permissions'
import { Button } from '@/shared/ui/Button'
Avoid
import { UserAvatar } from '@/features/users/components/UserAvatar/UserAvatar'
import { PaymentBadge } from '@/features/orders/payment/components/PaymentBadge'
import { ProductCard } from '@/features/catalog/components/ProductCard'
Why this matters:
- internal layout can change without breaking consumers
- refactors stay local
- ownership is clearer
- dependency review becomes easier in PRs
This is one of the rare rules that is both simple and genuinely high-value:
Do not deep-import into another feature, and do not depend on sibling features directly.
What This Architecture Improves in Practice
When implemented with discipline, feature-based architecture tends to improve several day-to-day problems.
1. Locality
Everything related to one domain capability lives in one place.
A developer working on checkout should not need to jump across six top-level folders to understand one flow.
2. Encapsulation
Feature internals stay private unless intentionally exported.
That makes refactors less fragile.
3. Onboarding
New developers can reason about one domain slice at a time instead of learning the entire technical tree upfront.
4. PR Scope
Changes are more likely to stay inside one bounded area.
That makes reviews easier and safer.
5. Parallel Work
Teams working across different features collide less often when boundaries are real.
6. Safer Cross-Feature Reuse
Reusable business pieces move into features/core/ instead of leaking through sibling feature dependencies.
7. Deletion and Extraction
A well-isolated feature is easier to remove, replace, or extract into a separate package or app section.
Not trivial. Not always one command. But definitely easier than in a scattered structure.
The Trade-Offs
This is the part many architecture articles skip.
Feature-based architecture is useful, but it is not free.
More structure upfront
You need to define boundaries deliberately instead of letting the project grow organically.
That requires judgment.
More layers to keep clean
Once features/core/ exists, somebody needs to protect it from becoming a second shared/ dumping ground.
More files
If every small piece gets its own folder, index.ts, types.ts, utils.ts, and so on, the structure can become too granular.
What was meant to improve clarity can become ceremony.
More conventions to enforce
Without lint rules and review discipline, people eventually start bypassing the boundaries:
- deep imports
- sibling feature dependencies
-
shared/becoming a junk drawer -
features/core/becoming an unbounded catch-all
Public APIs require maintenance
Once a feature has an explicit public surface, somebody has to own it.
That is a good thing, but it still has a cost.
Not every project needs it
For a small product, internal tool, or early MVP, this architecture may be more formal than the project actually needs.
So the right question is not:
Is feature-based architecture good?
The right question is:
Is the complexity of this codebase high enough to justify stronger boundaries?
When This Approach Fits Best
This architecture tends to work especially well when:
- the app has multiple business domains
- several developers work in parallel
- the project will live for a long time
- the team cares about ownership and maintainability
- feature boundaries are more important than technical grouping
It is particularly effective in products where business workflows keep growing:
- admin platforms
- dashboards
- e-commerce apps
- SaaS products
- enterprise frontends
- large internal tools
When I Would Not Reach for It First
I would be more cautious in cases like:
- a small solo project
- an MVP still searching for product shape
- a tiny marketing app
- a codebase with very little domain depth
- a design-system-heavy app where feature boundaries are minimal
In those cases, a lighter structure may be enough.
Architecture should reduce friction, not introduce it prematurely.
Migrating an Existing Project
You do not need a big-bang rewrite.
A gradual migration is usually the better choice.
Step 1 — Define aliases
Set up imports like:
{"compilerOptions":{"baseUrl":".","paths":{"@/features/*":["src/features/*"],"@/shared/*":["src/shared/*"],"@/pages/*":["src/pages/*"]}}}
This makes future boundaries clearer from the start.
Step 2 — Create shared/
Move only truly generic pieces first:
- UI primitives
- API client
- cross-app utilities
- generic hooks
This is usually the safest extraction.
Step 3 — Migrate one small feature
Choose an isolated domain and move it completely.
Do not redesign the whole project at once.
Step 4 — Add public APIs
Introduce index.ts at the feature boundary and stop deep imports from outside.
Step 5 — Introduce features/core/ when cross-feature reuse appears
At this point, if two or more features need the same domain piece, move it to features/core/ rather than creating a sibling feature dependency.
Extract only those business-oriented pieces that are lightweight, reusable, and needed across multiple features.
Do not move orchestration there. Do not move full feature flows there.
Step 6 — Enforce rules
Use ESLint to restrict cross-feature deep imports and sibling feature dependencies.
Tools like eslint-plugin-boundaries or linters from the Feature-Sliced Design ecosystem are excellent choices for this. You can configure them to strictly forbid a feature from importing another sibling feature, or to block deep paths.
Without enforcement, the architecture becomes a suggestion instead of a boundary.
Rules Worth Keeping
If I had to reduce the whole approach to a few durable rules, I would keep these:
1. Organize by domain ownership
A feature should own its UI, hooks, state, types, and logic.
2. Keep public APIs small
Do not expose everything by default.
3. Avoid deep imports across feature boundaries
Import from the feature, not from its internals.
4. Keep full features isolated
Feature modules should not know about sibling features.
5. Use features/core/ for lightweight reusable domain slices
If cross-feature reuse is needed, route it through a controlled core domain layer rather than direct feature-to-feature imports.
6. Keep shared/ free from business logic
Infrastructure belongs in shared/. Domain behavior belongs in features/ or features/core/.
7. Start simple, split later
Do not introduce subfeatures too early. Add them when the feature actually becomes too large or too mixed.
One Important Caveat
Folder structure alone does not solve architecture.
A codebase can still become chaotic even with beautiful feature folders if:
- ownership is unclear
- review discipline is weak
- shared boundaries are ignored
- abstractions are introduced too early
- business logic leaks through “helper” files
The structure helps, but the real value comes from the dependency discipline behind it.
That is what makes the difference between a folder convention and an actual architecture.
Relation to Feature-Sliced Design (FSD)
If this structure feels familiar, it is because it shares its philosophy with Feature-Sliced Design (FSD).
What it keeps from FSD:
- explicit boundaries between slices
- public APIs instead of deep imports
- strong dependency direction
What it simplifies:
- fewer top-level layers to explain
- no separate
entitiesandwidgetslayers - a more pragmatic path for teams that want the benefits without the full model
In that sense, this is not an alternative to FSD as much as a lighter interpretation of the same core instinct: make boundaries visible, and make dependency direction intentional.
Conclusion
Feature-based architecture is not a magic formula, and it is not the only valid way to structure a React codebase.
But for growing applications, it solves a real problem: it aligns the codebase with the shape of the business domain and makes dependency direction more explicit.
That gives you something more valuable than neat folders.
It gives you:
- clearer ownership
- safer refactoring
- smaller dependency surfaces
- better onboarding
- less structural drift over time
- more controlled cross-feature reuse
The goal is not to create the “perfect” folder tree.
The goal is to make the codebase easier to understand, change, and scale.
If your current structure is already making feature work feel scattered, this approach is worth serious consideration.
Start with one feature. Keep the boundaries real. Let the structure prove itself in practice.
Acknowledgment
I’d like to thank my team and my team lead for the inspiration behind this article. Many of the ideas in it were shaped through engineering discussions, collaboration, and practical experience.
Discussion
How do you handle feature boundaries in React projects? Do you prefer a flatter feature model, or do you introduce a lightweight core domain layer for safe cross-feature reuse?