Nuxt

Essential Nuxt 4 syntax, auto-imports, data fetching, and best practices for building Vue applications.

frameworks
nuxtvuejavascriptssrfrontend

Project Structure

# Nuxt 4 directory structure (new default)
├── nuxt.config.ts      # Nuxt configuration
├── app/                # App source directory (new in v4)
   ├── app.vue         # Main app component
   ├── app.config.ts   # App configuration
   ├── error.vue       # Error page
   ├── assets/         # Build-processed assets
   ├── components/     # Auto-imported components
   ├── composables/    # Auto-imported composables
   ├── layouts/        # App layouts
   ├── middleware/     # Route middleware
   ├── pages/          # File-based routing
   ├── plugins/        # Vue plugins
   └── utils/          # Utility functions
├── shared/             # Shared code (app & server)
├── server/             # Server routes & API
├── content/            # Content files (if using @nuxt/content)
├── layers/             # Nuxt layers
├── modules/            # Local modules
└── public/             # Static assets

Pages & Routing

<!-- app/pages/index.vue -->
<template>
  <div>
    <h1>Home Page</h1>
  </div>
</template>

<!-- app/pages/about.vue => /about -->
<!-- app/pages/users/index.vue => /users -->
<!-- app/pages/users/[id].vue => /users/:id -->
<!-- app/pages/posts/[...slug].vue => /posts/* (catch-all) -->
<!-- app/pages/users/[id].vue -->
<script setup>
// Get route params
const route = useRoute()
const userId = route.params.id

// Navigation
const router = useRouter()

function goHome() {
  navigateTo('/')
  // Or with options
  navigateTo('/login', { replace: true })
}
</script>

<template>
  <div>
    <h1>User {{ userId }}</h1>
    <NuxtLink to="/">Home</NuxtLink>
    <NuxtLink :to="{ name: 'users-id', params: { id: 123 } }">
      User 123
    </NuxtLink>
    <button @click="goHome">Go Home</button>
  </div>
</template>

Data Fetching

<script setup>
// useFetch - SSR-friendly fetch with caching
// In Nuxt 4: data defaults to undefined, uses shallowRef
const { data, status, error, refresh } = await useFetch('/api/users')

// With options
const { data: posts } = await useFetch('/api/posts', {
  method: 'POST',
  body: { limit: 10 },
  query: { page: 1 },
  headers: { 'Authorization': 'Bearer token' },
  // Transform response
  transform: (data) => data.map(p => p.title),
  // Cache key
  key: 'posts-list',
  // Only fetch on server
  server: true,
  // Lazy fetch (don't block navigation)
  lazy: true,
  // Default value
  default: () => [],
  // Deep reactivity (default: false in v4)
  deep: true
})

// Nuxt 4: Reactive keys - auto refetch when key changes
const userId = ref('123')
const { data: user } = await useFetch(() => `/api/users/${userId.value}`)

// useAsyncData - for custom async logic
// Same key shares data across components (singleton pattern in v4)
const { data: userData } = await useAsyncData('user', async () => {
  const user = await $fetch('/api/user')
  const posts = await $fetch(`/api/users/${user.id}/posts`)
  return { ...user, posts }
})

// Nuxt 4: getCachedData with context
const { data } = await useAsyncData('key', fetchFn, {
  getCachedData: (key, nuxtApp, ctx) => {
    // ctx.cause: 'initial' | 'refresh:hook' | 'refresh:manual' | 'watch'
    if (ctx.cause === 'refresh:manual') return undefined
    return nuxtApp.payload.data[key]
  }
})

// useLazyFetch - non-blocking fetch
const { data, status } = useLazyFetch('/api/data')

// $fetch - direct fetch utility
const result = await $fetch('/api/endpoint', {
  method: 'POST',
  body: { name: 'John' }
})

// Refresh data
async function reload() {
  // Nuxt 4: dedupe accepts 'cancel' or 'defer'
  await refresh({ dedupe: 'cancel' })
}
</script>

<template>
  <div>
    <p v-if="status === 'pending'">Loading...</p>
    <p v-else-if="status === 'error'">Error: {{ error.message }}</p>
    <ul v-else>
      <li v-for="user in data" :key="user.id">{{ user.name }}</li>
    </ul>
    <button @click="reload">Refresh</button>
  </div>
</template>

State Management

<script setup>
// useState - SSR-friendly shared state
const counter = useState('counter', () => 0)

// Global state across components
const user = useState('user', () => ({
  name: '',
  email: ''
}))

function increment() {
  counter.value++
}

function setUser(name, email) {
  user.value = { name, email }
}
</script>

<!-- Access in another component -->
<script setup>
// Same key returns same state
const counter = useState('counter')
const user = useState('user')
</script>
// composables/useAuth.ts - Custom composable
export const useAuth = () => {
  const user = useState('auth-user', () => null)
  const isLoggedIn = computed(() => !!user.value)

  async function login(credentials) {
    const data = await $fetch('/api/login', {
      method: 'POST',
      body: credentials
    })
    user.value = data.user
  }

  function logout() {
    user.value = null
    navigateTo('/login')
  }

  return { user, isLoggedIn, login, logout }
}

Layouts

<!-- app/layouts/default.vue -->
<template>
  <div>
    <AppHeader />
    <main>
      <slot />
    </main>
    <AppFooter />
  </div>
</template>

<!-- app/layouts/auth.vue -->
<template>
  <div class="auth-layout">
    <slot />
  </div>
</template>

<!-- app/pages/login.vue - Use specific layout -->
<script setup>
definePageMeta({
  layout: 'auth'
})
</script>

<!-- Or set layout dynamically -->
<script setup>
const route = useRoute()

// Change layout based on condition
definePageMeta({
  layout: false // Disable layout
})
</script>

<template>
  <NuxtLayout :name="someCondition ? 'custom' : 'default'">
    <NuxtPage />
  </NuxtLayout>
</template>

Components

<!-- app/components/AppButton.vue - Auto-imported -->
<script setup>
defineProps({
  variant: {
    type: String,
    default: 'primary'
  }
})
</script>

<template>
  <button :class="variant">
    <slot />
  </button>
</template>

<!-- Usage (no import needed) -->
<template>
  <AppButton variant="secondary">Click me</AppButton>
</template>

<!-- app/components/base/Button.vue => <BaseButton> -->
<!-- app/components/ui/Card.vue => <UiCard> -->
<!-- Nuxt 4: Component names normalized to match file structure -->
<!-- Client-only component -->
<template>
  <ClientOnly>
    <BrowserOnlyComponent />
    <template #fallback>
      <p>Loading...</p>
    </template>
  </ClientOnly>
</template>

<!-- Lazy load component -->
<template>
  <LazyHeavyComponent v-if="showHeavy" />
</template>

Middleware

// app/middleware/auth.ts - Named middleware
export default defineNuxtRouteMiddleware((to, from) => {
  const { isLoggedIn } = useAuth()
  
  if (!isLoggedIn.value && to.path !== '/login') {
    return navigateTo('/login')
  }
})

// app/middleware/auth.global.ts - Global middleware (runs on every route)
export default defineNuxtRouteMiddleware((to, from) => {
  console.log('Navigating to:', to.path)
})

// Nuxt 4: middleware/folder/index.ts is now auto-registered
<!-- Apply middleware to page -->
<script setup>
definePageMeta({
  middleware: ['auth'],
  // Or inline middleware
  middleware: [
    function (to, from) {
      console.log('Inline middleware')
    }
  ]
})
</script>

Server Routes (API)

// server/api/users.get.ts
export default defineEventHandler(async (event) => {
  // Return JSON
  return [
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ]
})

// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  // Create user logic
  return { id: 3, ...body }
})

// server/api/users/[id].ts - Dynamic route
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return { id, name: `User ${id}` }
})

// server/api/search.ts - Query params
export default defineEventHandler((event) => {
  const query = getQuery(event)
  // /api/search?q=hello => query.q = 'hello'
  return { results: [], query: query.q }
})

// server/api/protected.ts - With validation
export default defineEventHandler(async (event) => {
  const headers = getHeaders(event)
  
  if (!headers.authorization) {
    throw createError({
      statusCode: 401,
      message: 'Unauthorized'
    })
  }
  
  return { secret: 'data' }
})

Plugins

// app/plugins/my-plugin.ts
export default defineNuxtPlugin((nuxtApp) => {
  // Available on client and server
  return {
    provide: {
      hello: (name: string) => `Hello ${name}!`
    }
  }
})

// app/plugins/client-only.client.ts - Client only
export default defineNuxtPlugin(() => {
  // Browser APIs available here
})

// app/plugins/server-only.server.ts - Server only
export default defineNuxtPlugin(() => {
  // Server-only logic
})
<!-- Using plugin -->
<script setup>
const { $hello } = useNuxtApp()
console.log($hello('World')) // Hello World!
</script>

SEO & Meta

<script setup>
// Page-level meta (Nuxt 4 uses Unhead v2)
useHead({
  title: 'My Page Title',
  meta: [
    { name: 'description', content: 'Page description' },
    { property: 'og:title', content: 'My Page' }
  ],
  link: [
    { rel: 'canonical', href: 'https://example.com/page' }
  ],
  script: [
    { src: 'https://example.com/script.js', defer: true }
  ]
})

// Dynamic title
const title = ref('Initial Title')
useHead({
  title: () => title.value
})

// SEO composable
useSeoMeta({
  title: 'My Amazing Site',
  ogTitle: 'My Amazing Site',
  description: 'This is my amazing site.',
  ogDescription: 'This is my amazing site.',
  ogImage: 'https://example.com/image.png',
  twitterCard: 'summary_large_image'
})
</script>
// nuxt.config.ts - Global defaults
export default defineNuxtConfig({
  app: {
    head: {
      title: 'My App',
      meta: [
        { charset: 'utf-8' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' }
      ],
      link: [
        { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
      ]
    }
  }
})

Runtime Config

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only (private)
    apiSecret: process.env.API_SECRET,
    
    // Public (exposed to client)
    public: {
      apiBase: process.env.API_BASE || '/api'
    }
  }
})
<!-- Using runtime config -->
<script setup>
const config = useRuntimeConfig()

// Client: only public available
console.log(config.public.apiBase)

// Server: all config available
// console.log(config.apiSecret)
</script>
// server/api/example.ts - Server-side access
export default defineEventHandler((event) => {
  const config = useRuntimeConfig()
  // Access private config
  const secret = config.apiSecret
  return { status: 'ok' }
})

Error Handling

<!-- app/error.vue - Custom error page -->
<script setup>
const props = defineProps({
  error: Object
})

// Nuxt 4: error.data is now automatically parsed
const errorData = props.error.data

const handleError = () => clearError({ redirect: '/' })
</script>

<template>
  <div>
    <h1>{{ error.statusCode }}</h1>
    <p>{{ error.message }}</p>
    <button @click="handleError">Go Home</button>
  </div>
</template>
<!-- Handling errors in components -->
<script setup>
const { data, error } = await useFetch('/api/data')

// Throw error page
if (error.value) {
  throw createError({
    statusCode: 404,
    message: 'Page not found',
    data: { additionalInfo: 'some data' }
  })
}
</script>

<template>
  <NuxtErrorBoundary>
    <template #error="{ error, clearError }">
      <p>Error: {{ error.message }}</p>
      <button @click="clearError">Clear</button>
    </template>
    <SomeComponent />
  </NuxtErrorBoundary>
</template>

Nuxt Config

// nuxt.config.ts
export default defineNuxtConfig({
  // Enable devtools
  devtools: { enabled: true },
  
  // SSR mode (default: true)
  ssr: true,
  
  // Modules
  modules: [
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
    '@nuxt/image'
  ],
  
  // CSS
  css: ['~/assets/css/main.css'],
  
  // Auto-imports
  imports: {
    dirs: ['stores']
  },
  
  // Components config
  components: [
    { path: '~/components', pathPrefix: false }
  ],
  
  // Route rules
  routeRules: {
    '/': { prerender: true },
    '/api/**': { cors: true },
    '/admin/**': { ssr: false }
  },
  
  // Nitro server config
  nitro: {
    preset: 'node-server',
    // Nuxt 4: prerender config moved here
    prerender: {
      routes: ['/sitemap.xml'],
      ignore: ['/admin']
    }
  },
  
  // TypeScript (Nuxt 4: separate tsconfigs per context)
  typescript: {
    strict: true,
    // Customize app tsconfig
    tsConfig: {},
    // Customize shared tsconfig
    sharedTsConfig: {}
  },
  
  // Nuxt 4: Revert to v3 folder structure if needed
  // srcDir: '.',
  // dir: { app: 'app' }
})

Composables

// app/composables/useCounter.ts - Auto-imported
export const useCounter = () => {
  const count = useState('counter', () => 0)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = 0
  
  return { count, increment, decrement, reset }
}

// app/composables/useApi.ts
export const useApi = () => {
  const config = useRuntimeConfig()
  
  const get = async (endpoint: string) => {
    return await $fetch(`${config.public.apiBase}${endpoint}`)
  }
  
  const post = async (endpoint: string, body: any) => {
    return await $fetch(`${config.public.apiBase}${endpoint}`, {
      method: 'POST',
      body
    })
  }
  
  return { get, post }
}

// Nuxt 4: Extract shared useAsyncData with same key to composable
// app/composables/useUserData.ts
export const useUserData = (userId: string) => {
  return useAsyncData(
    `user-${userId}`,
    () => $fetch(`/api/users/${userId}`),
    { deep: true }
  )
}
<!-- Using composables -->
<script setup>
const { count, increment } = useCounter()
const { get, post } = useApi()

const users = await get('/users')
</script>

Shared Directory

// shared/types/user.ts - Available in both app and server
export interface User {
  id: string
  name: string
  email: string
}

// shared/utils/format.ts - Shared utilities
export function formatDate(date: Date): string {
  return date.toLocaleDateString()
}

// Use in app/
import type { User } from '~/shared/types/user'
import { formatDate } from '~/shared/utils/format'

// Use in server/
import type { User } from '~~/shared/types/user'
import { formatDate } from '~~/shared/utils/format'

Useful Utils

<script setup>
// Check rendering context
if (import.meta.client) {
  // Client-side only code
}
if (import.meta.server) {
  // Server-side only code
}

// Cookie handling
const token = useCookie('token')
token.value = 'abc123'

// With options
const session = useCookie('session', {
  maxAge: 60 * 60 * 24,
  secure: true,
  httpOnly: true
})

// Request headers (server-side)
const headers = useRequestHeaders(['cookie', 'authorization'])

// Request URL
const url = useRequestURL()
console.log(url.origin, url.pathname)

// App config
const appConfig = useAppConfig()

// Preload components
preloadComponents('HeavyComponent')

// Prefetch routes
prefetchComponents(['LazyModal'])

// Nuxt 4: Access payload (replaces window.__NUXT__)
const nuxtApp = useNuxtApp()
console.log(nuxtApp.payload)
</script>

Migration from Nuxt 3

// nuxt.config.ts - Key changes for Nuxt 4

export default defineNuxtConfig({
  // New app/ directory is default, to keep v3 structure:
  srcDir: '.',
  dir: { app: 'app' },
  
  // Experimental features now default (can disable if needed)
  experimental: {
    // Data fetching changes
    sharedPrerenderData: false,    // Disable shared prerender data
    granularCachedData: false,     // Disable granular cache control
    purgeCachedData: false,        // Disable auto cleanup
    
    // Component changes
    normalizeComponentNames: false, // Keep v3 component names
    
    // Data reactivity
    defaults: {
      useAsyncData: { deep: true }  // Restore deep reactivity
    }
  }
})