Feature-Sliced Design: Taming a Growing React Codebase
Folder structure is architecture. How layered, feature-first organization with enforced import rules keeps a large frontend from collapsing into spaghetti.
Every large frontend I have worked on eventually hits the same wall: nobody can change anything without touching everything. A 'small' edit ripples across the codebase because imports go in every direction and there is no agreed-upon notion of what depends on what. Feature-Sliced Design (FSD) is the methodology that fixed this for me — not because it is clever, but because it makes the dependency direction explicit and enforceable.
Layers with a one-way dependency rule
FSD organizes code into a fixed set of layers, ordered by responsibility. The non-negotiable rule: a module may only import from layers below it, never above or sideways at the same level (with one deliberate exception). From top to bottom:
app— providers, routing, global styles; the composition root.pages— route-level compositions that assemble widgets and features.widgets— self-contained UI blocks like a header or a sidebar.features— user-facing interactions, e.g. 'add to cart' or 'toggle subscription'.entities— business domain models likeuserorproduct.shared— framework-agnostic utilities, UI kit, and API clients with no business logic.
Because dependencies only ever point downward, you can reason about impact. A change in shared may affect everything above it, but a change in a feature can never break an entity. That single constraint is what keeps the graph acyclic and the codebase navigable.
Slices and the public API rule
Within a layer, code is divided into slices by business domain — user, cart, auth. Each slice exposes a public API through an index barrel, and everything else inside it is private. Outside code imports from the slice's entry point, never reaches into its internals:
// entities/user/index.ts — the slice's public API
export { UserCard } from "./ui/UserCard";
export { useUser } from "./model/useUser";
export type { User } from "./model/types";
// ✅ allowed: import through the public API
import { UserCard, type User } from "@/entities/user";
// ❌ forbidden: reaching into private internals
import { UserCard } from "@/entities/user/ui/UserCard";This is the same encapsulation principle that makes well-designed packages pleasant to use, applied inside your own app. The internal structure of a slice becomes free to refactor, because nothing outside depends on it.
Enforce it with tooling, not goodwill
Conventions that rely on people remembering them decay the moment a deadline looms. Encode the layering rules in the linter so violations fail CI. The import/no-restricted-paths rule or the dedicated eslint-plugin-boundaries make the architecture self-policing:
// eslint — forbid upward and illegal cross-layer imports
"import/no-restricted-paths": ["error", {
zones: [
// entities must never import from features or above
{ target: "./src/entities", from: "./src/features" },
{ target: "./src/entities", from: "./src/widgets" },
{ target: "./src/shared", from: "./src/entities" },
],
}]An architecture that lives only in a wiki is a suggestion. An architecture encoded in the linter is a guarantee. Make the wrong thing impossible, not merely discouraged.
What it costs, and when to adopt it
FSD is not free. It adds indirection and a learning curve, and on a small app it is overkill — a few well-named folders will do. Reach for it when a codebase has crossed the threshold where multiple people step on each other, where 'where does this go?' is a recurring question, and where refactors feel dangerous because the blast radius is unknowable.
You do not have to adopt the methodology by the letter — I have shipped pragmatic variants on every team. What matters is the underlying idea: explicit layers, a one-way dependency rule, slices with public APIs, and tooling that enforces all three. Get those right and a large frontend stays as easy to change at year three as it was at month one.