Feature-Sliced Design for React: Layers, Import Rules and When to Stop
Learn how Feature-Sliced Design's layers, one-way import rule, thin public APIs and @x cross-imports untangle React codebases, and when to adopt only part of it.
This article is published in English.
Sources: Feature-Sliced Design in React: How to Structure a Scalable Codebase (and When It’s Overkill)
Almost every React codebase that survives past its second year has a utils/ folder nobody wants to open. Inside sits a date formatter, a retry wrapper, half a state machine and a helpers.ts that imports from three feature folders while two of those folders import it back, so nobody can say what depends on what. Feature-Sliced Design (FSD) is a methodology aimed squarely at that problem. After reading this, you will know its six layers, the single import rule that does most of the work, how to design slice boundaries that allow safe refactoring, and, most importantly, how to decide whether full FSD is worth it for your app or whether a lighter subset is the better choice.
Why type-based folders stop scaling
The classic layout groups files by what they are (components, hooks, utilities) rather than by what they are for. That is fine for a small app, but once real business domains appear it starts to look like this:
src/
components/ # 200 files, no domain grouping
hooks/ # useAuth next to useCartTotals
utils/ # the grab-bag
services/ # api calls that import from utils, which imports back
Two things decay in a structure like this. First, the question "where does this file belong?" has no clear answer, so anything ambiguous ends up in utils/. Second, nothing restricts the direction of dependencies, which lets cycles form. Picture the order service pulling in utils/user.ts, that utility depending on services/auth.ts, and the auth service importing the order service again. Such loops are quiet. They typically show up much later as a value that is unexpectedly undefined when a module first loads, because one file in the loop was evaluated before the one it depends on had finished, and by then the graph is too tangled to read.
FSD addresses both issues by giving the entire project a prescribed shape rather than relying on each developer's habits. At the time of writing, v2.1 is the current version of the specification; check the official docs for later changes.
The six layers and what goes in each
FSD organizes code into layers with a fixed order, from top to bottom: App, Pages, Widgets, Features, Entities, Shared. An older Processes layer also exists but is deprecated and can be ignored. The ordering is not cosmetic; it defines who may depend on whom.
- App: routing, entry points, global styles and providers.
- Pages: complete pages, or large sections of the UI tied to a route.
- Widgets: big, self-contained UI blocks that deliver a whole use case on their own.
- Features: implementations of product functionality that are reused in several places.
- Entities: the business concepts the project deals with, such as a user or a product.
- Shared: reusable code with no business knowledge, including the design system and generic helpers.
The four middle layers (Pages through Entities) are divided into slices, one per business domain, for example entities/user or features/auth. App and Shared have no slices. Each slice is then divided into segments according to technical role: ui for components, api for backend requests and response types, model for schemas and stores, lib for helpers used only inside that slice, and config.
The import rule that makes cycles impossible
The rule that justifies the whole system is short: a module may import only from layers strictly below its own. A page can use a feature, but a feature can never use a page. On top of that, slices on the same layer may not import one another at all. Inside a single slice, files can reference each other freely; the restriction applies across slice and layer boundaries.
Apply that rule to the cycle from earlier. After re-slicing, order logic lives in entities/order, user logic in entities/user, and the authentication flow in features/auth. The allowed dependencies become:
features/auth → can import entities, shared
entities/order → can import shared only
entities/user → can import shared only
Now if entities/order tries to import from features/auth, that is a structural violation of the architecture rather than a matter of style. A cycle cannot form, because every edge in the dependency graph points downward. This also changes how you reason about the code: instead of tracing imports file by file, you think in terms of six layers, which is a model a person can actually keep in mind.
Public APIs keep slice internals private
Every slice exposes a Public API, typically an index.ts that re-exports what outsiders may use. The FSD docs are direct about the intent: expose only what is needed and keep implementation details hidden.
A very common version of this file defeats the purpose:
// features/auth/index.ts (the anti-pattern)
export * from "./ui";
export * from "./model";
The wildcards re-export everything, including model/tokenStore.ts, which was supposed to stay private. Sooner or later some page imports the token store directly, and from then on you cannot restructure it without breaking a consumer you did not know about. Wildcard barrels can also make it harder for bundlers to tree-shake, so unused code may end up shipped to users.
The alternative lists each export explicitly:
// features/auth/index.ts (the boundary)
export { LoginForm } from "./ui/LoginForm";
export { useSession } from "./model/useSession";
// tokenStore stays internal. Nobody outside auth can touch it.
The slice now has exactly two public names. Everything else inside it can be renamed, split or rewritten without touching any caller. That combination, a readable dependency graph plus freedom to change internals, is the core benefit FSD promises.
There is one caveat the FSD documentation raises itself: barrel index files can introduce circular imports, cause tree-shaking problems in shared/ui and shared/lib, and slow down bundlers in large projects. The practical response is to keep public APIs explicit and minimal, and to be especially careful in Shared, where many small, independent modules are better imported directly than funneled through one large barrel.
Cross-imports, over-slicing and letting a linter push back
The @x notation for entities that reference each other
Some entities legitimately depend on one another; an order, for example, needs the User type. Because same-layer imports are forbidden, v2.1 standardized the @x notation for exactly this situation. The user slice publishes a dedicated entry point meant for the order slice, and order imports from it:
// entities/user/@x/order.ts
export type { User } from "../model/types";
// in entities/order/model/types.ts
import type { User } from "entities/user/@x/order";
The file name makes the relationship explicit and searchable: entities/user/@x/order.ts is the part of user that order is allowed to see. The docs treat this as a narrow exception. Keep cross-imports to a minimum and use @x only in the Entities layer. If you find yourself needing it all over the codebase, that is a sign your slices are cut in the wrong places.
Steiger flags both violations and over-engineering
Rules only help if they are enforced. Steiger, the architecture linter for FSD (its FSD rules ship as @feature-sliced/steiger-plugin), works without configuration and was still in beta at the time of writing. Beyond checking the import rules, it pushes back on over-engineering: the insignificant-slice rule fires when a feature or entity has just a single page consuming it, while excessive-slicing points out when the project has been split into too many tiny pieces. When Steiger reports an insignificant slice, take the hint: you created a folder where a local function would have been enough.
When full FSD is overkill
The folder names are the least important part of FSD. Copying all six layers into a small app creates its own kind of mess: more files than logic, and every feature scattered across four directories so that a simple change becomes a search.
The specification recognizes this, which is why v2.1 recommends a "pages first" approach to adoption. Start with just App, Pages, Widgets and Shared, and consider stopping there. For many applications, that alone is a complete and readable structure.
Move code into Features and Entities only when a concrete reason appears. A useful heuristic is to wait until the same logic is needed by three or more pages or widgets before giving it its own slice. Until then, a checkout flow that lives entirely inside pages/checkout is the correct choice, not a shortcut. A single reuse can be coincidence; three suggests a genuine pattern, and representing reusable patterns is what the Features layer exists for.
If you are also weighing FSD against simpler feature-folder or domain-based layouts, our comparison of React folder structure patterns covers the alternatives side by side.
A short adoption checklist
- Adopt FSD fully when the team is larger than a handful of engineers, the number of business domains keeps growing, and
utils/has already turned into a dumping ground. - For a small product with one clear owner, keep the downward import rule and thin public APIs, and skip the extra layers.
- Write every
index.tsas an explicit list of exports; avoidexport *. - Reserve
@xfor entity-to-entity references and treat frequent use as a boundary problem. - Run a linter such as Steiger so the rules, and the warnings about unnecessary slices, are applied automatically.
Wrapping up
Six folders were never the goal. What FSD really offers is a dependency graph that only flows one way and slices whose internals are hidden behind small contracts, which together keep a codebase understandable a year from now. You can get most of that value from the import rule and public APIs alone, then add layers only when real reuse demands them.