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
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:
- contains all Vue/client code (Nuxt 4 default)
- is never auto-imported — always use explicit statements
- is flat: one file per domain, all functions for that domain in the same file
- and 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 suffix — , not
- Composable filenames have no prefix (), but the exported function still does ()
- Store filenames use suffix (), exported as
Nuxt Config
ts
// 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
,
(via config),
, and
. Leverage this everywhere
except .
✅ Use auto-imports:
ts
// 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 :
ts
// 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.
ts
// 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 }
})
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 }
}
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>
ts
// shared/types/App.ts
export interface App {
config: AppConfig
user: User | null
}
export interface AppConfig {
featureFlags: Record<string, boolean>
locale: string
}
is not a separate type — it is the
interface defined in
. If the authenticated user shape differs from the domain user, extend from
in
.
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
. Each endpoint fetches everything the page needs and returns it in one response.
ts
// 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 }
})
ts
// 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:
and
are auto-imported from
.
Composable: — all page fetchers in one place.
ts
// 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: —
all page types together (not split by module, pages are a cross-cutting concern).
ts
// 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.
ts
// 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)
})
ts
// server/api/user/[id].delete.ts
export default defineEventHandler(async (event): Promise<void> => {
const id = getRouterParam(event, 'id')!
await deleteUser(event, id)
})
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
— 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:
ts
// server/contexts/user/domain/User.ts
export interface User {
id: string
name: string
email: string
createdAt: Date
}
Repository interface (DIP):
ts
// 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:
ts
// 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:
ts
// 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])
}
}
— 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.
contains
,
,
, etc.
ts
// 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: functions are auto-imported in route handlers.
classes are
never auto-imported — always explicitly imported inside
.
Shared Layer
is available on both client and server. Everything here is
flat (no subfolders) and
auto-importable.
— Types and enums, one file per module
ts
// 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
(from
). There is no separate
type — the same
interface represents domain users and authenticated users alike. If extra auth-only fields are needed, use intersection or optional fields.
Special files:
- — App initialization data (see App Endpoint section)
- — All page response types (not split by module, pages are a special cross-cutting concern)
— Zod schemas and utilities, one file per module
ts
// 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)
Rules:
- As simple as possible — just data fetching and layout composition
- Uses to fetch page data via 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)
vue
<!-- 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>
vue
<!-- 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: — inside the relevant module subfolder.
Rules:
- Named after the view it orchestrates (e.g. , , ) — no 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
vue
<!-- 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>
vue
<!-- 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. ):
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
for truly generic components.
Rules:
- Completely independent — no Pinia, no composables, no $fetch
- Communicates only through , , and
- Fully reusable within its module (or across the app if in )
- Uses Nuxt UI components internally
- Can contain local UI state (e.g., open/close toggles)
vue
<!-- 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 | | ❌ | ❌ (via ) | ❌ | ❌ |
| Orchestrating | | ✅ | ✅ (via composables) | ✅ | ❌ |
| Presentational | or | ❌ | ❌ | ✅ | ✅ |
State Management with Pinia
Stores hold reactive client-side state and are organized by domain module.
ts
// 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 with the setup store syntax (composition API style)
- Store files use suffix: ,
- Exported store function keeps the prefix: ,
- Stores are auto-imported via in
- Stores are only accessed in orchestrating components and composables — never in presentational components
- Stores are hydrated from server data in orchestrating components or the flow
Layouts, Middleware & Plugins
Layouts —
Layouts wrap pages and provide shared structure (header, sidebar, footer). Nuxt automatically wraps pages using
in
.
vue
<!-- app/layouts/default.vue -->
<template>
<div class="flex flex-col min-h-screen">
<AppHeader />
<main class="flex-1">
<slot />
</main>
<AppFooter />
</div>
</template>
vue
<!-- 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:
vue
<!-- app/pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>
Disable layout:
vue
<script setup lang="ts">
definePageMeta({ layout: false })
</script>
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
- Global middleware — filename ends with — runs on every route change
ts
// app/middleware/auth.ts — named middleware
export default defineNuxtRouteMiddleware((to, from) => {
const userStore = useUserStore()
if (!userStore.isAuthenticated) {
return navigateTo('/login')
}
})
ts
// 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:
vue
<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'role'],
requiredRole: UserRole.Admin,
})
</script>
Global middleware example:
ts
// app/middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.client) {
trackPageView(to.fullPath)
}
})
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:
- — runs on both server and client
- — runs only on the client
- — runs only on the server
- Plugins are ordered by filename (prefix with numbers if order matters: )
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)
}
})
ts
// 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:
ts
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 prefix: ,
- Filename has no prefix: , ,
- Located in (flat, auto-imported)
- One file per domain module
- Use-case composables call directly — is the page's responsibility
- Composables may read and write Pinia stores
Use-case composable pattern:
ts
// 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 |
|---|
| All interactive buttons |
| Content containers with header/footer slots |
| Dialog overlays |
| / | Form containers with validation |
| , , | Form inputs |
| Data tables |
| Status labels |
| Feedback messages |
| Navigation menus |
| / | Toast notifications |
Form with Zod validation:
vue
<!-- 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):
ts
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
in pages
Use
in pages to fetch data server-side with proper SSR support and deduplication.
ts
// 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),
)
in composables
Use
for mutations and imperative calls from composables (no SSR caching needed).
ts
const user = await $fetch<User>('/api/user/create', {
method: 'POST',
body: dto,
})
in
Ensures the app initialization runs exactly once during SSR and is not repeated on client hydration.
ts
await callOnce(async () => {
const { init } = useApp()
await init()
})
Error Handling
ts
// 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:
ts
// 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 :
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:
ts
// 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:
vue
<!-- 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 (, ) instead of one god composable. |
| DIP | 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.
vue
<!-- ✅ 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.
vue
<!-- ✅ 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
or
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
. The Vue layer (pages, orchestrating components, composables) should only orchestrate calls and manage UI state.
ts
// ❌ 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
to validate all incoming data at the API boundary using
or
. Never trust unvalidated input inside
or
.
ts
// 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 for app initialization
Never initialize the app inside
or a
without
. This prevents double-fetching during SSR hydration.
ts
// ✅ 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.
ts
// ✅ 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 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 | | |
| Components | | |
| Composable files | (no prefix) | |
| Composable functions | with prefix | |
| Store files | + suffix | |
| Store functions | with prefix | |
| Server utils | , flat, one file per domain | |
| Context classes | | |
| Types/interfaces | | |
| Enums | values | |
| API endpoints | | |
| Shared type files | | |
| Shared util files | | |
| Component folders | by module | |
<!--
Source references:
- https://nuxt.com/docs
- https://pinia.vuejs.org
- https://ui.nuxt.com
- https://zod.dev
- Guidelines skill (guidelines/SKILL.md) — SOLID, DDD, Clean Code
-->