nuxt
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 explicitimportstatementsserver/utils/is flat: one file per domain, all functions for that domain in the same fileshared/types/andshared/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
Containersuffix —UserDetail, notUserDetailContainer - Composable filenames have no
useprefix (app.ts), but the exported function still does (useApp) - Store filenames use
.store.tssuffix (user.store.ts), exported asuseUserStore
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
}
AuthUseris not a separate type — it is theUserinterface defined inshared/types/User.ts. If the authenticated user shape differs from the domain user, extend fromUserinUser.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:
findUserandgetUserActivityare auto-imported fromserver/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.ts — all 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(fromshared/types/User.ts). There is no separateAuthUsertype — the sameUserinterface 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
useAsyncDatato fetch page data viausePagescomposable — 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) — noContainersuffix - 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, andemit - 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
defineStorewith the setup store syntax (composition API style) - Store files use
.store.tssuffix:user.store.ts,app.store.ts - Exported store function keeps the
useprefix:useUserStore,useAppStore - Stores are auto-imported via
imports.dirs: ['stores']innuxt.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 clientplugin.client.ts— runs only on the clientplugin.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
useprefix:useUser,useApp - Filename has no
useprefix:user.ts,app.ts,pages.ts - Located in
app/composables/(flat, auto-imported) - One file per domain module
- Use-case composables call
$fetchdirectly —useAsyncDatais 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/ |