nuxt

SKILL.md

This skill defines opinionated Nuxt 4 architecture: a BFF server layer, DDD-inspired contexts, a strict component hierarchy, and clean code principles applied to the Nuxt ecosystem.

Nuxt 4 provides a full-stack framework with file-based routing, server API routes, auto-imports, and SSR/SSG capabilities. The patterns here ensure a maintainable, scalable application by combining Nuxt's conventions with solid software design principles (see guidelines skill).


Project Structure

.
├── app/
│   ├── app.vue                        # Root component — calls useApp init
│   ├── error.vue                      # Global error page
│   ├── pages/                         # File-based routing
│   │   ├── index.vue
│   │   └── users/
│   │       └── [id].vue
│   ├── layouts/                       # Layout components
│   │   ├── default.vue
│   │   └── dashboard.vue
│   ├── middleware/                    # Route middleware
│   │   ├── auth.ts                    # Global or named auth guard
│   │   └── role.ts
│   ├── plugins/                       # App-level plugins
│   │   ├── analytics.client.ts        # Client-only plugin
│   │   └── sentry.ts
│   ├── components/
│   │   ├── app/                       # Shared application components
│   │   │   ├── AppHeader.vue
│   │   │   ├── AppFooter.vue
│   │   │   └── AppLoadingState.vue
│   │   ├── ui/                        # Independent, reusable UI components (no domain logic)
│   │   │   ├── BaseCard.vue
│   │   │   └── BaseEmptyState.vue
│   │   ├── home/                      # Home page components
│   │   │   ├── HomeHero.vue
│   │   │   ├── HomeLatestArticles.vue
│   │   │   └── HomeFeaturedProducts.vue
│   │   └── user/                      # User module components (matches the user domain)
│   │       ├── list/                  # User list view
│   │       │   ├── UserList.vue       # Orchestrates user list (no "Container" suffix)
│   │       │   ├── UserListItem.vue
│   │       │   └── UserListFilters.vue
│   │       ├── detail/                # User detail view
│   │       │   ├── UserDetail.vue     # Orchestrates user detail
│   │       │   ├── UserProfile.vue
│   │       │   └── UserActivityFeed.vue
│   │       └── add/                   # User add/edit views
│   │           ├── UserAdd.vue        # Orchestrates user creation
│   │           └── UserAddForm.vue
│   ├── composables/                   # Auto-imported composables (filename without "use" prefix)
│   │   ├── app.ts                     # exports useApp()
│   │   ├── pages.ts                   # exports usePages()
│   │   └── user.ts                    # exports useUser()
│   └── stores/                        # Pinia stores (auto-imported via nuxt.config)
│       ├── app.store.ts               # exports useAppStore
│       └── user.store.ts              # exports useUserStore
├── server/
│   ├── api/
│   │   ├── app/
│   │   │   └── index.get.ts           # App initialization endpoint
│   │   ├── pages/
│   │   │   ├── index.get.ts           # Home page data endpoint — returns ALL home data
│   │   │   └── users/
│   │   │       └── [id].get.ts        # User page data endpoint — returns ALL user data
│   │   └── user/
│   │       ├── create.post.ts
│   │       └── [id].delete.ts
│   ├── utils/                         # Auto-imported server utilities — flat, one file per domain
│   │   ├── user.ts                    # createUser, findUser, deleteUser — ALL in one file
│   │   ├── product.ts
│   │   └── app.ts
│   └── contexts/                      # NOT auto-imported — explicit imports only
│       ├── shared/
│       │   ├── services/
│       │   │   └── PostgresService.ts
│       │   └── errors/
│       │       └── ServerError.ts
│       └── user/
│           ├── domain/
│           │   ├── User.ts
│           │   ├── UserRepository.ts
│           │   └── UserError.ts
│           ├── application/
│           │   ├── UserCreator.ts
│           │   └── UserFinder.ts
│           └── infrastructure/
│               └── PostgresUserRepository.ts
└── shared/
    ├── types/                         # Flat — no subfolders — auto-importable
    │   ├── App.ts                     # App initialization types (interface App)
    │   ├── Page.ts                    # All page types (special — not split by module)
    │   └── User.ts                    # Domain types + AuthUser
    └── utils/                         # Flat — no subfolders — auto-importable
        ├── user.ts                    # Zod schemas and utilities for user module
        └── product.ts

