# 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
<!-- 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>
<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>
<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 }
}
<!-- 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>
<!-- 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>
// 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/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' }
})
// 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>
<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' }
]
}
}
})
// 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' }
})
<!-- 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.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' }
})
// 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/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'
<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>
// 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
}
}
})