Feature-sliced design (FSD). must-know frontend architecture
Feature-Sliced Design (FSD) is a frontend architecture convention that organizes code into strict horizontal layers (app, pages, features, entities, shared), where each layer can only import from layers below it, and all files for one domain live together in one slice.
Theory
TL;DR
- Think of it like a company org chart:
appis the CEO,pagesare departments,featuresare teams delivering user actions,entitiesare core business objects,sharedis office supplies. - Main difference from classic structure: code groups by feature/domain, not by file type. No giant
components/folder. - Dependency rule:
shared→entities→features→pages→app. Only bottom-up. Never the other way. - Use FSD for teams of 5+ developers or when business requirements change often. For prototypes under 1000 lines, skip it.
- No compiler magic. Convention enforced by ESLint plugins.
Quick example
// Traditional "by-type" structure (breaks down at scale):
src/
components/ # CartButton, UserCard, ProductList all mixed
pages/
cart.tsx # imports from components/, utils/, api/
utils/
api/
// FSD structure (everything co-located by domain):
src/
app/ # routing, providers
pages/cart/
ui/CartButton.tsx # cart UI lives here
model/cartApi.ts # cart API lives here
entities/Product/
ui/ProductCard.tsx
model/product.ts # pure domain type
shared/ui/Button.tsx # only generic reusable stuffAdding a new feature means creating one folder, not editing five.
The key idea
Classic architecture groups by file type: all buttons in components/, all API calls in api/, all utilities in utils/. At 2000 lines that works fine. At 30000 lines, adding a "cart" feature means touching 5+ directories and hunting for related files across the entire codebase.
FSD flips this. The cart button lives with cart logic, cart API calls, and cart tests. One folder, one responsibility. A developer joining the team finds everything about orders in entities/Order/ and never has to guess where the API call is hiding.
Dependency rules
Each layer sits at a fixed level in the hierarchy:
app (top - orchestrates everything)
↑
pages
↑
features
↑
entities
↑
shared (bottom - generic utilities only)A features/ slice can import from entities/ or shared/. It cannot import from pages/ or app/. The shared/ui/Button.tsx component knows nothing about your business domain. These rules exist because the moment shared/ starts importing from features/, you get circular dependencies and the structure collapses.
Slices inside the same layer also cannot import each other. features/cart/ cannot import from features/auth/. If they need to share something, that something belongs in entities/ or shared/.
When to use
- Team of 5+ developers on the same codebase: FSD enforces clear ownership without extra coordination.
- Business-driven apps (e-commerce, SaaS dashboards): the
entities/layer maps directly to your domain model. - Monorepo with shared libraries:
shared/layer prevents duplication across packages. - Prototype under 1000 lines: skip it. The overhead costs more than the benefit.
- Migrating a legacy project: start with
pages/(natural route-based boundaries), then extractentities/from your existingutils/.
Comparison: FSD vs. traditional structure
| Aspect | Traditional (by-type) | FSD (by-feature) |
|---|---|---|
| Folder example | components/CartButton/, api/cart.ts | pages/cart/ui/CartButton.tsx |
| Adding a feature | Edit 5+ folders | 1 folder |
| Dependencies | Anything imports anything | Strict top-down only |
| 100k+ LOC | Navigation becomes painful | Slices stay isolated |
| Testing | Cross-folder mocks needed | Tests live in-slice |
| Recommended for | Apps under 5k LOC | Production apps with teams |
How FSD is enforced
No framework does this for you. FSD is pure convention, and conventions break without tooling. The standard approach is eslint-plugin-feature-sliced, which lints layer violations. If a features/auth/ file tries to import from app/routing, the linter flags it immediately.
Vite and Webpack resolve imports normally. But because related code is co-located, tree-shaking works better. Local dependencies produce smaller chunks. Teams report roughly 20-30% smaller bundle sizes after migration, mostly from better dead-code elimination.
One thing I've seen consistently in larger codebases: the biggest win is not bundle size. It's onboarding. A new developer who understands the layer names finds any file in under 30 seconds.
Common mistakes
Dumping everything in shared/
// Wrong: cart-specific component buried in shared
// src/shared/ui/CartButton.tsx
// Fix: move it where it belongs
// src/features/cart/ui/CartButton.tsxshared/ is for truly generic code: design system components, utility functions, type helpers. When teams put domain-specific components there, shared/ becomes a 500+ file dumping ground. Reddit threads on r/reactjs report 40% bundle bloat from this pattern alone.
Cross-importing within the same layer
// Wrong: features importing features
// src/features/cart/model/cart.ts
import { useAuth } from 'src/features/auth'; // circular dependency risk
// Fix: extract shared domain logic to entities
// src/entities/User/model/authApi.tsImporting upward in the layer hierarchy
// Wrong: feature reaching into the app layer
import { router } from 'src/app/routing'; // ESLint violation
// Fix: invert control with a callback
export const loginSuccess = (onSuccess: () => void) => {
// auth logic
onSuccess();
};
// The pages layer calls: loginSuccess(() => router.navigate('/dashboard'))Missing public API barrel files
// Wrong: consumers must know internal paths
import { Button } from 'src/shared/ui/Button/Button';
// Fix: barrel file controls what's public
// src/shared/ui/index.ts
export { Button } from './Button';
// Consumer: import { Button } from 'src/shared/ui'Without index files, renaming anything inside a slice breaks consumers across the project.
Real-world usage
- React + Next.js: Saleor's
react-storefrontuses FSD patterns forpages/product/andentities/Order/. - Vite + React: the
fsd-viteboilerplate has 10k+ GitHub stars. - State management: Zustand stores go in
features/cart/model/, global user session state goes inentities/User/model/. - Nx monorepo: Angular and React projects use FSD layer structure mapped to Nx library boundaries.
- vs. Atomic Design: Atomic Design is UI-focused (atoms, molecules, organisms). FSD is domain-focused. They solve different problems and can coexist: Atomic Design governs what lives inside
shared/ui/.
Follow-up questions
Q: Walk me through a folder structure for a todo app.
A: app/ holds the router and providers. pages/todo-list/ has the main page component. features/add-todo/ has the form and submission logic. entities/Todo/ has the Todo type and maybe a UI card. shared/ui/ has Input and Button.
Q: What happens when a component is used in two features?
A: If the business logic differs, duplicate it. If it is pure domain (like a ProductCard), extract it to entities/Product/ui/. Duplication is cheaper than a premature abstraction.
Q: How does FSD handle global state?
A: Slice-local state (Zustand store, Redux slice) lives in features/cart/model/. User session and other truly global concerns go in entities/User/model/ or app/.
Q: Can FSD work alongside Atomic Design?
A: Yes. Atomic Design can govern what goes inside shared/ui/ (atoms, molecules, organisms). FSD governs the overall project structure. They operate at different levels and do not conflict.
Q: How do you migrate a legacy project to FSD?
A: Start with pages/ because routes are already natural boundaries. Then identify domain objects and pull them into entities/. Finally move feature-specific code out of components/ and utils/. The ESLint plugin acts as a safety net during migration. Teams report cutting import path complexity by 30% and onboarding time by half after a two-week migration.
Examples
Basic: add-to-cart feature slice
Real e-commerce example with React and Zustand:
// src/features/cart/model/cartSlice.ts
import { create } from 'zustand';
import { Product } from 'src/entities/Product/model/types'; // allowed: feature → entity
interface CartState {
items: Product[];
add: (item: Product) => void;
}
export const useCart = create<CartState>(set => ({
items: [],
add: item => set(state => ({ items: [...state.items, item] }))
}));
// src/features/cart/ui/AddToCartButton.tsx
import { useCart } from '../model/cartSlice'; // local import inside the slice
export const AddToCartButton = ({ product }: { product: Product }) => {
const add = useCart(state => state.add);
return <button onClick={() => add(product)}>Add to cart</button>;
};The button and its state live in the same slice. Tests go in src/features/cart/. Refactoring cart logic never touches anything outside this folder.
Intermediate: page composing entities and features
// src/pages/user-orders/index.tsx
import { UserOrderList } from './ui/OrderList'; // local to this page
import { fetchUserOrders } from './model/ordersApi'; // local to this page
import { Order } from 'src/entities/Order'; // allowed: page → entity
export const UserOrdersPage = () => {
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
fetchUserOrders().then(setOrders);
}, []);
return <UserOrderList orders={orders} />;
};The page imports from entities/ (lower layer, allowed) and from its own local files. No imports from other pages or features.
Advanced: catching and fixing a layer violation
This is the pattern that creates long-term coupling problems and fails the ESLint layer check:
// BAD: feature reaching up into the app layer
// src/features/auth/model/auth.ts
import { router } from 'src/app/routing'; // ESLint violation: imports from higher layer
export const login = async (credentials: Credentials) => {
await authApi.login(credentials);
router.navigate('/dashboard'); // tightly coupled to app layer
};
// GOOD: invert control with a callback
// src/features/auth/lib/loginHandler.ts
export const login = async (
credentials: Credentials,
onSuccess: () => void
) => {
await authApi.login(credentials);
onSuccess();
};
// src/pages/login/index.tsx (page layer owns navigation)
import { login } from 'src/features/auth';
const handleLogin = (credentials: Credentials) => {
login(credentials, () => router.navigate('/dashboard'));
};The fixed version keeps features/auth/ independent of how navigation works. You can reuse login() on a different page, in a mobile app, or in tests without changing the feature itself.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.