Key rules:

  • app/ contains all Vue/client code (Nuxt 4 default)
  • server/contexts/ is never auto-imported — always use explicit import statements
  • server/utils/ is flat: one file per domain, all functions for that domain in the same file
  • shared/types/ and shared/utils/ are flat (no subfolders) and are auto-importable
  • Components are organized by module context (matching the domain/web module), not by technical type
  • Container-style components have no Container suffixUserDetail, not UserDetailContainer
  • Composable filenames have no use prefix (app.ts), but the exported function still does (useApp)
  • Store filenames use .store.ts suffix (user.store.ts), exported as useUserStore

Nuxt Config

// nuxt.config.ts
export default defineNuxtConfig({
  
  // Define components path without prefix
  components: [
    { path: '@/components', pathPrefix: false },
  ],
  
  // Define Devtools
  devtools: {
    enabled: import.meta.env.DEVTOOLS_ENABLED || false,
    timeline: {
      enabled: true,
    },
  },
  
  // Define CSS
  css: [
    '@/assets/css/main.css',
  ],
  
  // Runtime config
  runtimeConfig: {
    privateConfig: '',
    public: {
      publicConfig: ''
    }
  }
  
  compatibilityDate: '2025-07-15',
  
  // Auto-import stores from app/stores/
  pinia: {
    storesDirs: ['./stores/**.store.ts'],
  },
  
  modules: [
    '@pinia/nuxt',
    '@nuxt/ui', // if using Nuxt UI
    '@nuxtjs/i18n', // if using i18n
  ],
})

Auto-Imports

Nuxt auto-imports everything in app/composables/, app/stores/ (via config), app/components/, and server/utils/. Leverage this everywhere except server/contexts/.

✅ Use auto-imports:

// app/pages/index.vue — no imports needed
const pages = usePages()
const { data } = await useAsyncData('home', () => pages.getHomePage())
const userStore = useUserStore()

❌ Never use auto-imports in server/contexts/:

// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository'       // explicit import
import type { CreateUserDto } from '../../../../shared/types/User'   // explicit import

export class UserCreator {
  constructor(private readonly repository: UserRepository) {}

  async create(dto: CreateUserDto): Promise<void> {
    // ...
  }
}

Backend for Frontend (BFF) Pattern

The server layer acts exclusively as a BFF — it aggregates, transforms, and exposes data tailored for the Vue frontend. No business logic lives in the Vue layer.

There are three types of endpoints, each with a clear purpose:

1. App Endpoint

Path: server/api/app/index.get.ts

Returns all data needed to bootstrap the application (user session, config, feature flags, translations metadata, etc.). Called once on app mount.

// server/api/app/index.get.ts
import type { App } from '~~/shared/types/App'

export default defineEventHandler(async (event): Promise<App> => {
  const [config, user] = await Promise.all([
    getAppConfig(event),
    getAuthUser(event),
  ])

  return { config, user }
})

Composable: app/composables/app.ts

// app/composables/app.ts
export function useApp() {
  const appStore = useAppStore()
  const userStore = useUserStore()

  async function getAppData(): Promise<App> {
    return $fetch('/api/app')
  }

  async function init(): Promise<void> {
    const data = await getAppData()
    appStore.setConfig(data.config)
    userStore.setCurrentUser(data.user)
  }

  return { init }
}

Usage in app/app.vue:

<!-- app/app.vue -->
<script setup lang="ts">
const { init } = useApp()

// callOnce ensures this runs once on SSR and not again on client hydration
await callOnce(init)

// If using nuxt-i18n, re-init on locale change:
// const { locale } = useI18n()
// watch(locale, init)
</script>

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

Types: shared/types/App.ts

// shared/types/App.ts
export interface App {
  config: AppConfig
  user: User | null
}

export interface AppConfig {
  featureFlags: Record<string, boolean>
  locale: string
}

AuthUser is not a separate type — it is the User interface defined in shared/types/User.ts. If the authenticated user shape differs from the domain user, extend from User in User.ts.


2. Page Endpoints

Each Nuxt page calls exactly one server endpoint that returns all data the page needs in a single request. This avoids waterfalls and keeps pages simple.

Convention: server/api/pages/{route}.get.ts mirrors app/pages/{route}.vue. Each endpoint fetches everything the page needs and returns it in one response.

// server/api/pages/index.get.ts  →  app/pages/index.vue
import type { HomePageData } from '~~/shared/types/Page'

export default defineEventHandler(async (event): Promise<HomePageData> => {
  const [banner, products] = await Promise.all([
    getHeroBanner(event),
    getFeaturedProducts(event),
  ])

  return { banner, products }
})
// server/api/pages/users/[id].get.ts  →  app/pages/users/[id].vue
import type { UserPageData } from '~~/shared/types/Page'

