Nuxt 4 project structure
Nuxt 4 moved application code into an app/ directory by default (app/pages, app/components, app/composables), separating the Vue layer from server/, root config, and a new shared/ folder for code both sides import. This sharpens the client/server boundary and speeds up file watching.
app/ # Vue app: pages, components, composables, layouts
server/ # Nitro: api routes, middleware, plugins, utils
shared/ # code safe to import from both client and server
nuxt.config.tsMigrating from Nuxt 3? Opt into the new defaults incrementally, then drop the flag once you're fully on v4.
// nuxt.config.ts
export default defineNuxtConfig({
future: { compatibilityVersion: 4 },
})useFetch vs useAsyncData
useFetch is shorthand for useAsyncData + $fetch; use useAsyncData when you need a custom fetcher or want full control over the cache key.
// useFetch — concise, URL is reactive
const { data, pending, error, refresh } = await useFetch<User[]>('/api/users', {
query: { page: 1 }, // serialised as ?page=1
pick: ['id', 'name'], // only extract these keys from the response
})
// useAsyncData — explicit key prevents duplicate requests across components
const { data } = await useAsyncData('users', () =>
$fetch<User[]>('/api/users')
)How Nuxt data fetching actually runs
useFetch/useAsyncData run on the server during SSR, serialize the result into the page payload, and the client reuses it on hydration rather than refetching. The explicit key dedupes concurrent callers and powers that payload reuse — omit it and Nuxt derives one from the call site, which can collide or duplicate.
// Same key → one request, shared result, no second fetch on hydration
const { data } = await useAsyncData('user:42', () => $fetch('/api/users/42'))Two senior rules: always await these in <script setup> (they block render by design), and never call them inside event handlers or onMounted — reach for plain $fetch there. Use transform/pick to shrink what lands in the payload, since everything you return is serialized to the client.
$fetch for non-reactive requests
Use $fetch directly for mutations (POST/PUT/DELETE) or one-shot requests that don't need caching or SSR.
async function createPost(title: string) {
const post = await $fetch('/api/posts', {
method: 'POST',
body: { title },
})
return post
}Server-side $fetch skips the network
When $fetch calls one of your own /api/* routes during server render, Nitro invokes the handler function directly — no HTTP round-trip, no extra socket. Internal API calls in SSR are nearly free, which is also why useRequestFetch exists: to forward the original request's headers into that direct call.
// During SSR this does NOT open a real connection to localhost
const stats = await $fetch('/api/stats') // direct Nitro handler invocationServer routes
Files in server/api/ are automatically exposed as API endpoints — the filename encodes the HTTP method.
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const query = getQuery(event) // ?foo=bar → { foo: 'bar' }
const body = await readBody(event) // parsed JSON body
return { id } // auto-serialised as JSON
})Server middleware and Nitro plugins
Files in server/middleware/ run on every server request with no routing — the place for request logging, header parsing, or attaching event.context.user. They must not return a value (returning ends the request).
// server/middleware/context.ts
export default defineEventHandler((event) => {
event.context.user = parseUser(getHeader(event, 'authorization'))
})A Nitro plugin (server/plugins/) runs once at server startup — use it to open a database connection or hook Nitro lifecycle events like render:html or close.
// server/plugins/db.ts
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('close', () => db.disconnect())
})Route middleware
Middleware runs before a page renders — use it for auth guards, redirects, or setting page metadata.
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const user = useCookie('auth-token')
if (!user.value) {
return navigateTo('/login')
}
})<script setup lang="ts">
// pages/dashboard.vue
definePageMeta({ middleware: 'auth' })
</script>definePageMeta
definePageMeta sets per-page configuration that Nuxt reads at build time — layout, middleware, and custom meta.
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
middleware: ['auth'],
title: 'Settings', // accessible via route.meta.title
keepalive: true,
})
</script>Route validation
definePageMeta({ validate }) rejects invalid route params before the page renders — returning false (or a createError) triggers a 404, keeping malformed URLs out of your data layer entirely.
<script setup lang="ts">
definePageMeta({
validate: (route) => /^\d+$/.test(route.params.id as string),
})
</script>useState
useState creates SSR-safe shared state — the value is serialised with the page payload so the client gets the exact same value, avoiding hydration mismatches.
// composables/useTheme.ts
export const useTheme = () =>
useState('theme', () => 'light')
// In any component
const theme = useTheme()
theme.value = 'dark' // reactive and shared across all components on this pagecallOnce — run logic exactly once
callOnce (Nuxt 3.9+) runs a block a single time during SSR and skips it on client hydration — for one-time setup like seeding a store or firing a server-side event that must not run twice.
const store = useStore()
await callOnce('init-store', async () => {
store.items = await $fetch('/api/items') // runs once, on the server
})Client- and server-only code
Reading window, localStorage, or document during SSR crashes the render. Guard browser-only logic with import.meta.client (and server-only work with import.meta.server), or defer it to onMounted, which never runs on the server.
if (import.meta.client) {
localStorage.setItem('seen', '1') // browser-only, skipped during SSR
}For markup that can only render in the browser — a map, a chart that measures element size — wrap it in <ClientOnly> with a fallback to avoid hydration mismatches.
<template>
<ClientOnly>
<MapView />
<template #fallback><MapSkeleton /></template>
</ClientOnly>
</template>useCookie
useCookie is an SSR-safe reactive ref backed by a browser cookie — reads and writes work on both server and client without document.cookie.
const token = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 7, // 7 days in seconds
secure: true,
sameSite: 'lax',
httpOnly: false, // must be false to be readable in JS
})
token.value = 'abc123' // sets the cookie
token.value = null // clears the cookiePlugins
Plugins run once on app startup — use them to register global helpers or configure libraries.
// plugins/api.ts
export default defineNuxtPlugin(() => {
const api = $fetch.create({
baseURL: useRuntimeConfig().public.apiBase,
onResponseError({ response }) {
if (response.status === 401) navigateTo('/login')
},
})
return { provide: { api } } // use as: const { $api } = useNuxtApp()
})Error handling
Use createError to throw typed HTTP errors; NuxtErrorBoundary catches non-fatal errors in a subtree without crashing the whole page.
// In a server route:
throw createError({ statusCode: 404, message: 'User not found' })
// In a page — fatal shows the full error screen:
throw createError({ statusCode: 403, fatal: true })<template>
<NuxtErrorBoundary @error="logError">
<RiskyComponent />
<template #error="{ error }">{{ error.message }}</template>
</NuxtErrorBoundary>
</template>Layouts
Create named layout files in layouts/ and opt in per page with definePageMeta.
<!-- layouts/dashboard.vue -->
<template>
<div class="dashboard">
<Sidebar />
<main><slot /></main>
</div>
</template><!-- pages/settings.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>Hybrid rendering with routeRules
routeRules sets a rendering strategy per route — the key scalability lever, mixing static, ISR, SSR, and SPA in one app.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // static at build time (SSG)
'/blog/**': { isr: 3600 }, // incremental static regen, 1h
'/admin/**': { ssr: false }, // client-only SPA
'/api/data': { cache: { maxAge: 60 } }, // cache the response 60s
},
})Lazy data fetching
useLazyFetch (or lazy: true) returns immediately without blocking navigation — render a skeleton while data streams in.
<script setup lang="ts">
const { data, pending } = useLazyFetch('/api/products')
</script>
<template>
<Skeleton v-if="pending" />
<ProductGrid v-else :items="data" />
</template>Caching fetched data
getCachedData reuses the existing payload across navigations instead of refetching data the client already has.
const { data } = await useAsyncData('products', () => $fetch('/api/products'), {
getCachedData: (key, nuxtApp) =>
nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
})Server route caching with Nitro
cachedEventHandler caches an endpoint's response in the Nitro layer, offloading repeated expensive work like database aggregation.
// server/api/stats.get.ts
export default cachedEventHandler(
async () => await computeExpensiveStats(),
{ maxAge: 60 * 5 } // cache for 5 minutes
)Nitro cache storage and SWR
cachedEventHandler and defineCachedFunction store results in a Nitro storage layer — in-memory by default, but point it at Redis or a KV driver so the cache survives restarts and is shared across instances.
// nuxt.config.ts — back the cache with Redis in production
export default defineNuxtConfig({
nitro: {
storage: { cache: { driver: 'redis', url: process.env.REDIS_URL } },
},
})In routeRules, swr: 3600 serves a cached response instantly while revalidating in the background (stale-while-revalidate) — distinct from isr, which persists the rendered page to the CDN/edge.
Pinia with SSR hydration
State set on the server is serialised into the payload and rehydrated on the client automatically — no manual transfer, no double fetch.
// In a component or plugin during SSR
const store = useAuthStore()
await store.fetchUser() // runs on the server; state ships in the payload
// The client reuses the same state — no second fetch, no hydration mismatchForwarding headers in SSR fetch
On the server, useRequestFetch forwards the incoming request's cookies and headers so authenticated calls work during render.
const requestFetch = useRequestFetch()
const { data } = await useAsyncData('me', () => requestFetch('/api/me'))
// Forwards the user's auth cookie to the internal API during server renderSecurity — separate public and private runtime config
Anything in runtimeConfig.public is sent to the client — never put secrets there.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only — never exposed to the browser
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
public: {
// Exposed to the client — safe for public API base URLs etc.
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:3000',
},
},
})Security — validate server route input
readBody and getQuery return untyped data — always validate before using in queries or business logic.
// server/api/posts.post.ts
import { z } from 'zod'
const BodySchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
})
export default defineEventHandler(async (event) => {
const raw = await readBody(event)
const result = BodySchema.safeParse(raw)
if (!result.success) {
throw createError({ statusCode: 400, message: result.error.message })
}
const { title, content } = result.data // typed and validated
return await db.posts.create({ title, content })
})Security — CORS and CSRF on server routes
Nuxt server routes are accessible to any origin by default — configure CORS explicitly and use CSRF tokens for state-changing requests. For preflight handling, H3 ships handleCors / appendCorsHeaders as a higher-level alternative to the manual check below.
// server/api/data.post.ts
export default defineEventHandler(async (event) => {
// Restrict which origins may call this endpoint
const origin = getHeader(event, 'origin')
const allowed = ['https://yourapp.com']
if (!origin || !allowed.includes(origin)) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
// For cookie-based auth, verify a CSRF token from the request header
const csrf = getHeader(event, 'x-csrf-token')
if (csrf !== getCookie(event, 'csrf-token')) {
throw createError({ statusCode: 403, message: 'Invalid CSRF token' })
}
})Security — httpOnly auth cookies
Store auth tokens in httpOnly cookies so JavaScript cannot read them — this prevents token theft via XSS.
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const token = await signJwt({ email }) // your JWT logic
setCookie(event, 'auth-token', token, {
httpOnly: true, // not readable by JS — XSS-safe
secure: true, // HTTPS only
sameSite: 'lax', // CSRF mitigation
maxAge: 60 * 60 * 24 * 7,
})
return { ok: true }
})Security — set security headers via routeRules
Apply security headers globally with routeRules (or the nuxt-security module) to mitigate clickjacking, MIME sniffing, and XSS.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/**': {
headers: {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'self'",
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
},
},
})Security — enforce auth on the server, not just middleware
Route middleware only guards the UI; anyone can call /api/* directly, so re-check auth inside every protected server route.
// server/utils/requireUser.ts
export async function requireUser(event: H3Event) {
const token = getCookie(event, 'auth-token')
const user = token ? await verifyJwt(token) : null
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
return user
}// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
const user = await requireUser(event) // enforced server-side
if (user.role !== 'admin') throw createError({ statusCode: 403 })
return await db.users.findAll()
})Security — don't leak state across requests in SSR
Module-level variables are shared by every request on the server; never store per-user data there — use the event context, cookies, or useState.
// DANGER: shared by every user hitting the server
let currentUser = null
// SAFE: per-request state, isolated between users
export default defineEventHandler((event) => {
event.context.user = getUserFromRequest(event)
})