Skip to main content

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: app is the CEO, pages are departments, features are teams delivering user actions, entities are core business objects, shared is office supplies.
  • Main difference from classic structure: code groups by feature/domain, not by file type. No giant components/ folder.
  • Dependency rule: sharedentitiesfeaturespagesapp. 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 stuff

Adding 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 extract entities/ from your existing utils/.

Comparison: FSD vs. traditional structure

AspectTraditional (by-type)FSD (by-feature)
Folder examplecomponents/CartButton/, api/cart.tspages/cart/ui/CartButton.tsx
Adding a featureEdit 5+ folders1 folder
DependenciesAnything imports anythingStrict top-down only
100k+ LOCNavigation becomes painfulSlices stay isolated
TestingCross-folder mocks neededTests live in-slice
Recommended forApps under 5k LOCProduction 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/

tsx
// Wrong: cart-specific component buried in shared // src/shared/ui/CartButton.tsx // Fix: move it where it belongs // src/features/cart/ui/CartButton.tsx

shared/ 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

tsx
// 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.ts

Importing upward in the layer hierarchy

tsx
// 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

ts
// 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-storefront uses FSD patterns for pages/product/ and entities/Order/.
  • Vite + React: the fsd-vite boilerplate has 10k+ GitHub stars.
  • State management: Zustand stores go in features/cart/model/, global user session state goes in entities/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:

tsx
// 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

tsx
// 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:

tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?