export default defineEventHandler(async (event): Promise<UserPageData> => {
  const id = getRouterParam(event, 'id')!

  const [user, activity] = await Promise.all([
    findUser(event, id),
    getUserActivity(event, id),
  ])

  return { user, activity }
})

Note: findUser and getUserActivity are auto-imported from server/utils/user.ts.

Composable: app/composables/pages.ts — all page fetchers in one place.

// app/composables/pages.ts
export function usePages() {
  async function getHomePage(): Promise<HomePageData> {
    return $fetch('/api/pages')
  }

  async function getUserPage(id: string): Promise<UserPageData> {
    return $fetch(`/api/pages/users/${id}`)
  }

  return { getHomePage, getUserPage }
}

Types: shared/types/Page.tsall page types together (not split by module, pages are a cross-cutting concern).

// shared/types/Page.ts
export interface HomePageData {
  banner: Banner
  products: Product[]
}

export interface UserPageData {
  user: User
  activity: UserActivity[]
}

export interface Banner {
  title: string
  imageUrl: string
  ctaLabel: string
  ctaUrl: string
}

3. Use-Case Endpoints

Business operation endpoints (mutations and domain queries). Organized by module.

// server/api/user/create.post.ts
import type { User } from '~~/shared/types/User'

export default defineEventHandler(async (event): Promise<User> => {
  const body = await readValidatedBody(event, createUserSchema.parse)
  return createUser(event, body)
})
// server/api/user/[id].delete.ts
export default defineEventHandler(async (event): Promise<void> => {
  const id = getRouterParam(event, 'id')!
  await deleteUser(event, id)
})

Composable: app/composables/user.ts

// app/composables/user.ts
export function useUser() {
  const userStore = useUserStore()

  async function createUser(dto: CreateUserDto): Promise<User> {
    const user = await $fetch('/api/user/create', {
      method: 'POST',
      body: dto,
    })
    userStore.addUser(user)
    return user
  }

  async function deleteUser(id: string): Promise<void> {
    await $fetch(`/api/user/${id}`, { method: 'DELETE' })
    userStore.removeUser(id)
  }

  return { createUser, deleteUser }
}

Server Layer: Contexts & Utils

server/contexts/ — Domain logic with explicit imports

Follows DDD (Domain-Driven Design) patterns. Never rely on Nuxt auto-imports here. All imports are explicit to keep the domain layer framework-agnostic and testable.

server/contexts/
└── user/
    ├── domain/
    │   ├── User.ts                    # Domain entity
    │   ├── UserRepository.ts          # Repository interface
    │   └── UserError.ts               # Domain errors
    ├── application/
    │   ├── UserCreator.ts             # Use case
    │   └── UserFinder.ts              # Use case
    └── infrastructure/
        └── PostgresUserRepository.ts  # Concrete implementation

Domain entity:

// server/contexts/user/domain/User.ts
export interface User {
  id: string
  name: string
  email: string
  createdAt: Date
}

Repository interface (DIP):

// server/contexts/user/domain/UserRepository.ts
import type { User } from './User'
import type { CreateUserDto } from '../../../../shared/types/User'

export interface UserRepository {
  findById(id: string): Promise<User | null>
  create(dto: CreateUserDto): Promise<User>
  delete(id: string): Promise<void>
}

Use case with dependency injection:

// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository'
import type { CreateUserDto } from '../../../../shared/types/User'
import type { User } from '../domain/User'
import { UserError } from '../domain/UserError'

export class UserCreator {
  constructor(private readonly repository: UserRepository) {}

  async create(dto: CreateUserDto): Promise<User> {
    const existing = await this.repository.findByEmail(dto.email)
    if (existing) throw new UserError('EMAIL_ALREADY_EXISTS', dto.email)
    return this.repository.create(dto)
  }
}

Concrete implementation:

// server/contexts/user/infrastructure/PostgresUserRepository.ts
import type { UserRepository } from '../domain/UserRepository'
import type { User } from '../domain/User'
import type { CreateUserDto } from '../../../../shared/types/User'
import { PostgresService } from '../../shared/services/PostgresService'

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly db: PostgresService) {}

  async findById(id: string): Promise<User | null> {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id])
  }

  async create(dto: CreateUserDto): Promise<User> {
    return this.db.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      [dto.name, dto.email],
    )
  }

  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM users WHERE id = $1', [id])
  }
}

server/utils/ — Use-case orchestrators (auto-imported, flat)

These are the bridge between API route handlers and the context layer. They wire up dependencies (DI) and expose simple functions to the endpoint handlers.

Key rule: All functions for a given domain live in one file — no subfolders. server/utils/user.ts contains createUser, findUser, deleteUser, etc.

