pnpm Monorepo FSD Architecture — A Step-by-Step Guide to Enforcing Layers as Package Boundaries
"Where should I put this component?" — it's the question every newcomer asks. Having worked across multiple teams, I've found the answer is always different. Should it go in components/? Does it belong in modules/? What's the criteria for common/? As the codebase grows, this uncertainty quietly accumulates into technical debt.
This post is for frontend developers who are running or considering a React-based monorepo. It walks through, step by step, how to physically enforce Feature-Sliced Design (FSD) layer principles as pnpm package boundaries. After adopting this structure on our team, new hires could orient themselves within their first week, and PR conflict frequency dropped noticeably. It's not a perfect solution yet, but this is an honest account of what I wish I'd known when we first introduced it.
We'll cover FSD's core concepts, real-world structure design in a pnpm monorepo, and how to automate the rules with the Steiger linter.
Core Concepts
FSD's Three-Tier Structure — Layers, Slices, and Segments
FSD classifies code at three levels. The largest unit is the Layer, within which code is divided by business feature into Slices, and the folders inside a slice organized by technical role are Segments.
There are 6 layers in total (processes is deprecated as of v2), and the direction of dependency flows from top to bottom only.
| Layer | Role | Has Slices |
|---|---|---|
| app | Routing, global styles, providers | ✗ |
| pages | Full pages, nested routing units | ✓ |
| widgets | Independent large UI/feature blocks | ✓ |
| features | User scenarios delivering business value | ✓ |
| entities | Business domain objects (user, product, etc.) | ✓ |
| shared | Business-agnostic reusable modules | ✗ |
The Core Rule — Upper layers may only depend on lower layers, and direct imports between slices within the same layer are forbidden. This single rule structurally prevents circular dependencies.
I personally found the boundary between features and entities the most confusing at first. Here's a rule of thumb that's easy to remember: if it's "an action the user directly performs," it belongs in features; if it's "domain data and its representation," it belongs in entities. The button that adds an item to a cart is features/cart-add-item; the product card that button displays is entities/product.
The widgets layer is also hard to grasp at first. When we first introduced it on our team, it sparked the most debate. The conclusion we eventually reached: widgets are "large UI blocks that combine entities and features and operate independently." For example, a header component pulls in a user avatar from entities/user and a logout button from features/auth-by-email and composes them together.
// apps/web/src/widgets/header/index.tsx
import { UserAvatar, useUserStore } from '@myapp/entities/user'
import { LogoutButton } from '@myapp/features/auth-by-email'
import { Button } from '@myapp/shared/ui'
export function Header() {
const user = useUserStore(state => state.user)
return (
<header>
<UserAvatar user={user} />
<LogoutButton />
</header>
)
}It helps to think of widgets as "the layer that assembles Lego bricks."
Segment Naming Conventions
Folders inside a slice are divided by technical role.
| Segment | Responsibility |
|---|---|
ui/ |
Components, styles |
api/ |
Server request functions, types, mappers |
model/ |
Schemas, interfaces, stores, business logic |
lib/ |
Utilities and formatters used only within that slice |
config/ |
Environment configuration values, constants |
According to the official FSD guidelines, formatters belong in lib/, not ui/. The ui/ segment should be focused on component and style files.
Each slice must have an index.ts. External code may only import through this file. This is the Public API pattern — the key mechanism that lets you freely change internal implementations while maintaining external contracts.
// packages/entities/src/user/index.ts ← Public API file
export { UserAvatar } from './ui/UserAvatar'
export { useUserStore } from './model/user.store'
export type { User } from './model/user.types'
// External code should only import like this
import { UserAvatar, useUserStore } from '@myapp/entities/user'
// This is not allowed — bypassing the Public API
import { UserAvatar } from '@myapp/entities/src/user/ui/UserAvatar'The Natural Mapping Between pnpm Monorepos and FSD
While you can organize FSD with folders inside a single app, if you have a monorepo with multiple apps sharing code, separating layers into packages is a far more powerful strategy. The package boundary becomes the layer boundary, and the direction of dependency is enforced simply by declaring dependencies in package.json.
my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── apps/
│ ├── web/ # Next.js app — contains app, pages, and widgets layers
│ └── admin/ # Admin app
└── packages/
├── shared/ # @myapp/shared
├── entities/ # @myapp/entities
└── features/ # @myapp/featuresentities depends only on shared, and features depends only on entities and shared. Declaring these relationships in package.json means violations can be caught at package installation time, not at runtime.
When Slices Need to Share Something
There's one case that inevitably blocks you when you first apply FSD in practice: two entities slices need the same shared type. For instance, if both the user and order slices use a Currency type, importing from one slice into the other is a rule violation.
The solution is simple: move shared things downward. If Currency is a generic type with no business domain dependency, it goes into @myapp/shared/lib. If it's tied to a specific domain, split it into the model/ of a separate slice within entities. At first this raises lots of "where does this go?" questions, but once you build the habit of rethinking dependency direction, it becomes natural.
Pros and Cons
Before diving into practical examples, it's worth deciding whether this structure suits your team's situation.
Advantages
| Item | Details |
|---|---|
| Unidirectional dependencies | Circular dependencies are structurally prevented, maintaining a predictable code flow |
| Parallel team development | Slice boundaries align with team ownership boundaries, reducing PR conflicts |
| Onboarding speed | "Where does this file go?" has a clear answer, lowering the learning cost for new hires |
| Refactoring safety | Public API boundaries let you predict the impact of internal implementation changes |
| Monorepo scalability | Package boundaries naturally correspond to FSD layer boundaries, making it easy to add apps |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| High initial learning cost | The entire team needs to understand layers, slices, and segments | Team workshop + official docs reading session for onboarding |
| Overhead for small projects | For MVPs or short-term projects, the structural design cost may outweigh the benefit | If you have one app and a small team, applying folder-based FSD only is recommended |
| Risk of interpretation inconsistency | "Is this code entities or features?" debates arise easily | Document clear criteria in a team ADR upfront |
| Difficulty sharing within the same layer | Shared logic between features must be moved down to entities or shared | Inconvenient at first, but it becomes an opportunity to rethink dependency direction |
| Code review culture required | Tools alone have limits in enforcing rules | Connecting Steiger + ESLint to CI as a mandatory step is recommended |
Mistakes I've Witnessed Firsthand
-
Separating each entity into its own package — Splitting into
@myapp/entity-user,@myapp/entity-product, etc. causes package management overhead to explode. It's far more practical to organize them as slices within a single@myapp/entities. -
Rushing to separate packages in a single-app setup — If you only have one app, try experiencing FSD through the app's internal folder structure first. When a second app appears, that's soon enough to consider package separation.
-
Putting business logic in
shared—sharedmust not depend on any domain. Ask yourself: "Does this code make sense without the concept of a user?" If the answer is "no," it doesn't belong inshared.
Practical Application
Now that we've reviewed the pros and cons, let's look at how to actually set this up. Each example builds on the previous one, so following them in order will naturally complete the full picture.
Example 1: Initializing a pnpm Monorepo & Setting Up the Workspace
Setting up the workspace configuration from the start makes it much easier to add packages later.
mkdir my-fsd-monorepo && cd my-fsd-monorepo
pnpm init# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// turbo.json — For Turborepo v2 and above (use the "pipeline" key for v1)
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {
"dependsOn": ["^build"]
}
}
}Turborepo's
^build— This means "thebuildof every package I depend on must finish first." Ifentitiesdepends onshared, the ordershared build→entities buildis automatically guaranteed.
Example 2: Setting Up the @myapp/shared Package
shared is a collection of common modules unrelated to business logic. Basic UI components like buttons, axios instances, date formatters, and similar utilities all go here.
packages/shared/
├── package.json
├── tsconfig.json
└── src/
├── ui/
│ ├── Button/
│ │ ├── Button.tsx
│ │ └── index.ts
│ └── index.ts
├── api/
│ ├── client.ts
│ └── index.ts
├── lib/
│ ├── formatDate.ts ← formatters go here
│ └── index.ts
└── index.tsAfter shipping without this configuration once and losing TypeScript types, I now make sure to always include the types condition. Without the types condition in the exports field, type autocomplete won't work.
// packages/shared/package.json
{
"name": "@myapp/shared",
"version": "0.0.0",
"exports": {
"./ui": {
"types": "./src/ui/index.ts",
"default": "./src/ui/index.ts"
},
"./api": {
"types": "./src/api/index.ts",
"default": "./src/api/index.ts"
},
"./lib": {
"types": "./src/lib/index.ts",
"default": "./src/lib/index.ts"
}
}
}Thanks to the exports field, import { Button } from '@myapp/shared/ui' is allowed, but import { Button } from '@myapp/shared/src/ui/Button/Button' is blocked at the Node.js level. It's a structure where software enforces the Public API.
The
workspace:*protocol — A special version notation used in pnpm to reference internal packages. Declaring"@myapp/shared": "workspace:*"references the package on the local file system, and it's automatically replaced with the actual version at publish time.
Example 3: Setting Up @myapp/entities & @myapp/features Packages
Now that shared is in place, let's add entities, which depends on it. Seeing in code how actual components express layer boundaries makes it much more intuitive.
packages/entities/
├── package.json
└── src/
├── user/
│ ├── ui/
│ │ └── UserAvatar.tsx
│ ├── model/
│ │ ├── user.store.ts # Zustand store
│ │ └── user.types.ts
│ └── index.ts # Public API
└── product/
├── model/
└── index.ts// packages/entities/package.json
{
"name": "@myapp/entities",
"version": "0.0.0",
"dependencies": {
"@myapp/shared": "workspace:*"
},
"exports": {
"./user": {
"types": "./src/user/index.ts",
"default": "./src/user/index.ts"
},
"./product": {
"types": "./src/product/index.ts",
"default": "./src/product/index.ts"
}
}
}With entities in place, let's add features, which depends on both entities and shared. Let's verify in actual code how features components reference entities.
// packages/features/src/cart-add-item/ui/CartButton.tsx
import type { Product } from '@myapp/entities/product'
import { Button } from '@myapp/shared/ui'
interface Props {
product: Product
onAdd: () => void
}
export function CartButton({ product, onAdd }: Props) {
return (
<Button onClick={onAdd}>
{product.name} 담기
</Button>
)
}// packages/features/package.json
{
"name": "@myapp/features",
"version": "0.0.0",
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/entities": "workspace:*"
}
}features's package.json includes @myapp/entities but not @myapp/features. Since the features package cannot depend on itself, importing between slices in the same layer is impossible to declare in the first place. This is the true strength of the package separation strategy.
Example 4: App-Level FSD Layers & Connecting TSConfig Paths
app, pages, and widgets are app-specific layers, so they're kept as internal folders of the app. There's one point of confusion that can arise here. Inside the app, you use paths starting with @/ as path aliases, while references to packages use package names starting with @myapp/. These serve entirely different purposes.
@/pages/home— a path shortcut referencing a file within the same app (defined via tsconfig paths)@myapp/entities/user— a package import referencing a separate package (connected via pnpm workspace)
apps/web/src/
├── app/
│ ├── providers.tsx
│ └── styles/
│ └── global.css
├── pages/
│ ├── home/
│ │ └── index.tsx
│ └── product-detail/
│ └── index.tsx
└── widgets/
├── header/
│ └── index.tsx
└── sidebar/
└── index.tsx// apps/web/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/app/*": ["./src/app/*"],
"@/pages/*": ["./src/pages/*"],
"@/widgets/*": ["./src/widgets/*"]
}
}
}// apps/web/package.json — workspace package references
{
"name": "@myapp/web",
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/entities": "workspace:*",
"@myapp/features": "workspace:*"
}
}Example 5: Automated FSD Rule Validation with Steiger
No matter how well the structure is designed, catching every violation through code review alone is difficult. A single cross-import that slips in during a busy season becomes the seed of a tangled mess later. Steiger is a linter officially built by the FSD team that catches these issues through static analysis.
pnpm add -D steiger @feature-sliced/steiger-plugin -w// steiger.config.ts (monorepo root)
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'
export default defineConfig([
...fsd.configs.recommended,
{
files: ['apps/web/src/**'],
},
])When running from the monorepo root, you need to specify which paths to scan. Specifying the app path where FSD structure is applied in the files option restricts analysis to the intended scope. Running without this option may trigger a flood of warnings as it tries to scan under packages/ as well.
The two main rules Steiger checks are:
| Rule | Details |
|---|---|
fsd/forbidden-imports |
Blocks imports from upper layers to lower layers, and cross-imports between slices within the same layer |
fsd/no-public-api-sidestep |
Blocks direct imports of internal files that bypass index.ts |
Closing Thoughts
When you physically enforce FSD's layer dependency direction as pnpm package boundaries, the structural rules become tooling rather than documentation, and the whole team follows them naturally. It may look like a lot of setup at first, but once it's in place, the codebase maintains consistency even as the team grows.
Three steps you can start with right now:
-
Try separating out the
sharedlayer from your existing project first — Move UI components, API clients, and utility functions unrelated to business logic intopackages/shared/and update references to@myapp/shared. This is the first step with the least disruption. -
Create the
entitiespackage and migrate your domain models — Organize types, stores, and basic UI components for domain objects likeuserandproductas slices, and write anindex.tsPublic API for each. -
Connect Steiger to your CI pipeline — Install it with a single line
pnpm add -D steiger @feature-sliced/steiger-plugin -w, and add apnpm steigerstep to GitHub Actions to automatically catch FSD violations in every incoming PR.
Once the team experiences working on top of this structure, architecture stops being a matter of debate and becomes a language the code speaks for itself.
Next post: A practical configuration guide for reducing monorepo build times with Turborepo remote caching and task graph optimization
References
- Feature-Sliced Design Official Documentation | feature-sliced.design
- Complete Guide to FSD Monorepo Architecture 2025 | feature-sliced.design
- FSD Frontend Folder Structure Guide | feature-sliced.design
- FSD Naming Conventions | feature-sliced.design
- FSD Layers Reference | feature-sliced.design
- FSD Monorepo Example | feature-sliced.design
- Steiger Official Linter | GitHub
- Frontend Monorepo Architecture with pnpm & Turborepo | DEV.to
- Enforcing FSD import rules (eslint-plugin-import) | DEV.to
- pnpm Workspaces Official Documentation | pnpm.io
- Turborepo Repository Structure Design | turborepo.dev