Feature-Sliced Design for Nuxt 4+
Official FSD docs: https://feature-sliced.design/ LLM reference: https://feature-sliced.design/llms-full.txt
Core principle
FSD organizes code into layers with a strict dependency rule: each layer can only import from layers below it, never above or sideways. This prevents tangled dependencies — a widget never knows about a page that uses it, a feature never reaches into another feature, and an entity never depends on the UI that displays it.
FSD lives exclusively in src/. The Nuxt app/ directory is the runtime shell — it consumes FSD code from src/ but is not itself organized by FSD. Things like app/composables/, app/middleware/, app/plugins/, and app/layouts/ follow Nuxt conventions, not FSD layers.
FSD layers (all in src/)
Layers are ordered top (most specific) to bottom (most stable). Every layer can only import from layers below it.
pages ← FSD page slices in src/pages/ — compose widgets, features, entities
widgets ← Self-contained UI blocks with coupled logic + presentation
features ← Reusable user interactions — logic is standalone, UI is replaceable
entities ← Business domain models: types, schemas, base queries, formatters
shared ← Framework utilities, UI kit, API client, helpers — zero business logic
Layer quick-reference
| Layer | Contains | Does NOT contain |
|---|---|---|
| shared | UI kit components, cn(), date/string helpers, API client setup, type utilities | Business logic, domain concepts |
| entities | User, Product, Order types, Zod schemas, base useFetch wrappers, model formatters | User actions, interactive features |
| features | Auth flow, search logic, cart operations, form submission composables | Entity definitions, layout concerns |
| widgets | ProductCard, AppHeader, Sidebar, complete composed sections with own data | Raw entity data access, route logic |
| pages | Page slices assembling widgets/features, page-specific data fetching | Reusable pieces (extract when proven) |
Nuxt 4+ directory mapping
project-root/
├── app/ ← Nuxt 4 app directory (runtime shell, NOT FSD)
│ ├── app.vue ← Root component
│ ├── pages/ ← Thin routing shells (see "Thin page pattern")
│ │ ├── index.vue
│ │ └── products/
│ │ ├── index.vue
│ │ └── [id].vue
│ ├── layouts/ ← Nuxt layouts
│ │ └── default.vue
│ ├── plugins/ ← Nuxt plugins
│ ├── middleware/ ← Nuxt route middleware
│ └── composables/ ← Nuxt global composables
├── src/ ← FSD root — all sliced layers live here
│ ├── pages/ ← FSD page slices (full implementations)
│ │ ├── product-detail/
│ │ │ ├── ui/
│ │ │ │ └── ProductDetailPage.vue
│ │ │ ├── model/
│ │ │ │ └── useProductDetail.ts
│ │ │ └── index.ts
│ │ └── (checkout)/ ← Route group (parentheses)
│ │ ├── _layout/ ← Shared layout for sub-routes
│ │ │ └── CheckoutLayout.vue
│ │ ├── cart/
│ │ │ ├── ui/
│ │ │ └── index.ts
│ │ └── payment/
│ │ ├── ui/
│ │ └── index.ts
│ ├── widgets/ ← FSD widgets layer
│ │ └── product-card/
│ │ ├── ui/
│ │ │ └── ProductCard.vue
│ │ ├── model/
│ │ │ └── useProductCard.ts
│ │ └── index.ts
│ ├── features/ ← FSD features layer
│ │ └── add-to-cart/
│ │ ├── ui/
│ │ │ └── AddToCartButton.vue
│ │ ├── model/
│ │ │ └── useAddToCart.ts
│ │ ├── api/
│ │ │ └── mutations.ts
│ │ └── index.ts
│ ├── entities/ ← FSD entities layer
│ │ └── product/
│ │ ├── ui/
│ │ │ └── ProductPreview.vue
│ │ ├── model/
│ │ │ ├── types.ts
│ │ │ └── schema.ts
│ │ ├── api/
│ │ │ └── queries.ts
│ │ └── index.ts
│ └── shared/ ← FSD shared layer
│ ├── ui/
│ │ ├── UiButton.vue
│ │ └── UiModal.vue
│ ├── lib/
│ │ ├── format-date.ts
│ │ └── cn.ts
│ ├── api/
│ │ └── client.ts
│ └── config/
│ └── constants.ts
├── server/ ← Nuxt server routes (outside FSD client layers)
├── public/ ← Static assets
└── nuxt.config.ts
Key mapping rules
src/is the FSD root. All FSD layers (pages/,widgets/,features/,entities/,shared/) live here.app/is the Nuxt runtime shell. It follows Nuxt conventions, not FSD. It consumes FSD code fromsrc/via imports.app/pages/contains thin routing shells only — they delegate tosrc/pages/page slices. See "Thin page pattern" below.server/is outside FSD entirely. Server routes follow Nuxt server conventions.
Thin page pattern
app/pages/*.vue files are routing shells (5–25 lines). They exist solely for Nuxt's file-based routing and delegate all real implementation to FSD page slices in src/pages/.
A thin page:
- Imports the page component from
src/pages/<slice> - Defines
definePageMeta({ i18n: { ... } })for i18n path mappings (if using@nuxtjs/i18nwithcustomRoutes: 'meta') - Defines
definePageMeta()for validation, layout, route key - Renders the imported page component
Example thin page
<!-- app/pages/products/[id].vue — thin routing shell -->
<script setup lang="ts">
import { ProductDetailPage } from '~~/src/pages/product-detail'
definePageMeta({
layout: 'default',
validate: async (route) => /^\d+$/.test(route.params.id as string),
i18n: {
paths: {
cs: '/produkty/[id]',
en: '/products/[id]',
},
},
})
</script>
<template>
<ProductDetailPage />
</template>
The actual implementation lives in the FSD page slice:
src/pages/product-detail/
ui/
ProductDetailPage.vue ← Full page implementation
model/
useProductDetail.ts
index.ts ← Exports ProductDetailPage
FSD page slices in src/pages/
src/pages/ contains full FSD page slices — not Nuxt file-based routes. These are regular FSD slices with ui/, model/, api/, and index.ts.
Conventions
- Slice naming: kebab-case matching the domain concept:
product-detail/,blog-article-detail/,user-profile/ - Route groups: Parentheses group related sub-routes:
(checkout)/cart/,(checkout)/payment/ - Shared layout:
_layout/inside a route group holds layout components shared across sub-routes
Page slice structure
src/pages/product-detail/
ui/
ProductDetailPage.vue ← Main page component
ProductGallery.vue
ProductSpecs.vue
model/
useProductDetail.ts ← Page-specific composable
api/
queries.ts ← Page-specific data fetching
index.ts ← Public API: exports ProductDetailPage
// src/pages/product-detail/index.ts
export { default as ProductDetailPage } from './ui/ProductDetailPage.vue'
Imports and aliases
Primary pattern: ~~/src/ prefix
Use Nuxt's ~~/ alias (which resolves to the project root) to import from FSD layers:
// CORRECT — primary import pattern
import { useAuth } from '~~/src/features/auth'
import { type User } from '~~/src/entities/user'
import { UiButton } from '~~/src/shared/ui'
import { ProductDetailPage } from '~~/src/pages/product-detail'
Alternative: custom path aliases
You can optionally configure shorter aliases in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
alias: {
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url)),
'@entities': fileURLToPath(new URL('./src/entities', import.meta.url)),
'@features': fileURLToPath(new URL('./src/features', import.meta.url)),
'@widgets': fileURLToPath(new URL('./src/widgets', import.meta.url)),
},
})
Then use: import { UiButton } from '@shared/ui'. Both patterns are valid — ~~/src/ requires no config, aliases are shorter.
Important: Do NOT rely on Nuxt auto-imports for cross-layer dependencies. Always use explicit imports through the slice's public API (index.ts). This keeps dependencies visible and enforceable.
// CORRECT — explicit import via public API
import { useAuth } from '~~/src/features/auth'
// WRONG — deep import bypassing public API
import { useAuth } from '~~/src/features/auth/model/useAuth'
Nuxt auto-imports are acceptable only for:
- Vue/Nuxt built-ins (
ref,computed,useFetch,useRoute,navigateTo, etc.) - Global app-layer composables in
app/composables/
TypeScript configuration
Since src/ lives outside the Nuxt app/ directory, you must explicitly include it in the TypeScript config:
// nuxt.config.ts
export default defineNuxtConfig({
typescript: {
tsConfig: {
include: ['../src/**/*'],
},
sharedTsConfig: {
include: ['../src/**/*'],
},
nodeTsConfig: {
include: ['../src/**/*'],
},
},
})
This ensures all three Nuxt-generated tsconfigs (client, shared, server) include type-checking and auto-completion for FSD code in src/.
Slices and segments
A slice is a subfolder inside a layer, named after a business domain concept:
src/features/auth,src/entities/user,src/widgets/product-card
A segment groups code by technical role inside a slice:
src/features/auth/
ui/ ← Vue components
model/ ← Composables, stores (Pinia), state, types
api/ ← Data fetching (useFetch, $fetch, query wrappers)
lib/ ← Helpers specific to this slice
config/ ← Constants, feature flags
index.ts ← Public API (REQUIRED)
Naming conventions
- kebab-case everywhere:
user-profile,add-to-cart,product-card - Domain-first names, NOT technical:
authnotuse-auth-hook,productnotproduct-helpers - Vue components: PascalCase filenames matching component name:
LoginForm.vue,ProductCard.vue - Composables: camelCase with
useprefix:useAuth.ts,useProductSearch.ts
Public API
Every slice must expose an index.ts. External code imports only from index.ts.
// src/features/auth/index.ts
export { default as LoginForm } from './ui/LoginForm.vue'
export { useAuth } from './model/useAuth'
export { useProvideAuth } from './model/useAuth'
export type { AuthUser, AuthCredentials } from './model/types'
Deep imports are forbidden between slices:
// WRONG
import { useAuth } from '~~/src/features/auth/model/useAuth'
// CORRECT
import { useAuth } from '~~/src/features/auth'
Within-slice relative imports are fine:
// Inside src/features/auth/ui/LoginForm.vue
import { useAuth } from '../model/useAuth'
Pages-first workflow
Start everything in src/pages/. Extract only when reuse is proven.
New functionality needed
│
v
Build inside src/pages/<slice>/
│
v
Used in a 2nd place?
NO --> Keep in src/pages/
YES
│
v
Duplicate it (2 copies is acceptable)
│
v
Used in a 3rd place?
NO --> Keep duplicated
YES
│
v
Extract. Ask: "Is this logic always tied to THIS specific UI?"
YES --> Widget (logic + UI coupled)
NO --> Feature (logic reusable, UI replaceable)
or Entity (pure domain data, no user action)
Rule: Extract when you have evidence (3+ usages), not a prediction. Premature extraction creates unnecessary abstraction.
Widget vs Feature decision
The critical question: "Can the logic be used WITHOUT this specific UI?"
Widget — logic and UI are inseparable
- Always rendered as a unit
- Contains its own data fetching and state
- Reused across 2+ pages but always as a whole block
src/widgets/job-list/
ui/
JobList.vue ← Fetches data, renders list, handles pagination
JobListItem.vue ← Presentational sub-component
model/
useJobList.ts ← Encapsulated composable, not exported alone
index.ts ← Exports only <JobList />
Feature — logic is standalone
- Composable works with any UI
- Default UI is provided but replaceable
- Logic can be used headlessly
src/features/search/
model/
useSearch.ts ← Works standalone, any UI can consume it
ui/
SearchBar.vue ← Default UI, receives state via props/inject
index.ts ← Exports both useSearch and SearchBar
Quick decision table
| Module | Widget or Feature? | Why |
|---|---|---|
| Search with debounced results | Feature | useSearch powers different UIs |
| Infinite scroll product list | Widget | Fetch + render always together |
| Auth login form | Feature | useAuth reusable in modal, page, drawer |
| Navigation sidebar | Widget | Nav items + layout always coupled |
| Like/bookmark button | Feature | useLike works standalone |
| Dashboard stats panel | Widget | Chart + data always together |
State management placement
All state lives in the model/ segment of the relevant slice:
| Pattern | When to use | Placement |
|---|---|---|
Simple composable (useState) | Shared state within a feature/entity, SSR-safe | src/features/auth/model/useAuth.ts |
Provider/Inject (createInjectionState) | State scoped to a component subtree | src/features/cart/model/useCart.ts |
| Pinia store | Global state, devtools, persistence, complex actions | src/entities/user/model/store.ts |
Provider/Inject pattern
Use createInjectionState from @vueuse/core for scoped state. Always export a throwing variant so consumers get a clear error if the provider is missing:
// src/features/auth/model/useAuth.ts
import { createInjectionState } from '@vueuse/core'
const [useProvideAuth, useAuthRaw] = createInjectionState(() => {
const user = ref<User | null>(null)
return { user }
})
export { useProvideAuth }
export function useAuth() {
const state = useAuthRaw()
if (!state) throw new Error('useAuth must be used within AuthProvider')
return state
}
Data fetching placement
| What | Where | Example path |
|---|---|---|
| Base queries (read) | Entity api/ segment | src/entities/product/api/queries.ts |
| Mutations (write) | Feature api/ segment | src/features/add-to-cart/api/mutations.ts |
| Page-specific fetching | Page slice api/ or inline | src/pages/product-detail/api/queries.ts |
Import rules (strict)
Layer imports — only downward (within src/)
pages can import from → widgets, features, entities, shared
widgets can import from → features, entities, shared
features can import from → entities, shared
entities can import from → shared
shared can import from → (nothing — only external packages)
app/ (Nuxt shell) can import from any FSD layer in src/.
Same-layer isolation
Slices within the same layer cannot import from each other:
// WRONG — features/auth importing from features/profile
import { useProfile } from '~~/src/features/profile'
// CORRECT — extract shared concept to entities or shared
import { type User } from '~~/src/entities/user'
Cross-entity references (@x notation)
When entities have legitimate business relationships, use explicit @x cross-references:
// src/entities/order/ui/OrderCard.vue
import { UserAvatar } from '~~/src/entities/user/@x/order'
The @x/<consumer> folder is a controlled cross-import API. Use sparingly.
Cross-slice communication patterns
When slices on the same layer need to coordinate:
- Extract to a lower layer — if two features share a concept, move it to
entitiesorshared - Event-based communication — use a shared event bus (
mitt) or Pinia store for loose coupling - Composition at a higher layer — let a page or widget compose multiple features together
Nuxt app/ directory (not FSD)
Everything in app/ follows Nuxt conventions, not FSD. These files consume FSD code from src/ but are not themselves organized into FSD layers:
| Nuxt directory | Role | Relation to FSD |
|---|---|---|
app/pages/ | Thin routing shells | Imports page components from src/pages/ |
app/layouts/ | Nuxt layouts | May import widgets from src/widgets/ |
app/middleware/ | Route middleware | May import composables from src/features/ or src/entities/ |
app/plugins/ | Global setup | May import from any src/ layer |
app/composables/ | Global composables | Only truly app-wide concerns, not FSD-sliced code |
server/ | Server routes | Entirely outside FSD, follows Nuxt server conventions |
Shared layer structure
The shared layer has no slices — only segments:
src/shared/
ui/ ← Generic UI components: buttons, modals, inputs, cards
lib/ ← Utility functions: formatDate, cn(), debounce
api/ ← API client setup, interceptors, base fetch wrapper
config/ ← App-wide constants, env helpers, route names
types/ ← Shared TypeScript utility types
Each segment can have its own index.ts for cleaner imports.
Scaffolding a new slice
src/features/bookmark/
ui/
BookmarkButton.vue
model/
useBookmark.ts
types.ts
api/
mutations.ts
index.ts ← Public API (REQUIRED)
Only create segments you actually need. An entity with only types needs only model/ and index.ts.
Common mistakes
1. Putting FSD layers inside app/
Wrong: app/features/, app/entities/, app/widgets/, app/shared/.
Right: FSD layers live in src/. Only app/pages/ (thin shells), app/layouts/, app/plugins/, app/middleware/, app/composables/ go in app/.
2. Fat page routes
Wrong: Putting full page implementations in app/pages/products/[id].vue (200+ lines).
Right: app/pages/ files are thin routing shells (5–25 lines) that import from src/pages/.
3. Premature extraction
Wrong: Creating src/features/fancy-button because "it might be reused."
Right: Keep in src/pages/ until 3 actual usages prove the need.
4. Wrong layer
Wrong: Putting useProductSearch (standalone logic) in a widget.
Right: Ask "can this logic work without THIS specific UI?" — yes means Feature.
5. Missing public API
Wrong: import { x } from '~~/src/features/auth/model/internal'
Right: All exports go through index.ts.
6. Upward imports
Wrong: src/entities/user importing from src/features/auth.
Right: Dependency flows only downward — entities never reference features.
7. Same-layer cross-imports
Wrong: src/features/profile importing from src/features/auth.
Right: Extract shared concept to entities/ or use events.
8. Business logic in shared
Wrong: src/shared/lib/useAuth.ts, src/shared/lib/useProductSearch.ts.
Right: Shared is for generic utilities only — zero business logic.
9. Relying on Nuxt auto-imports for FSD layers
Wrong: Expecting useAuth() to auto-resolve from src/features/auth/model/.
Right: Use explicit imports via ~~/src/features/auth public API.
10. Applying FSD outside src/
Wrong: Organizing app/composables/, app/middleware/, or server/ into FSD layers.
Right: FSD lives exclusively in src/. Everything else (app/, server/) follows Nuxt conventions.
Checklist for new code
Before writing or reviewing code, verify:
- Which layer does this belong to? (Use the decision tree)
- Does the slice have an
index.tspublic API? - Are all cross-slice imports going through
index.ts? - Am I importing only from layers below?
- Are imports using
~~/src/prefix (or configured aliases)? - Is
app/pages/*.vuea thin routing shell (<25 lines)? - Widget or Feature? (Asked: "can logic exist without this UI?")
- Is extraction proven (3+ usages) or premature?
- Is all FSD-structured code in
src/, not inapp/orserver/? - Does
app/only contain Nuxt runtime concerns (thin pages, layouts, plugins, middleware)?
Migration strategy
For existing Nuxt projects adopting FSD with src/ root:
| Code | Approach |
|---|---|
| New modules | Always follow FSD in src/ |
Existing app/components/ | Migrate to src/shared/ui/ or appropriate slice ui/ segment |
Existing app/composables/ | Classify into feature/entity model/ in src/ or keep in app/composables/ if truly global |
Existing app/utils/ | Move to src/shared/lib/ |
Existing app/stores/ | Move to entity/feature model/ segments in src/ |
| Existing fat pages | Split into thin shell in app/pages/ + page slice in src/pages/ |
Steps:
- Create
src/directory with FSD layer subdirectories - Add
typescriptincludes (tsConfig,sharedTsConfig,nodeTsConfig) tonuxt.config.ts - Start all new code in FSD structure under
src/ - Migrate existing code slice-by-slice when touched
- Convert fat pages to thin routing shells +
src/pages/slices - Update imports to use
~~/src/and public APIs - Remove old directories once empty