// server/utils/user.ts — ALL user utilities in one file
import { UserCreator } from '~~/server/contexts/user/application/UserCreator'
import { UserFinder } from '~~/server/contexts/user/application/UserFinder'
import { PostgresUserRepository } from '~~/server/contexts/user/infrastructure/PostgresUserRepository'
import { PostgresService } from '~~/server/contexts/shared/services/PostgresService'
import type { CreateUserDto } from '~~/shared/types/User'
import type { User } from '~~/shared/types/User'
import type { H3Event } from 'h3'

export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
  const db = new PostgresService()
  const repository = new PostgresUserRepository(db)
  const creator = new UserCreator(repository)
  return creator.create(dto)
}

export async function findUser(event: H3Event, id: string): Promise<User> {
  const db = new PostgresService()
  const repository = new PostgresUserRepository(db)
  const finder = new UserFinder(repository)
  return finder.find(id)
}

export async function deleteUser(event: H3Event, id: string): Promise<void> {
  const db = new PostgresService()
  const repository = new PostgresUserRepository(db)
  await repository.delete(id)
}

export async function getUserActivity(event: H3Event, id: string): Promise<UserActivity[]> {
  // ...
}

Key rule: server/utils/ functions are auto-imported in route handlers. server/contexts/ classes are never auto-imported — always explicitly imported inside server/utils/.


Shared Layer

shared/ is available on both client and server. Everything here is flat (no subfolders) and auto-importable.

shared/types/ — Types and enums, one file per module

// shared/types/User.ts
export interface User {
  id: string
  name: string
  email: string
  role: UserRole
  roles: UserRole[]    // for authenticated user — roles array
  createdAt: string
}

export interface CreateUserDto {
  name: string
  email: string
  role: UserRole
}

export enum UserRole {
  Admin = 'admin',
  User = 'user',
}

The authenticated user type is User (from shared/types/User.ts). There is no separate AuthUser type — the same User interface represents domain users and authenticated users alike. If extra auth-only fields are needed, use intersection or optional fields.

Special files:

  • shared/types/App.ts — App initialization data (see App Endpoint section)
  • shared/types/Page.ts — All page response types (not split by module, pages are a special cross-cutting concern)

shared/utils/ — Zod schemas and utilities, one file per module

// shared/utils/user.ts — ALL user schemas and utilities in one file
import { z } from 'zod'
import { UserRole } from '../types/User'

export const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.nativeEnum(UserRole),
})

export type CreateUserInput = z.infer<typeof createUserSchema>

export function formatUserDisplayName(user: { name: string; email: string }): string {
  return `${user.name} <${user.email}>`
}

Component Architecture

Components follow a strict three-layer hierarchy. Each layer has a single, well-defined responsibility.

Layer 1: Page Component (View)

Location: app/pages/

Rules:

  • As simple as possible — just data fetching and layout composition
  • Uses useAsyncData to fetch page data via usePages composable — one single endpoint call per page
  • Renders a single orchestrating component, passing data as props
  • No business logic, no direct store access (except reading loading state)
<!-- app/pages/index.vue -->
<script setup lang="ts">
const { getHomePage } = usePages()

const { data, status } = await useAsyncData('home', getHomePage)
</script>

<template>
  <div>
    <Home
      v-if="data"
      :data="data"
    />
    <AppLoadingState v-else-if="status === 'pending'" />
  </div>
</template>
<!-- app/pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { getUserPage } = usePages()

const { data, status } = await useAsyncData(
  `user-${route.params.id}`,
  () => getUserPage(route.params.id as string),
)
</script>

<template>
  <UserDetail
    v-if="data"
    :data="data"
  />
</template>

Layer 2: Orchestrating Component (no "Container" suffix)

Location: app/components/{module}/ — inside the relevant module subfolder.

Rules:

  • Named after the view it orchestrates (e.g. UserDetail, UserList, Home) — no Container suffix
  • Receives page data as props
  • Connects to Pinia stores for reactive state and actions
  • Calls use-case composables for mutations
  • Computes derived state
  • Passes only what each child component needs
  • Never contains raw HTML — only child presentational components
<!-- app/components/user/detail/UserDetail.vue -->
<script setup lang="ts">
import type { UserPageData } from '~/shared/types/Page'

const props = defineProps<{
  data: UserPageData
}>()

const userStore = useUserStore()
const { deleteUser } = useUser()

// Hydrate store with server data
userStore.setUser(props.data.user)

// Derived state
const isCurrentUser = computed(() =>
  userStore.currentUser?.id === props.data.user.id
)

async function handleDelete(): Promise<void> {
  await deleteUser(props.data.user.id)
  await navigateTo('/users')
}
</script>

