The Day Our Team Stopped Fighting Over Where to Put Code — Establishing a Frontend Folder Structure with Feature-Sliced Design
"Where should this component go?" If you've been on a frontend team, you've probably seen Slack blow up over that question at least once. Every PR review comes with a comment like "Shouldn't this be in utils rather than components?", and as the project grows, the components folder swells to hundreds of files — I went through exactly that. It's a problem that inevitably arrives once a service reaches a certain scale, and Feature-Sliced Design was what solved it through structure.
FSD (Feature-Sliced Design) is a frontend architecture methodology that organizes code by business feature units rather than by technical role (whether it's a hook or a util). That might sound obvious at first, but actually applying it to a team changes a great deal. In our team's case, after adopting it, comments like "Where does this go?" in PR reviews dropped noticeably, and the time it took newly joined members to get up to speed on the codebase felt like it was cut in half.
This post is primarily aimed at frontend developers who mainly use React/Next.js. I'll walk through FSD's structure and how it works, how to integrate it into a real project, and honestly — where our team struggled along the way.
Core Concepts
30-second summary: FSD divides code by "business feature" rather than "technical role," and structurally prevents circular dependencies through a unidirectional dependency rule between layers. Each feature unit (slice) exposes itself to the outside world through a single
index.ts.
A Three-Tier Structure — Layer, Slice, Segment
FSD is a structure built from three nested concepts. When I first read the docs I was confused for a while, but comparing it to a supermarket shelf helped me understand quickly. A layer is like a section (frozen foods, beverages), a slice is like an aisle within that section (within beverages: sparkling, juice), and a segment is how things are arranged within an aisle (by size, by brand).
Layers are fixed at 6–7 and can only be referenced top-down (unidirectional dependency).
| Layer | Role | Examples |
|---|---|---|
app |
Routing, global providers, global styles | providers/, router/, styles/ |
pages |
Page-level components | DashboardPage, ProfilePage |
widgets |
Independent UI blocks | Header, Sidebar, DataTable |
features |
Interaction units that deliver business value | auth/, report-download/ |
entities |
Business domain models | user/, product/, order/ |
shared |
Project-agnostic reusable code | ui/Button, api/httpClient |
pages can reference widgets, and widgets can reference features. The reverse — shared referencing features, or entities referencing features — is a rule violation. That single rule structurally eliminates circular dependencies.
Slices are subdivisions within each layer, organized by domain. Names like features/auth or features/payment-history are freely chosen to fit the project's purpose.
Segments further divide the interior of a slice by technical role.
features/
└── auth/
├── ui/ # Components
├── model/ # State, business logic
├── api/ # API calls
├── lib/ # Utilities
└── index.ts # Public APIfeatures vs widgets — The Boundary That Confuses Everyone in Practice
When you first adopt FSD, this question always comes up: "If the header is in widgets, does the search feature inside the header go in features?" Our team once burned 20 minutes in a meeting over this boundary.
The distinguishing criterion is "Is this an independent UI block, or is it an interaction the user performs?"
widgets: Independent UI blocks composed of multiple features. They occupy a section of the screen on their own and don't directly own business logic.Header,Sidebar, andDataTablebelong here.features: A single interaction unit that a user performs. It directly delivers business value. "Log in," "download a report," "apply a filter" belong here.
The shell of the search input UI can live in widgets/header, but the logic and UI that actually perform the search should be separated into features/search, which widgets/header then consumes — that's the natural structure.
The Public API Pattern — The Core of Encapsulation
Public API pattern: Each slice exposes itself to the outside world only through a single
index.ts. Directly importing files from inside a slice is forbidden.
This might feel cumbersome at first. But thanks to this pattern, you can freely refactor the internals of a slice without affecting external code. As long as index.ts doesn't change, the outside world has nothing to worry about.
// features/auth/index.ts — only this is exposed to the outside
export { LoginForm } from './ui/LoginForm';
export { useAuth } from './model/useAuth';
export type { AuthUser } from './model/types';
// The files below are not directly accessible from outside — internal implementation details
// features/auth/api/authApi.ts (private)
// features/auth/model/authStore.ts (private)// ✅ Correct import
import { LoginForm, useAuth } from '@/features/auth';
// ❌ Wrong import — directly accessing internal implementation
import { LoginForm } from '@/features/auth/ui/LoginForm';The same applies to the entities layer. Inside entities/user/ or entities/order/, you can have not just types and UI components, but also the API call logic for that domain (the api/ segment). External code always accesses through index.ts.
Practical Application
Example 1: Structuring a Typical SaaS Dashboard
The dashboard project is the most common form encountered in practice. If you're applying FSD for the first time, starting with this structure will help you get the feel for it quickly.
src/
├── app/
│ ├── providers/ # Redux Provider, QueryClientProvider
│ ├── router/ # Routing configuration
│ └── styles/ # Global CSS
│
├── pages/
│ ├── dashboard/
│ │ ├── ui/DashboardPage.tsx
│ │ └── index.ts
│ └── profile/
│ ├── ui/ProfilePage.tsx
│ └── index.ts
│
├── widgets/
│ ├── sidebar/
│ │ ├── ui/Sidebar.tsx
│ │ └── index.ts
│ └── data-table/
│ ├── ui/DataTable.tsx
│ └── index.ts
│
├── features/
│ ├── auth/
│ │ ├── ui/LoginForm.tsx
│ │ ├── model/useAuth.ts
│ │ ├── api/authApi.ts
│ │ └── index.ts
│ ├── report-download/
│ │ ├── ui/DownloadButton.tsx
│ │ ├── model/useReportDownload.ts
│ │ ├── lib/generatePdf.ts
│ │ └── index.ts
│ └── filter-apply/
│ ├── ui/FilterPanel.tsx
│ ├── model/filterStore.ts
│ └── index.ts
│
├── entities/
│ ├── user/
│ │ ├── model/types.ts
│ │ ├── ui/UserAvatar.tsx
│ │ └── index.ts
│ └── order/
│ ├── api/orderApi.ts # Domain API calls go in entities too
│ ├── model/types.ts
│ ├── ui/OrderStatusBadge.tsx
│ └── index.ts
│
└── shared/
├── ui/
│ ├── Button/
│ ├── Modal/
│ └── Input/
├── api/
│ └── httpClient.ts
└── lib/
└── formatDate.tsThe key is to look at the report-download feature. The download button UI, the PDF generation logic, and the related hook are all contained in a single features/report-download. When you need to remove this feature, you just delete the folder. No teammate needs to ask "Is this function used anywhere else?" The first time our team deleted a feature using this structure, the immediate reaction was "Wow, it comes out this cleanly."
Example 2: Using FSD with the Next.js App Router
The question most people ask when first applying FSD after switching to the Next.js App Router is: "The app folder name conflicts — what do I do?" The officially recommended approach is to distinguish FSD layers with an underscore prefix.
my-next-app/
├── app/ # Next.js App Router (routing only)
│ ├── (dashboard)/
│ │ └── page.tsx # Imports from _pages/dashboard and renders
│ └── layout.tsx
│
└── src/
├── _app/ # FSD app layer (providers, global config)
├── _pages/ # FSD pages layer
│ └── dashboard/
│ ├── ui/DashboardPage.tsx
│ └── index.ts
├── widgets/
├── features/
├── entities/
└── shared/When using the _app, _pages prefix, you also need to update the paths setting in tsconfig.json. Aliases like @/_pages/dashboard need to be correctly set up so that Next.js route files can import cleanly. For detailed configuration, refer to the FSD official Next.js guide.
// app/(dashboard)/page.tsx — keep the Next.js route file thin
import { DashboardPage } from '@/_pages/dashboard';
export default function Page() {
return <DashboardPage />;
}// _pages/dashboard/ui/DashboardPage.tsx
import { OrderList } from '@/widgets/order-list';
import { FilterPanel } from '@/features/filter-apply';
import { getOrders } from '@/entities/order'; // Access through the Public API
export async function DashboardPage() {
const orders = await getOrders(); // Fetch in Server Component
return (
<div>
<FilterPanel />
<OrderList orders={orders} />
</div>
);
}Our team settled on placing Server Components in the data-fetching logic of entities and shared, and Client Components in the interaction units of features. This felt awkward at first, but once we tried it, we found ourselves spending less time wondering "Does this component need to be a Client component?" The layer naturally guided that decision.
Example 3: Automating Architecture Rule Checks with Steiger
When rules are managed only in documents, different team members interpret them differently. Setting up steiger, the official FSD linter, lets you automatically catch layer direction violations at the CI stage.
# Installation
pnpm add -D steiger @feature-sliced/steiger-plugin// steiger.config.ts
import { defineConfig } from 'steiger';
import fsd from '@feature-sliced/steiger-plugin';
export default defineConfig([
...fsd.configs.recommended,
]);// package.json
{
"scripts": {
"lint:arch": "steiger ./src"
}
}If shared imports from features, or a slice directly references another slice on the same layer, you'll actually see an error like this:
$ pnpm lint:arch
✖ Violation: no-cross-imports (features/auth → features/payment)
src/features/auth/model/useAuth.ts
Slices on the same layer cannot import from each other.
Consider moving shared logic to @/shared or @/entities.
1 violation found.That single line makes you realize "even if no one reads the team convention doc, CI will catch it." When a first-week team member accidentally imported features/payment from features/auth, they fixed it on their own just from the CI error message, with no guidance needed from the docs.
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Elimination of circular dependencies | Unidirectional layer rules structurally block circular references |
| Parallel team work | Slice-level isolation allows multiple teams to develop simultaneously without conflicts |
| Faster onboarding | Predictable structure lets new joiners quickly locate code |
| Easy feature deletion | The scope of feature changes or removals is clearly bounded to the relevant slice |
| AI collaboration fit | Clear "shelf" structure prevents location conflicts between AI-generated and human-written code |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Initial learning curve | The entire team must internalize the layer, slice, and segment concepts | Team workshop + immediate feedback via Steiger |
| Overhead for small projects | For simple apps with fewer than 10 features, it just adds directory depth | Introduce when 20+ slices are expected |
| Legacy migration | Migrating an existing codebase to FSD requires significant effort | Start writing new features in FSD and expand gradually |
| Next.js naming conflict | The app/ directory conflicts with the FSD app layer |
Resolved with the _app, _pages prefix convention |
| Community size | Fewer references and tutorials compared to MVC or Flux | Use official docs + feature-sliced/awesome |
Terminology note — Unidirectional Dependency: Upper layers can reference lower layers, but lower layers referencing upper layers is forbidden. Think of it as applying the same philosophy as React's unidirectional data flow to your folder structure.
The Most Common Mistakes in Practice
-
Putting business logic in
shared—sharedshould contain only pure utilities and UI kit components unrelated to the project or business domain. When you start seeing code that smells of a specific domain, it needs to be moved up toentitiesorfeatures. -
Slices on the same layer directly referencing each other —
features/authimporting fromfeatures/paymentis a rule violation. Our team initially held a team meeting over "Then where do we extract shared logic?", and the conclusion was simple: move it down tosharedorentities. -
Directly importing internal files without
index.ts— It's easy to skip this at first out of laziness, but once this pattern breaks down, FSD's encapsulation benefits disappear. Setting up Steiger or an ESLint plugin early on will automatically prevent this mistake.
In particular, when attempting legacy migrations, deciding "What should go in features?" takes longer than you'd expect. When our team first started migrating, we had a 3-hour debate over whether to put the existing UserProfileCard component in widgets or entities/user. We eventually settled on the criterion "Does this component compose multiple domains (→ widgets), or does it represent only the user domain (→ entities)?" — but getting the whole team to share that criterion took that much time. Going through this process at the start is normal.
Closing Thoughts
FSD is a methodology that resolves the recurring team debate of "where does this code go?" through structure. There's a small learning cost when first applying it, but from the moment a project grows to 20 or more feature slices, that cost is quickly recouped. The Kakao Pay engineering blog shared their own adoption story using the same approach, showing it's a well-validated methodology even domestically.
I started by writing just one new feature — a login form — in the FSD structure. Converting everything at once felt like too much. That was all it took, and it spread to the whole team within 3 weeks. The reaction "hey, this actually works" spread naturally, and without anyone pushing for it, everyone started writing the next feature the same way.
Three steps you can start right now:
-
Skim the official docs for the structure — Looking at the layer diagram and examples at feature-sliced.design is a great place to start. 30 minutes is enough to grasp the concepts.
-
Write one new feature in FSD — Trying to convert your entire existing project at once is too much. Try writing the next feature you're adding in the
features/[feature-name]/ui,model,api,index.tsstructure first, and you'll get the feel for it. -
Automate the rules with Steiger — After
pnpm add -D steiger @feature-sliced/steiger-plugin, set upsteiger.config.tsand CI will automatically catch rule violations even when teammates forget. Once you've set up the linter, your FSD adoption is ready to go.
References
- [KO] Feature-Sliced Design Official Docs — Overview
- [KO] Feature-Sliced Design — Usage with Next.js
- [KO] Adopting FSD Architecture — Kakao Pay Tech Blog
- [KO] (Translation) Feature-Sliced Design — The Best Frontend Architecture | emewjin.log
- [EN] Steiger — Official FSD Architecture Linter (GitHub)
- [EN] feature-sliced/awesome — Curated Resource List (GitHub)
- [EN] Mastering Feature-Sliced Design: Lessons from Real Projects — DEV Community
- [EN] Understanding Feature-Sliced Design: Benefits and Real Code Examples — HackerNoon
- [EN] The Drawbacks of Feature-Sliced Design — Medium
- [EN] Clean Architecture vs. Feature-Sliced Design in Next.js — Medium