opencode-agent-kit
Version:
Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)
1,227 lines (1,041 loc) • 34.1 kB
Markdown
---
name: nuxt4-vue3-patterns
description: "Nuxt 4 + Vue 3 Composition API patterns: app/ directory structure, server routes, data fetching (useFetch/useAsyncData), Nuxt UI components, Pinia state management, middleware (auth/redirect), SEO (useHead/useSeoMeta), error handling, and error pages."
---
# Nuxt 4 + Vue 3 Patterns
Comprehensive reference for building Nuxt 4 applications with Vue 3 Composition API, TypeScript, Nuxt UI, Pinia, and Tailwind CSS.
## 1. Nuxt 4 `app/` Directory Structure
Nuxt 4 introduces the `app/` directory as the primary location for application code, separating it from server-side code in `server/`.
```
my-nuxt-app/
├── app/
│ ├── app.vue # Root component (replaces nuxt.config layout)
│ ├── app.config.ts # Runtime app config
│ ├── layouts/ # Layout components
│ │ ├── default.vue
│ │ └── auth.vue
│ ├── pages/ # File-based routing
│ │ ├── index.vue
│ │ ├── login.vue
│ │ ├── dashboard.vue
│ │ ├── about.vue
│ │ └── posts/
│ │ ├── index.vue # /posts
│ │ └── [id].vue # /posts/:id
│ ├── components/ # Auto-imported Vue components
│ │ ├── AppHeader.vue
│ │ ├── AppFooter.vue
│ │ ├── ui/ # Reusable UI primitives
│ │ │ ├── BaseButton.vue
│ │ │ └── BaseCard.vue
│ │ └── form/ # Form-specific components
│ │ ├── FormInput.vue
│ │ └── FormSelect.vue
│ ├── composables/ # Auto-imported composables
│ │ ├── useAuth.ts
│ │ ├── useCustomFetch.ts
│ │ └── useToast.ts
│ ├── middleware/ # Route middleware (only route middleware here)
│ │ ├── auth.ts
│ │ └── guest.ts
│ ├── plugins/ # Vue plugins
│ │ ├── pinia.ts
│ │ └── toast.ts
│ ├── stores/ # Pinia stores (auto-imported)
│ │ ├── auth.store.ts
│ │ └── user.store.ts
│ ├── utils/ # Utility functions (auto-imported)
│ │ ├── format.ts
│ │ └── validation.ts
│ ├── errors/ # Error pages
│ │ ├── error.vue # General error page (~404/500)
│ │ └── forbidden.vue # Custom 403 error page
│ └── types/ # TypeScript types/interfaces
│ ├── user.ts
│ └── api.ts
├── server/ # Server engine (Nitropack-based)
│ ├── api/ # API routes
│ │ ├── hello.get.ts
│ │ ├── posts.get.ts
│ │ ├── posts.post.ts
│ │ └── auth/
│ │ ├── login.post.ts
│ │ └── me.get.ts
│ ├── routes/ # Custom server routes
│ │ └── sitemap.xml.ts
│ ├── middleware/ # Server middleware
│ │ ├── auth.ts
│ │ └── cors.ts
│ └── utils/ # Server-only utilities
│ ├── db.ts
│ └── jwt.ts
├── public/ # Static assets (served at root)
│ ├── favicon.ico
│ └── robots.txt
├── nuxt.config.ts # Nuxt configuration
├── tailwind.config.ts # Tailwind CSS configuration (if not in nuxt.config)
├── tsconfig.json
└── package.json
```
### Key Nuxt 4 Migration Notes
- `app/` directory replaces the root-level `pages/`, `layouts/`, `middleware/`, `plugins/`, `composables/`, and `components/` directories. Files can still live at the root for backward compatibility, but `app/` is the canonical location.
- `server/` directory remains at root level and contains API endpoints, server middleware, and server utilities.
- Auto-imports work for `components/`, `composables/`, `stores/`, and `utils/` inside `app/`.
- The `app/router.options.ts` file can be used for custom Vue Router configuration.
## 2. Server Routes & API Endpoints
### API Route Convention (Nitro)
Server routes follow Nitro's file-based routing with HTTP method suffixes:
| File | HTTP Method | Route |
|---|---|---|
| `server/api/hello.get.ts` | GET | `/api/hello` |
| `server/api/posts.get.ts` | GET | `/api/posts` |
| `server/api/posts.post.ts` | POST | `/api/posts` |
| `server/api/posts/[id].get.ts` | GET | `/api/posts/:id` |
| `server/api/posts/[id].put.ts` | PUT | `/api/posts/:id` |
| `server/api/posts/[id].delete.ts` | DELETE | `/api/posts/:id` |
| `server/api/auth/login.post.ts` | POST | `/api/auth/login` |
### API Handler Pattern (TypeScript)
```ts
// server/api/posts/[id].get.ts
import { Post } from '~/types/post'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// Validate params
if (!id || isNaN(Number(id))) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid post ID',
})
}
// Fetch data (example with Drizzle/Kysely)
const post = await db.select().from(posts).where(eq(posts.id, Number(id))).get()
if (!post) {
throw createError({
statusCode: 404,
statusMessage: 'Post not found',
})
}
return { data: post }
})
```
### POST / Login Handler
```ts
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate input
if (!body.email || !body.password) {
throw createError({
statusCode: 400,
statusMessage: 'Email and password are required',
})
}
// Authenticate (example)
const user = await authenticateUser(body.email, body.password)
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid credentials',
})
}
// Set session
await setUserSession(event, { user: { id: user.id, email: user.email, role: user.role } })
return { data: { user: { id: user.id, email: user.email, name: user.name } } }
})
```
### Protected API Endpoint with Auth Check
```ts
// server/api/posts.post.ts
export default defineEventHandler(async (event) => {
// Require authentication
const session = await getUserSession(event)
if (!session.user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const body = await readBody(event)
// Validate
if (!body.title || !body.content) {
throw createError({ statusCode: 400, statusMessage: 'Title and content required' })
}
// Create post
const post = await db.insert(posts).values({
title: body.title,
content: body.content,
authorId: session.user.id,
}).returning().get()
return { data: post }
})
```
### Server Middleware (CORS, Auth)
```ts
// server/middleware/cors.ts
export default defineEventHandler((event) => {
setResponseHeaders(event, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
})
if (getMethod(event) === 'OPTIONS') {
event.respondWith(new Response(null, { status: 204 }))
}
})
```
### Server Utility / Database
```ts
// server/utils/db.ts
import { drizzle } from 'drizzle-orm/libsql'
import { createClient } from '@libsql/client'
const client = createClient({
url: process.env.DATABASE_URL || 'file:./data.db',
})
export const db = drizzle(client)
```
## 3. useFetch / useAsyncData Data Fetching
### Basic useFetch
```ts
// app/pages/posts/index.vue
<script setup lang="ts">
interface Post {
id: number
title: string
content: string
createdAt: string
}
// Basic usage — automatically awaits on server, sends data to client
const { data: posts, pending, error, refresh } = await useFetch<{ data: Post[] }>('/api/posts')
// With reactive params (auto-refetches when params change)
const page = ref(1)
const { data: paginatedPosts } = await useFetch<{ data: Post[] }>('/api/posts', {
query: { page, limit: 10 },
})
</script>
<template>
<div>
<div v-if="pending">Loading...</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else>
<div v-for="post in posts?.data" :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
</div>
</div>
</div>
</template>
```
### useAsyncData for Custom Fetching
```ts
// app/pages/posts/[id].vue
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id as string)
const { data, pending, error } = await useAsyncData<{ data: Post }>(
`post-${id.value}`,
async () => {
const response = await $fetch(`/api/posts/${id.value}`)
return response
},
{
watch: [id], // Refetch when id changes
default: () => ({ data: null }),
lazy: true, // Don't block navigation
server: true, // Fetch on server (default: true)
// Dedupe across components
dedupe: 'cancel',
}
)
// Mutate to update cached data without refetch
const updateTitle = (newTitle: string) => {
if (data.value) {
data.value.data.title = newTitle
}
}
</script>
```
### Custom Composable for Consistent Fetching
```ts
// app/composables/useApi.ts
import type { UseFetchOptions } from 'nuxt/app'
export function useApi<T>(url: string | (() => string), options?: UseFetchOptions<T>) {
// Default options with error handling
const defaults: UseFetchOptions<T> = {
key: typeof url === 'string' ? url : undefined,
headers: {
'Content-Type': 'application/json',
},
onResponseError({ response }) {
// Centralized error handling
const toast = useToast()
toast.add({
title: 'Error',
description: response._data?.statusMessage || 'An unexpected error occurred',
color: 'red',
})
},
}
const mergedOptions = { ...defaults, ...options }
return useFetch<T>(url, mergedOptions)
}
// Usage:
// const { data, pending } = await useApi<{ data: Post[] }>('/api/posts')
```
### Mutations with useFetch
```ts
// app/pages/posts/create.vue
<script setup lang="ts">
const router = useRouter()
// POST request
const { execute, pending } = useFetch('/api/posts', {
method: 'POST',
body: { title: '', content: '' },
immediate: false, // Don't execute immediately
watch: false, // Don't watch reactive body changes
onResponse({ response }) {
if (response.ok) {
navigateTo('/posts')
}
}
})
const submit = async (formData: { title: string; content: string }) => {
// Override body before executing
await execute(null, { body: formData } as any)
}
</script>
```
### Key Options Reference
| Option | Type | Description |
|---|---|---|
| `key` | string | Unique key for deduplication/caching |
| `lazy` | boolean | Don't block navigation (default: false) |
| `server` | boolean | Fetch on server side (default: true) |
| `default` | () => T | Default value while loading |
| `watch` | WatchSource[] | Reactive sources that trigger refetch |
| `immediate` | boolean | Execute immediately (default: true) |
| `dedupe` | 'cancel' \| 'defer' | Deduplicate concurrent requests |
| `transform` | (data) => T | Transform response data |
| `pick` | string[] | Pick specific fields from response |
| `onResponse` | function | Callback on successful response |
| `onResponseError` | function | Callback on error response |
## 4. Nuxt UI Component Usage
### Import & Setup
Nuxt UI v3 components are auto-imported when the module is registered in `nuxt.config.ts`:
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
ui: {
// Optional: disable auto-import of specific components
// prefixes: ['U'],
},
colorMode: {
preference: 'system', // 'system' | 'light' | 'dark'
},
})
```
### Common Components
```vue
<!-- Button variants -->
<UButton label="Submit" size="sm" color="primary" variant="solid" />
<UButton label="Cancel" color="neutral" variant="outline" />
<UButton loading :disabled="isPending">Saving...</UButton>
<UButton icon="i-heroicons-trash" color="error" variant="ghost" />
<!-- Input -->
<UInput v-model="email" placeholder="Email" size="lg" />
<UInput v-model="password" type="password" placeholder="Password" />
<UInput v-model="search" :trailing="true">
<template #trailing>
<UButton icon="i-heroicons-magnifying-glass" size="2xs" color="neutral" variant="ghost" />
</template>
</UInput>
<!-- Select -->
<USelect v-model="role" :items="['admin', 'user', 'moderator']" placeholder="Select role" />
<!-- Textarea -->
<UTextarea v-model="content" placeholder="Write something..." :rows="5" />
<!-- Form Group with Validation -->
<UFormGroup label="Email" name="email" :error="errors.email">
<UInput v-model="form.email" />
</UFormGroup>
<!-- Card -->
<UCard>
<template #header>
<h3 class="font-semibold">Card Title</h3>
</template>
<p>Card content goes here.</p>
<template #footer>
<UButton label="Action" />
</template>
</UCard>
<!-- Modal -->
<UModal v-model:open="isModalOpen">
<template #content>
<UCard>
<template #header>
<h3>Modal Title</h3>
</template>
<p>Modal content</p>
</UCard>
</template>
</UModal>
<!-- Toast Notifications -->
<script setup lang="ts">
const toast = useToast()
const showSuccess = () => {
toast.add({
title: 'Success!',
description: 'Your changes have been saved.',
color: 'success',
icon: 'i-heroicons-check-circle',
})
}
const showError = () => {
toast.add({
title: 'Error',
description: 'Something went wrong.',
color: 'error',
duration: 5000, // 5 seconds
})
}
</script>
<!-- Badge / Tag -->
<UBadge label="Active" color="success" variant="solid" size="sm" />
<UBadge label="Pending" color="warning" variant="outline" />
<!-- Table -->
<UTable :columns="columns" :rows="rows" :loading="isLoading" sortable>
<template #status-data="{ row }">
<UBadge :label="row.status" :color="row.status === 'active' ? 'success' : 'neutral'" />
</template>
</UTable>
```
### Layout with Nuxt UI
```vue
<!-- app/layouts/default.vue -->
<script setup lang="ts">
const colorMode = useColorMode()
const isDark = computed(() => colorMode.value === 'dark')
</script>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950">
<!-- Navigation Bar -->
<UContainer>
<header class="flex items-center justify-between py-4">
<NuxtLink to="/" class="text-xl font-bold">My App</NuxtLink>
<nav class="flex items-center gap-4">
<NuxtLink to="/posts">Posts</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
<UButton
:icon="isDark ? 'i-heroicons-sun' : 'i-heroicons-moon'"
variant="ghost"
color="neutral"
@click="colorMode.preference = isDark ? 'light' : 'dark'"
/>
</nav>
</header>
</UContainer>
<!-- Page Content -->
<UContainer>
<main class="py-8">
<slot />
</main>
</UContainer>
<!-- Footer -->
<UContainer>
<footer class="py-6 text-center text-sm text-gray-500">
© {{ new Date().getFullYear() }} My App
</footer>
</UContainer>
</div>
</template>
```
## 5. Pinia State Management
### Store Definition (Composition API style)
```ts
// app/stores/auth.store.ts
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => !!user.value)
const userRole = computed(() => user.value?.role ?? null)
// Actions
async function login(email: string, password: string) {
const { data, error } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email, password },
immediate: true,
})
if (error.value) throw new Error(error.value.statusMessage)
user.value = data.value.data.user
token.value = data.value.data.token
}
async function fetchProfile() {
const { data } = await useFetch('/api/auth/me')
if (data.value) {
user.value = data.value.data
}
}
function logout() {
user.value = null
token.value = null
navigateTo('/login')
}
// Persist to localStorage (optional)
// Use pinia-plugin-persistedstate for automatic persistence
return { user, token, isAuthenticated, userRole, login, fetchProfile, logout }
})
```
### Store with Data Fetching (Posts)
```ts
// app/stores/posts.store.ts
export const usePostsStore = defineStore('posts', () => {
const posts = ref<Post[]>([])
const currentPost = ref<Post | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchPosts() {
isLoading.value = true
error.value = null
try {
const { data } = await useFetch<{ data: Post[] }>('/api/posts')
posts.value = data.value?.data ?? []
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function fetchPostById(id: string | number) {
isLoading.value = true
error.value = null
try {
const { data } = await useFetch<{ data: Post }>(`/api/posts/${id}`)
currentPost.value = data.value?.data ?? null
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function createPost(input: { title: string; content: string }) {
const { data } = await useFetch('/api/posts', {
method: 'POST',
body: input,
})
if (data.value) {
posts.value.push(data.value.data)
}
}
return { posts, currentPost, isLoading, error, fetchPosts, fetchPostById, createPost }
})
```
### Using Pinia in Components
```vue
<!-- app/pages/posts/index.vue -->
<script setup lang="ts">
const store = usePostsStore()
// Or import { usePostsStore } from '~/stores/posts.store'
// Fetch on mount
onMounted(() => {
store.fetchPosts()
})
// Direct access
const { posts, isLoading } = storeToRefs(store)
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Posts</h1>
<div v-if="isLoading" class="flex justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-6 w-6" />
</div>
<div v-else-if="store.error" class="text-red-500">{{ store.error }}</div>
<div v-else class="grid gap-4">
<UCard v-for="post in posts" :key="post.id">
<NuxtLink :to="`/posts/${post.id}`">
<h2 class="font-semibold">{{ post.title }}</h2>
</NuxtLink>
</UCard>
</div>
<div v-if="posts.length === 0 && !isLoading" class="text-gray-500 text-center py-8">
No posts found.
</div>
</div>
</template>
```
### State Reset
```ts
// Reset entire store (e.g., on logout)
const authStore = useAuthStore()
authStore.$reset()
// Or patch specific state
authStore.$patch({
user: null,
token: null,
})
// Subscribe to state changes
authStore.$subscribe((mutation, state) => {
console.log('Auth state changed:', state)
})
```
## 6. Middleware (Auth, Redirect)
### Route Middleware Basics
Route middleware in Nuxt 4 lives in `app/middleware/`. Files are auto-imported by filename.
### Authentication Middleware
```ts
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip middleware for public routes
const publicRoutes = ['/login', '/register', '/forgot-password']
if (publicRoutes.includes(to.path)) {
return
}
// Check authentication state
const authStore = useAuthStore()
// If user not loaded yet, try to fetch profile
if (!authStore.isAuthenticated && authStore.token) {
try {
await authStore.fetchProfile()
} catch {
// Token invalid, redirect to login
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
}
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
})
```
### Role-Based Middleware (Admin)
```ts
// app/middleware/admin.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
const authStore = useAuthStore()
// Ensure user is authenticated first
if (!authStore.isAuthenticated) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
})
}
// Check role
if (authStore.userRole !== 'admin') {
return navigateTo('/') // Redirect to homepage
// Or use abortNavigation for 403
// return abortNavigation(createError({
// statusCode: 403,
// statusMessage: 'Forbidden',
// }))
}
})
```
### Guest Middleware (Redirect if already logged in)
```ts
// app/middleware/guest.ts
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
// Redirect authenticated users away from login/register
return navigateTo(to.query.redirect as string || '/dashboard')
}
})
```
### Applying Middleware
```ts
// In a page component (inline middleware)
definePageMeta({
middleware: ['auth'],
// or multiple:
// middleware: ['auth', 'admin'],
})
// Global middleware — add ".global" suffix
// app/middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
// Track page view (runs on every route change)
trackPageView(to.path)
})
```
### Middleware with Navigation Guards
```ts
// app/middleware/form-guard.ts
export default defineNuxtRouteMiddleware((to, from) => {
const formStore = useFormStore()
if (formStore.isDirty) {
// Use abortNavigation to prevent navigation and show a dialog
const { proceed } = await showUnsavedChangesDialog()
if (!proceed) {
return abortNavigation()
}
}
})
```
## 7. SEO dengan useHead / useSeoMeta
### useHead — Full Head Control
```ts
// app/pages/index.vue
<script setup lang="ts">
useHead({
title: 'Home - My App',
meta: [
{ name: 'description', content: 'Welcome to My App — a modern Nuxt 4 application.' },
{ name: 'keywords', content: 'nuxt, vue, app' },
{ property: 'og:title', content: 'My App' },
{ property: 'og:description', content: 'A modern Nuxt 4 application built with Vue 3 and TypeScript.' },
{ property: 'og:image', content: '/og-image.png' },
],
link: [
{ rel: 'canonical', href: 'https://myapp.com' },
],
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'My App',
url: 'https://myapp.com',
}),
},
],
htmlAttrs: {
lang: 'en',
},
bodyAttrs: {
class: 'home-page',
},
})
</script>
```
### useSeoMeta — Simplified SEO
```ts
// app/pages/about.vue
<script setup lang="ts">
useSeoMeta({
title: 'About Us - My App',
ogTitle: 'About My App',
description: 'Learn more about our team and mission.',
ogDescription: 'Learn more about our team and mission.',
ogImage: '/images/about-og.png',
ogUrl: 'https://myapp.com/about',
twitterCard: 'summary_large_image',
twitterTitle: 'About My App',
twitterDescription: 'Learn more about our team and mission.',
})
</script>
```
### Dynamic SEO with Route Params
```ts
// app/pages/posts/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData<{ data: Post }>(
`post-${route.params.id}`,
() => $fetch(`/api/posts/${route.params.id}`)
)
const title = computed(() => post.value?.data?.title ?? 'Post - My App')
const description = computed(() =>
post.value?.data?.content?.slice(0, 160).replace(/<[^>]*>/g, '') ?? ''
)
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description,
ogImage: post.value?.data?.image ?? '/default-og.png',
ogUrl: computed(() => `https://myapp.com/posts/${route.params.id}`),
})
</script>
```
### useHead Tips for SEO
- Use `useHead` for fine-grained control (multiple meta tags, link tags, scripts, structured data).
- Use `useSeoMeta` for standard SEO meta tags with reactive computed properties.
- Both components are auto-imported.
- Set global defaults in `app/app.vue` using `useHead` with the `titleTemplate` option.
- Reactive values are automatically tracked — no need for `watch`.
```ts
// app/app.vue — global SEO defaults
useHead({
titleTemplate: (title) => `${title ? `${title} | ` : ''}My App`,
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
})
```
## 8. Error Handling
### Client-Side Error Handling
```ts
// App-level error handler (app/error.vue is the error page — see section 9)
// In components — using try/catch with async operations
async function submitForm() {
try {
await store.createPost(formData)
toast.add({ title: 'Post created!', color: 'success' })
} catch (err: any) {
toast.add({
title: 'Failed to create post',
description: err.message || 'Please try again.',
color: 'error',
})
}
}
// Using Vue's onErrorCaptured
onErrorCaptured((err, instance, info) => {
console.error('Component error:', err, info)
// Optionally send to error tracking
captureException(err)
// Return false to prevent error propagation
return false
})
```
### API Error Handling with useFetch
```ts
// app/composables/useApi.ts — with centralized error handling
export function useApi<T>(url: string | Ref<string> | (() => string), opts: UseFetchOptions<T> = {}) {
const options: UseFetchOptions<T> = {
...opts,
onResponseError({ response }) {
const toast = useToast()
const status = response.status
const message = response._data?.statusMessage || response.statusText
if (status === 401) {
const authStore = useAuthStore()
authStore.logout()
toast.add({ title: 'Session expired', color: 'warning' })
navigateTo('/login')
} else if (status === 403) {
toast.add({ title: 'Access denied', color: 'error' })
} else if (status === 422) {
// Validation errors
console.error('Validation errors:', response._data?.data)
} else {
toast.add({ title: 'Error', description: message, color: 'error' })
}
// Also call user-provided onResponseError if any
opts.onResponseError?.({ response })
},
}
return useFetch<T>(url, options)
}
```
### Global Error Handling with Vue Error Handler
```ts
// app/plugins/error-handler.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.config.errorHandler = (err, instance, info) => {
console.error('Global Vue error:', err, info)
// Send to error monitoring (Sentry, etc.)
// captureException(err)
}
// Handle unhandled promise rejections
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason)
// captureException(event.reason)
})
}
})
```
### Server Error Handling
```ts
// server/api/posts/[id].get.ts
export default defineEventHandler(async (event) => {
try {
const id = getRouterParam(event, 'id')
const post = await db.select().from(posts).where(eq(posts.id, Number(id))).get()
if (!post) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
return { data: post }
} catch (error: any) {
// Log server-side
console.error('Error fetching post:', error)
// Re-throw known errors
if (error.statusCode) {
throw error
}
// Handle unknown errors
throw createError({
statusCode: 500,
statusMessage: 'Internal server error',
})
}
})
```
### Using `createError` Utility
```ts
// Create a standardized error response
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
message: 'Invalid email format',
data: {
field: 'email',
constraints: ['Must be a valid email address'],
},
})
// Catch on client side
try {
await $fetch('/api/users', { method: 'POST', body })
} catch (err: any) {
if (err.statusCode === 422) {
// Handle validation errors
console.log(err.data?.data?.issues) // Zod validation issues
}
}
```
## 9. Error Pages
### Default Error Page
```vue
<!-- app/errors/error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{
error: NuxtError
}>()
const handleClearError = () => {
clearError({ redirect: '/' })
}
const is404 = computed(() => props.error.statusCode === 404)
const is403 = computed(() => props.error.statusCode === 403)
const is500 = computed(() => props.error.statusCode === 500)
// Set SEO for error page
useSeoMeta({
title: is404.value ? 'Page Not Found' : 'Error',
description: is404.value
? 'The page you are looking for does not exist.'
: 'An unexpected error occurred.',
})
</script>
<template>
<UContainer class="min-h-[60vh] flex items-center justify-center">
<div class="text-center max-w-md">
<!-- Error Icon -->
<div class="mb-6">
<UIcon
v-if="is404"
name="i-heroicons-document-magnifying-glass"
class="h-20 w-20 mx-auto text-gray-400 dark:text-gray-600"
/>
<UIcon
v-else-if="is403"
name="i-heroicons-lock-closed"
class="h-20 w-20 mx-auto text-orange-400 dark:text-orange-600"
/>
<UIcon
v-else
name="i-heroicons-exclamation-triangle"
class="h-20 w-20 mx-auto text-red-400 dark:text-red-600"
/>
</div>
<!-- Error Code -->
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-2">
{{ error.statusCode }}
</h1>
<!-- Error Message -->
<p class="text-xl text-gray-600 dark:text-gray-400 mb-2">
{{ is404 ? 'Page Not Found' : is403 ? 'Access Denied' : 'Something went wrong' }}
</p>
<p class="text-gray-500 dark:text-gray-500 mb-8">
{{ is404
? 'The page you are looking for does not exist or has been moved.'
: is403
? 'You do not have permission to access this page.'
: error.statusMessage || 'An unexpected error occurred. Please try again later.'
}}
</p>
<!-- Actions -->
<div class="flex items-center justify-center gap-3">
<UButton
label="Go Home"
color="primary"
variant="solid"
size="lg"
@click="handleClearError"
/>
<UButton
label="Try Again"
color="neutral"
variant="outline"
size="lg"
@click="clearError({ redirect: error.url || '/' })"
v-if="!is404"
/>
</div>
<!-- Debug info (dev only) -->
<p v-if="error.stack && process.dev" class="mt-8 text-xs text-left text-gray-400">
<strong>Stack trace:</strong><br />
<code class="block mt-1 whitespace-pre-wrap">{{ error.stack }}</code>
</p>
</div>
</UContainer>
</template>
```
### Custom 403 Forbidden Page
```vue
<!-- app/errors/forbidden.vue -->
<script setup lang="ts">
const handleGoBack = () => {
clearError({ redirect: '/' })
}
</script>
<template>
<UContainer class="min-h-[60vh] flex items-center justify-center">
<div class="text-center max-w-md">
<UIcon name="i-heroicons-shield-exclamation" class="h-20 w-20 mx-auto text-red-400 mb-6" />
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-2">Access Denied</h1>
<p class="text-gray-600 dark:text-gray-400 mb-8">
You don't have permission to access this resource. Contact your administrator if you believe this is a mistake.
</p>
<UButton label="Go Home" color="primary" variant="solid" size="lg" @click="handleGoBack" />
</div>
</UContainer>
</template>
```
### Using NuxtLink to error pages
When `clearError` is called with a redirect path, the error boundary is cleared and the app navigates to the specified route. For custom error handling in pages:
```vue
<!-- In a page, trigger a 404 manually -->
<script setup lang="ts">
const { data: post } = await useAsyncData('post', () => $fetch('/api/posts/999'))
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
</script>
```
### Global Server Error Handler (optional)
```ts
// server/middleware/error-handler.ts
export default defineEventHandler(async (event) => {
try {
return await event.node.req.url
? await handleEvent(event)
: undefined
} catch (error: any) {
// Log server errors
console.error('Server error:', error)
// Return consistent error shape
return sendError(event, createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Internal Server Error',
message: error.message || 'An unexpected error occurred',
}))
}
})
```
## Quick Reference Summary
| Topic | Key API / File | Notes |
|---|---|---|
| Directory structure | `app/` dir at project root | Nuxt 4 canonical structure |
| API routes | `server/api/*.get.ts`, `.post.ts` | Nitro file-based routing |
| Data fetching | `useFetch()`, `useAsyncData()` | Auto-dedupe, SSR-aware |
| UI components | Auto-imported `UButton`, `UCard`, etc. | Nuxt UI v3 auto-imports |
| State management | `defineStore('name', () => { ... })` | Pinia composition API |
| Route middleware | `app/middleware/auth.ts` | Named or `.global` |
| SEO | `useHead()`, `useSeoMeta()` | Reactive, auto-tracked |
| Error pages | `app/errors/error.vue` | Props: `error: NuxtError` |
| Error throwing | `createError({ statusCode, statusMessage })` | Works in server + client |