<template>
  <div>
    <UserProfile
      :user="data.user"
      :is-current-user="isCurrentUser"
      @delete="handleDelete"
    />
    <UserActivityFeed :activities="data.activity" />
  </div>
</template>
<!-- app/components/home/HomeLatestArticles.vue — presentational, part of Home -->
<script setup lang="ts">
import type { Article } from '~/shared/types/Page'

defineProps<{
  articles: Article[]
}>()
</script>

<template>
  <section>
    <h2>Latest Articles</h2>
    <ul>
      <li v-for="article in articles" :key="article.id">
        {{ article.title }}
      </li>
    </ul>
  </section>
</template>

Component folder anatomy for a module (e.g. user):

components/user/
├── list/
│   ├── UserList.vue           # Orchestrates list view (connects store, composables)
│   ├── UserListItem.vue       # Presentational — one user row
│   └── UserListFilters.vue    # Presentational — filter controls
├── detail/
│   ├── UserDetail.vue         # Orchestrates detail view
│   ├── UserProfile.vue        # Presentational — user info card
│   └── UserActivityFeed.vue   # Presentational — activity list
└── add/
    ├── UserAdd.vue            # Orchestrates add/create flow
    └── UserAddForm.vue        # Presentational — create form

Layer 3: Presentational Component

Location: Inside the relevant module subfolder, or app/components/ui/ for truly generic components.

Rules:

  • Completely independent — no Pinia, no composables, no $fetch
  • Communicates only through props, v-model, and emit
  • Fully reusable within its module (or across the app if in ui/)
  • Uses Nuxt UI components internally
  • Can contain local UI state (e.g., open/close toggles)
<!-- app/components/user/detail/UserProfile.vue -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'

const props = defineProps<{
  user: User
  isCurrentUser: boolean
}>()

const emit = defineEmits<{
  delete: []
}>()

const showConfirm = ref(false)

function confirmDelete(): void {
  showConfirm.value = true
}

function handleConfirm(): void {
  showConfirm.value = false
  emit('delete')
}
</script>

<template>
  <UCard>
    <template #header>
      <div class="flex items-center justify-between">
        <h1 class="text-xl font-bold">{{ user.name }}</h1>
        <UBadge :label="user.role" />
      </div>
    </template>

    <p class="text-gray-500">{{ user.email }}</p>

    <template #footer>
      <UButton
        v-if="isCurrentUser"
        color="red"
        variant="ghost"
        @click="confirmDelete"
      >
        Delete account
      </UButton>
    </template>

    <UModal v-model="showConfirm">
      <UCard>
        <p>Are you sure you want to delete your account?</p>
        <template #footer>
          <div class="flex gap-2">
            <UButton color="red" @click="handleConfirm">Confirm</UButton>
            <UButton variant="ghost" @click="showConfirm = false">Cancel</UButton>
          </div>
        </template>
      </UCard>
    </UModal>
  </UCard>
</template>

Summary table:

Layer Location Pinia $fetch Props Emits
Page pages/ ❌ (via usePages)
Orchestrating components/{module}/ ✅ (via composables)
Presentational components/{module}/ or components/ui/

State Management with Pinia

Stores hold reactive client-side state and are organized by domain module.

// app/stores/user.store.ts
import type { User } from '~/shared/types/User'

export const useUserStore = defineStore('user', () => {
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<User[]>([])

  // Getters
  const isAuthenticated = computed(() => currentUser.value !== null)
  const isAdmin = computed(() =>
    currentUser.value?.roles.includes(UserRole.Admin) ?? false
  )

  // Actions
  function setCurrentUser(user: User | null): void {
    currentUser.value = user
  }

  function setUsers(list: User[]): void {
    users.value = list
  }

  function addUser(user: User): void {
    users.value.push(user)
  }

  function removeUser(id: string): void {
    users.value = users.value.filter(u => u.id !== id)
  }

  return {
    currentUser,
    users,
    isAuthenticated,
    isAdmin,
    setCurrentUser,
    setUsers,
    addUser,
    removeUser,
  }
})

Key rules:

  • Always use defineStore with the setup store syntax (composition API style)
  • Store files use .store.ts suffix: user.store.ts, app.store.ts
  • Exported store function keeps the use prefix: useUserStore, useAppStore
  • Stores are auto-imported via imports.dirs: ['stores'] in nuxt.config.ts
  • Stores are only accessed in orchestrating components and composables — never in presentational components
  • Stores are hydrated from server data in orchestrating components or the useApp.init() flow

Layouts, Middleware & Plugins

Layouts — app/layouts/

