Learn-skills.dev nuxt-fsd
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/adamkasper/nuxt-fsd-skills/nuxt-fsd" ~/.claude/skills/neversight-learn-skills-dev-nuxt-fsd && rm -rf "$T"
data/skills-md/adamkasper/nuxt-fsd-skills/nuxt-fsd/SKILL.mdFeature-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
. The Nuxt src/
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/
)
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, , date/string helpers, API client setup, type utilities | Business logic, domain concepts |
| entities | , , types, Zod schemas, base wrappers, model formatters | User actions, interactive features |
| features | Auth flow, search logic, cart operations, form submission composables | Entity definitions, layout concerns |
| widgets | , , , 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
is the FSD root. All FSD layers (src/
,pages/
,widgets/
,features/
,entities/
) live here.shared/
is the Nuxt runtime shell. It follows Nuxt conventions, not FSD. It consumes FSD code fromapp/
via imports.src/
contains thin routing shells only — they delegate toapp/pages/
page slices. See "Thin page pattern" below.src/pages/
is outside FSD entirely. Server routes follow Nuxt server conventions.server/
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
for i18n path mappings (if usingdefinePageMeta({ i18n: { ... } })
with@nuxtjs/i18n
)customRoutes: 'meta' - Defines
for validation, layout, route keydefinePageMeta() - 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/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:
inside a route group holds layout components shared across sub-routes_layout/
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
~~/src/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
, etc.)navigateTo - 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/usersrc/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-cartproduct-card - Domain-first names, NOT technical:
notauth
,use-auth-hook
notproductproduct-helpers - Vue components: PascalCase filenames matching component name:
,LoginForm.vueProductCard.vue - Composables: camelCase with
prefix:use
,useAuth.tsuseProductSearch.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 | powers different UIs |
| Infinite scroll product list | Widget | Fetch + render always together |
| Auth login form | Feature | reusable in modal, page, drawer |
| Navigation sidebar | Widget | Nav items + layout always coupled |
| Like/bookmark button | Feature | 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 () | Shared state within a feature/entity, SSR-safe | |
Provider/Inject () | State scoped to a component subtree | |
| Pinia store | Global state, devtools, persistence, complex actions | |
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 segment | |
| Mutations (write) | Feature segment | |
| Page-specific fetching | Page slice or inline | |
Import rules (strict)
Layer imports — only downward (within src/
)
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
orentitiesshared - Event-based communication — use a shared event bus (
) or Pinia store for loose couplingmitt - Composition at a higher layer — let a page or widget compose multiple features together
Nuxt app/
directory (not FSD)
app/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 |
|---|---|---|
| Thin routing shells | Imports page components from |
| Nuxt layouts | May import widgets from |
| Route middleware | May import composables from or |
| Global setup | May import from any layer |
| Global composables | Only truly app-wide concerns, not FSD-sliced code |
| 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/
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/
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
public API?index.ts - Are all cross-slice imports going through
?index.ts - Am I importing only from layers below?
- Are imports using
prefix (or configured aliases)?~~/src/ - Is
a thin routing shell (<25 lines)?app/pages/*.vue - Widget or Feature? (Asked: "can logic exist without this UI?")
- Is extraction proven (3+ usages) or premature?
- Is all FSD-structured code in
, not insrc/
orapp/
?server/ - Does
only contain Nuxt runtime concerns (thin pages, layouts, plugins, middleware)?app/
Migration strategy
For existing Nuxt projects adopting FSD with
src/ root:
| Code | Approach |
|---|---|
| New modules | Always follow FSD in |
Existing | Migrate to or appropriate slice segment |
Existing | Classify into feature/entity in or keep in if truly global |
Existing | Move to |
Existing | Move to entity/feature segments in |
| Existing fat pages | Split into thin shell in + page slice in |
Steps:
- Create
directory with FSD layer subdirectoriessrc/ - Add
includes (typescript
,tsConfig
,sharedTsConfig
) tonodeTsConfignuxt.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 +
slicessrc/pages/ - Update imports to use
and public APIs~~/src/ - Remove old directories once empty