Learn-skills.dev nuxt-fsd

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
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"
manifest: data/skills-md/adamkasper/nuxt-fsd-skills/nuxt-fsd/SKILL.md
source content

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

LayerContainsDoes NOT contain
sharedUI 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
featuresAuth flow, search logic, cart operations, form submission composablesEntity definitions, layout concerns
widgets
ProductCard
,
AppHeader
,
Sidebar
, complete composed sections with own data
Raw entity data access, route logic
pagesPage slices assembling widgets/features, page-specific data fetchingReusable 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

  1. src/
    is the FSD root. All FSD layers (
    pages/
    ,
    widgets/
    ,
    features/
    ,
    entities/
    ,
    shared/
    ) live here.
  2. app/
    is the Nuxt runtime shell. It follows Nuxt conventions, not FSD. It consumes FSD code from
    src/
    via imports.
  3. app/pages/
    contains thin routing shells only — they delegate to
    src/pages/
    page slices. See "Thin page pattern" below.
  4. 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/i18n
    with
    customRoutes: '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:
    auth
    not
    use-auth-hook
    ,
    product
    not
    product-helpers
  • Vue components: PascalCase filenames matching component name:
    LoginForm.vue
    ,
    ProductCard.vue
  • Composables: camelCase with
    use
    prefix:
    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

ModuleWidget or Feature?Why
Search with debounced resultsFeature
useSearch
powers different UIs
Infinite scroll product listWidgetFetch + render always together
Auth login formFeature
useAuth
reusable in modal, page, drawer
Navigation sidebarWidgetNav items + layout always coupled
Like/bookmark buttonFeature
useLike
works standalone
Dashboard stats panelWidgetChart + data always together

State management placement

All state lives in the

model/
segment of the relevant slice:

PatternWhen to usePlacement
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 storeGlobal 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

WhatWhereExample 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 fetchingPage 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:

  1. Extract to a lower layer — if two features share a concept, move it to
    entities
    or
    shared
  2. Event-based communication — use a shared event bus (
    mitt
    ) or Pinia store for loose coupling
  3. 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 directoryRoleRelation to FSD
app/pages/
Thin routing shellsImports page components from
src/pages/
app/layouts/
Nuxt layoutsMay import widgets from
src/widgets/
app/middleware/
Route middlewareMay import composables from
src/features/
or
src/entities/
app/plugins/
Global setupMay import from any
src/
layer
app/composables/
Global composablesOnly truly app-wide concerns, not FSD-sliced code
server/
Server routesEntirely 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.ts
    public 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/*.vue
    a 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 in
    app/
    or
    server/
    ?
  • Does
    app/
    only contain Nuxt runtime concerns (thin pages, layouts, plugins, middleware)?

Migration strategy

For existing Nuxt projects adopting FSD with

src/
root:

CodeApproach
New modulesAlways 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 pagesSplit into thin shell in
app/pages/
+ page slice in
src/pages/

Steps:

  1. Create
    src/
    directory with FSD layer subdirectories
  2. Add
    typescript
    includes (
    tsConfig
    ,
    sharedTsConfig
    ,
    nodeTsConfig
    ) to
    nuxt.config.ts
  3. Start all new code in FSD structure under
    src/
  4. Migrate existing code slice-by-slice when touched
  5. Convert fat pages to thin routing shells +
    src/pages/
    slices
  6. Update imports to use
    ~~/src/
    and public APIs
  7. Remove old directories once empty