Layouts wrap pages and provide shared structure (header, sidebar, footer). Nuxt automatically wraps pages using <NuxtLayout> in app.vue.

<!-- app/layouts/default.vue -->
<template>
  <div class="flex flex-col min-h-screen">
    <AppHeader />
    <main class="flex-1">
      <slot />
    </main>
    <AppFooter />
  </div>
</template>
<!-- app/layouts/dashboard.vue -->
<template>
  <div class="flex">
    <DashboardSidebar />
    <main class="flex-1 p-6">
      <slot />
    </main>
  </div>
</template>

Use a specific layout in a page:

<!-- app/pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>

Disable layout:

<script setup lang="ts">
definePageMeta({ layout: false })
</script>

Middleware — app/middleware/

Middleware runs before a route is rendered. Use it for authentication guards, role checks, and redirects.

Types:

  • Named middleware — explicitly applied per page via definePageMeta
  • Global middleware — filename ends with .global.ts — runs on every route change
// app/middleware/auth.ts — named middleware
export default defineNuxtRouteMiddleware((to, from) => {
  const userStore = useUserStore()

  if (!userStore.isAuthenticated) {
    return navigateTo('/login')
  }
})
// app/middleware/role.ts — named middleware with meta
export default defineNuxtRouteMiddleware((to) => {
  const userStore = useUserStore()

  const requiredRole = to.meta.requiredRole as UserRole | undefined
  if (requiredRole && !userStore.currentUser?.roles.includes(requiredRole)) {
    return navigateTo('/unauthorized')
  }
})

Apply middleware in a page:

<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: ['auth', 'role'],
  requiredRole: UserRole.Admin,
})
</script>

Global middleware example:

// app/middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
  if (import.meta.client) {
    trackPageView(to.fullPath)
  }
})

Plugins — app/plugins/

Plugins run once when the Nuxt app is created. Use them to register third-party integrations, provide global helpers, or configure libraries.

Filename conventions:

  • plugin.ts — runs on both server and client
  • plugin.client.ts — runs only on the client
  • plugin.server.ts — runs only on the server
  • Plugins are ordered by filename (prefix with numbers if order matters: 01.analytics.client.ts)
// app/plugins/sentry.ts — isomorphic plugin
export default defineNuxtPlugin((nuxtApp) => {
  const config = useRuntimeConfig()

  Sentry.init({
    dsn: config.public.sentryDsn,
    environment: config.public.environment,
  })

  nuxtApp.vueApp.config.errorHandler = (error) => {
    Sentry.captureException(error)
  }
})
// app/plugins/analytics.client.ts — client-only plugin
export default defineNuxtPlugin(() => {
  // Safe to access window/document here
  const analytics = new Analytics({ token: useRuntimeConfig().public.analyticsToken })

  return {
    provide: {
      analytics,
    },
  }
})

Access provided values in components:

const { $analytics } = useNuxtApp()
$analytics.track('page_view')

Composables

Composables encapsulate reactive logic and API calls. They are the primary way Vue components interact with the server.

Conventions:

  • Exported function always has use prefix: useUser, useApp
  • Filename has no use prefix: user.ts, app.ts, pages.ts
  • Located in app/composables/ (flat, auto-imported)
  • One file per domain module
  • Use-case composables call $fetch directly — useAsyncData is the page's responsibility
  • Composables may read and write Pinia stores

Use-case composable pattern:

// app/composables/user.ts
export function useUser() {
  const userStore = useUserStore()
  const toast = useToast()

  async function createUser(dto: CreateUserDto): Promise<User | null> {
    try {
      const user = await $fetch<User>('/api/user/create', {
        method: 'POST',
        body: dto,
      })
      userStore.addUser(user)
      toast.add({ title: 'User created', color: 'green' })
      return user
    } catch (error) {
      toast.add({ title: 'Error creating user', color: 'red' })
      return null
    }
  }

  async function deleteUser(id: string): Promise<boolean> {
    try {
      await $fetch(`/api/user/${id}`, { method: 'DELETE' })
      userStore.removeUser(id)
      return true
    } catch {
      toast.add({ title: 'Error deleting user', color: 'red' })
      return false
    }
  }

  return { createUser, deleteUser }
}

Nuxt UI

Nuxt UI provides a design system built on Tailwind CSS. Use its components as building blocks inside presentational components.

Core components:

Component Purpose
UButton All interactive buttons
UCard Content containers with header/footer slots
UModal Dialog overlays
UForm / UFormField Form containers with validation
UInput, USelect, UTextarea Form inputs
UTable Data tables
UBadge Status labels
UAlert Feedback messages
UNavigationMenu Navigation menus
UToast / useToast Toast notifications

Form with Zod validation:

<!-- app/components/user/add/UserAddForm.vue -->
<script setup lang="ts">
import type { CreateUserDto } from '~/shared/types/User'
import { createUserSchema } from '~/shared/utils/user'

const props = defineProps<{
  loading: boolean
}>()

const emit = defineEmits<{
  submit: [dto: CreateUserDto]
}>()

const state = reactive<CreateUserDto>({
  name: '',
  email: '',
  role: UserRole.User,
})

async function handleSubmit(): Promise<void> {
  emit('submit', { ...state })
}
</script>

<template>
  <UForm
    :schema="createUserSchema"
    :state="state"
    @submit="handleSubmit"
  >
    <UFormField label="Name" name="name">
      <UInput v-model="state.name" />
    </UFormField>

    <UFormField label="Email" name="email">
      <UInput v-model="state.email" type="email" />
    </UFormField>

    <UFormField label="Role" name="role">
      <USelect
        v-model="state.role"
        :options="Object.values(UserRole)"
      />
    </UFormField>

    <UButton type="submit" :loading="loading">
      Create User
    </UButton>
  </UForm>
</template>

Toast notifications (use in orchestrating components or composables):

const toast = useToast()

async function handleCreate(dto: CreateUserDto): Promise<void> {
  try {
    await createUser(dto)
    toast.add({ title: 'User created', color: 'green' })
  } catch {
    toast.add({ title: 'Failed to create user', color: 'red' })
  }
}

Data Fetching Patterns

useAsyncData in pages

Use useAsyncData in pages to fetch data server-side with proper SSR support and deduplication.

// Basic usage — one endpoint call per page
const { data, status, refresh } = await useAsyncData('home', () => usePages().getHomePage())

// With route-based key and param
const route = useRoute()
const { data } = await useAsyncData(
  `user-${route.params.id}`,
  () => usePages().getUserPage(route.params.id as string),
)

$fetch in composables

Use $fetch for mutations and imperative calls from composables (no SSR caching needed).

const user = await $fetch<User>('/api/user/create', {
  method: 'POST',
  body: dto,
})

callOnce in app.vue

Ensures the app initialization runs exactly once during SSR and is not repeated on client hydration.

await callOnce(async () => {
  const { init } = useApp()
  await init()
})

Error Handling

Server side — throw createError:

// server/api/user/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  const user = await findUser(event, id)

  if (!user) {
    throw createError({ statusCode: 404, message: 'User not found' })
  }

  return user
})

Domain errors in contexts:

// server/contexts/user/domain/UserError.ts
export class UserError extends Error {
  constructor(
    public readonly code: 'EMAIL_ALREADY_EXISTS' | 'NOT_FOUND',
    public readonly detail?: string,
  ) {
    super(`UserError: ${code}`)
  }
}

Map domain errors to HTTP errors in server/utils/user.ts:

export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
  try {
    const db = new PostgresService()
    const repository = new PostgresUserRepository(db)
    const creator = new UserCreator(repository)
    return await creator.create(dto)
  } catch (error) {
    if (error instanceof UserError) {
      throw createError({ statusCode: 409, message: error.message })
    }
    throw createError({ statusCode: 500, message: 'Internal server error' })
  }
}

Client side — handle errors in composables, never let them bubble raw to the UI:

// app/composables/user.ts
async function createUser(dto: CreateUserDto): Promise<User | null> {
  try {
    return await $fetch<User>('/api/user/create', { method: 'POST', body: dto })
  } catch (error: unknown) {
    const msg = error instanceof Error ? error.message : 'Unknown error'
    toast.add({ title: msg, color: 'red' })
    return null
  }
}

Global error page:

<!-- app/error.vue -->
<script setup lang="ts">
const props = defineProps<{
  error: { statusCode: number; message: string }
}>()

function handleClear(): void {
  clearError({ redirect: '/' })
}
</script>

<template>
  <div>
    <h1>{{ error.statusCode }}</h1>
    <p>{{ error.message }}</p>
    <UButton @click="handleClear">Go home</UButton>
  </div>
</template>

Best Practices

Apply SOLID principles to the Nuxt stack

Principle Nuxt application
SRP Pages only fetch. Orchestrating components only coordinate. Presentational components only render.
OCP Add new pages/use cases without touching existing endpoints or stores.
LSP Repository implementations are fully substitutable for their interfaces.
ISP Small, focused composables (useUser, useOrder) instead of one god composable.
DIP server/utils/ depends on context interfaces, not concrete implementations.

Keep pages dumb

Pages should contain no business logic. They call one endpoint and render one orchestrating component.

<!-- ✅ Good — page is just a loader -->
<script setup lang="ts">
const { getHomePage } = usePages()
const { data } = await useAsyncData('home', getHomePage)
</script>

<!-- ❌ Bad — page doing too much -->
<script setup lang="ts">
const { data } = await useAsyncData('home', () => $fetch('/api/pages'))
const userStore = useUserStore()
const filtered = computed(() => data.value?.products.filter(p => p.active))
userStore.setProducts(filtered.value ?? [])
</script>

Keep presentational components pure

Presentational components must never import or call anything from outside their own file except types, Nuxt UI, and Vue primitives. If a component needs data from a store, pass it as a prop from the orchestrating component.

<!-- ✅ Good -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'
defineProps<{ user: User }>()
</script>

<!-- ❌ Bad — presentational touching Pinia -->
<script setup lang="ts">
const userStore = useUserStore()
</script>

One composable per domain module

Split composables by bounded context, not by technical concern. Avoid composables like useApi or useFetch that grow to touch everything.

app/composables/
├── app.ts       # exports useApp() — App bootstrap
├── pages.ts     # exports usePages() — All page fetchers
├── user.ts      # exports useUser() — User use cases
├── order.ts     # exports useOrder() — Order use cases
└── product.ts   # exports useProduct()

Never put business logic in the Vue layer

Business logic (validation beyond form UX, domain rules, calculations) belongs in server/contexts/. The Vue layer (pages, orchestrating components, composables) should only orchestrate calls and manage UI state.

// ❌ Bad — business rule in a composable
export function useUser() {
  async function createUser(dto: CreateUserDto) {
    if (dto.role === UserRole.Admin && !currentUser.isAdmin) {
      throw new Error('Only admins can create admins')
    }
    return $fetch('/api/user/create', { method: 'POST', body: dto })
  }
}

// ✅ Good — business rule enforced in context application layer
// server/contexts/user/application/UserCreator.ts
export class UserCreator {
  async create(dto: CreateUserDto, requesterId: string): Promise<User> {
    if (dto.role === UserRole.Admin) {
      await this.authService.requireAdmin(requesterId)
    }
    return this.repository.create(dto)
  }
}

Validate at the boundary

Use Zod schemas from shared/utils/ to validate all incoming data at the API boundary using readValidatedBody or getValidatedQuery. Never trust unvalidated input inside server/utils/ or server/contexts/.

// server/api/user/create.post.ts
export default defineEventHandler(async (event) => {
  // Validate at the edge — if this throws, H3 returns 422 automatically
  const body = await readValidatedBody(event, createUserSchema.parse)
  return createUser(event, body)
})

Use callOnce for app initialization

Never initialize the app inside onMounted or a watch without callOnce. This prevents double-fetching during SSR hydration.

// ✅ Good
await callOnce(init)

// ❌ Bad — runs twice (server + client)
onMounted(() => init())

Co-locate types with their domain

Types live in shared/types/ModuleName.ts, Zod schemas and utils in shared/utils/moduleName.ts. Never define types inline inside Vue components or server route handlers.

// ✅ Good — shared/types/Product.ts
export interface Product {
  id: string
  name: string
  price: number
  currency: Currency
}

export enum Currency {
  EUR = 'EUR',
  USD = 'USD',
}

Avoid over-engineering with YAGNI

Nuxt provides excellent conventions — don't add abstraction layers that duplicate framework features.

  • Don't create a generic Repository<T> base class unless you have multiple implementations that truly share behavior.
  • Don't add an event bus if Nuxt's built-in SSE or simple store reactivity covers the need.
  • Do start with the simplest solution and refactor when the need is proven.

Naming conventions

Artifact Convention Example
Pages kebab-case user-profile.vue
Components PascalCase UserProfile.vue
Composable files camelCase (no use prefix) user.ts
Composable functions camelCase with use prefix useUser()
Store files camelCase + .store.ts suffix user.store.ts
Store functions camelCase with use prefix useUserStore
Server utils camelCase, flat, one file per domain server/utils/user.ts
Context classes PascalCase UserCreator.ts
Types/interfaces PascalCase CreateUserDto
Enums PascalCase values UserRole.Admin
API endpoints [resource].[method].ts create.post.ts
Shared type files PascalCase shared/types/User.ts
Shared util files camelCase shared/utils/user.ts
Component folders kebab-case by module components/user/detail/
Weekly Installs
2
First Seen
Feb 22, 2026
Installed on
opencode2
gemini-cli2
claude-code2
github-copilot2
codex2
kimi-cli2