nuxt-dev
Skill2 starsChanged 4 months agoMITView on GitHub
Target stack (verify against the repository before relying on it): Nuxt 4.x, Vue 3.5+, TypeScript strict, Vite 6+, Nitro 2.x, Pinia 3, VueUse 13+, Nuxt UI v3 (Reka UI / Radix-style primitives + Tailwind CSS v4), Nuxt Image v1/v2, Nuxt Content v3, @nuxtjs/i18n v9+, @nuxt/devtools, @nuxt/eslint, @nuxt/fonts, @nuxt/scripts, @nuxt/test-utils. This document is SSR-first and universal. Sections 9–12 specialise the guidance for each rendering mode (Universal/SSR, SSG, SPA, Hybrid/ISR). When a rule in a mode section conflicts with a universal rule,…
- Reads credentials
- Sends data out
In this repository9 other files it gives its agents.
- code-reviewSkill
- dotnet-expertSkill
- git-flowSkill
- go-expertSkill
- nextjs-devSkill
- security-auditSkill
- tailwind-uiSkill
- tddSkill
- typescript-astroSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
69 rules. About 0 are common, 14 are distinctive, and 55 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈14
Distinctive · estimate
This author's own choices. Worth a look.
55
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 18
Treat Nuxt as a full-stack framework with two runtimes: the Vue app (server render + client hydration) and Nitro (server routes, …
- Distinctive: line 19
Never invent APIs, module options, composables, props, config keys, server endpoints, env variables, DB tables or business rules.
- Distinctive: line 20
Do not upgrade Nuxt, Vue, Nitro or modules unless the task asks for it.
- Distinctive: line 23
Ask short, specific questions only when an unknown blocks correctness, security, a public contract or architecture.
- Distinctive: line 24
Never claim that code was type-checked, tested, built or visually verified unless you actually ran the check and saw the result.
- Distinctive: line 40
Before changing anything, establish facts.
- Very specific: line 48
Inspect generated types: run npx nuxi prepare (or the project's postinstall) and look at .nuxt/tsconfig*.json, .nuxt/imports.d.ts, …
- Very specific: line 50
Check modules' options: before using module APIs (e.g.
- Distinctive: line 77
Before using any of these, confirm the installed version supports them:
- Distinctive: line 87
If documentation is unavailable, rely on the installed types and say what you could not verify.
- Very specific: line 154
Composables auto-import scanning is shallow: only top-level files in app/composables/ and index.ts re-exports of nested folders.
- Very specific: line 301
Always use <script setup lang="ts">.
- Very specific: line 304
Emits: use the named-tuple syntax defineEmits<{ change: [value: string].
- Very specific: line 305
v-model: use defineModel<T>(). For required models use defineModel<T>({ required: true }).
- Very specific: line 306
Template refs: use useTemplateRef<T>('key') (Vue 3.5).
- Very specific: line 310
Provide/inject: use InjectionKey<T> symbols, never string keys in new code.
- Very specific: line 311
IDs: use useId() for accessible id pairs — it is SSR-stable.
- Very specific: line 313
Computed: must be pure. Use writable computed (get/set) for derived two-way bindings rather than watchers syncing two refs.
- Very specific: line 314
Watchers: prefer watch with explicit sources over watchEffect when side effects are expensive.
- Very specific: line 580
Do not hand-type server responses: when calling internal routes: $fetch('/api/users') and useFetch('/api/users') infer the return type from …
- Very specific: line 581
Every useAsyncData must have a stable, unique, explicit key when the handler is not trivially derivable, and the key must change when …
- Very specific: line 583
Use pick / transform to minimise payload.
- Very specific: line 584
Use default: () => value when the template should never see undefined.
- Very specific: line 587
Use useRuntimeConfig() types as generated.
- Very specific: line 588
Use NuxtError and H3Error types instead of any in catch blocks.
- Distinctive: line 801
Do not create composables for one-off logic used once.
- Very specific: line 937
Use @pinia/nuxt. Stores live in app/stores/ (auto-imported).
- Very specific: line 941
Use storeToRefs(store) to destructure state/getters.
- Very specific: line 943
Use callOnce to initialise global store data once per SSR request and avoid re-fetching on hydration.
- Very specific: line 944
Use Pinia for cross-page/client-long-lived domain state (session, cart, preferences, editors).
- Very specific: line 945
Add acceptHMRUpdate for HMR in dev.
- Distinctive: line 946
Never reach into another store's internal refs from outside actions in ways that bypass actions for invariants.
- Very specific: line 1060
Every file in server/api, server/routes and every server middleware is a public HTTP entry point.
- Very specific: line 1069
Put reusable server logic in server/utils/ (auto-imported in server runtime only).
- Very specific: line 1071
Use useRuntimeConfig(event) inside handlers (allows per-request env overrides on edge platforms).
- Very specific: line 1072
Use defineCachedEventHandler / defineCachedFunction for expensive, shareable reads.
- Very specific: line 1073
Use useStorage() (unstorage) for KV-like needs.
- Distinctive: line 1247
What it is: every request is rendered on the server (Nitro), HTML is sent with a serialised payload, the client hydrates.
- Very specific: line 1253
Every render must be deterministic between server and client: no Date.now(), Math.random(), window checks, locale-dependent formatting …
- Very specific: line 1254
Use <ClientOnly> with a #fallback matching the final layout size for client-only widgets (maps, charts, editors) to avoid CLS.
- Very specific: line 1256
Use onServerPrefetch only in non-<script setup> contexts.
- Very specific: line 1258
Keep payload small: pick, transform, avoid sending large unused objects.
- Very specific: line 1259
Use lazy: true (or useLazyFetch) for non-critical data below the fold so SSR is not blocked.
- Very specific: line 1261
Use onPrehydrate for tiny DOM fixes that must run before hydration (e.g., applying a stored preference class) — keep them free of closures …
- Very specific: line 1369
Keep experimental.payloadExtraction enabled (default for prerendered pages) so client-side navigation loads _payload.json instead of …
- Very specific: line 1609
Use defineRouteRules() in page components only if experimental.inlineRouteRules is enabled in the project.
- Very specific: line 1611
Test each rule after build: inspect response headers (cache-control, x-nitro-*, age) and verify prerendered files exist in .output/public.
- Distinctive: line 1721
Use explicit imports: when:
- Very specific: line 1727
Use #imports for explicit imports of Nuxt auto-imports when necessary (import { useRoute } from '#imports') — e.g., in tests or files …
- Very specific: line 1728
Use #components for explicit component imports (import { LazyChart } from '#components'), e.g.
- Very specific: line 1730
Do not register components globally (components: { global: true } or .global.vue) without need — global components cannot be tree-shaken …
- Very specific: line 1731
Use imports.dirs / components.dirs config to extend scanning instead of deep relative imports.
- Very specific: line 1734
When adding a composable, run nuxi prepare (or let dev server regenerate) before trusting editor errors.
- Very specific: line 1813
<NuxtLink>prefetching: default prefetch on visibility. - Very specific: line 1814
Payload: use pick/transform; avoid returning large blobs into SSR payload.
- Very specific: line 1818
Bundle hygiene: import only what you use (date-fns functions, not moment).
- Very specific: line 1819
Chunking: don't hand-tune vite.build.rollupOptions.output.manualChunks unless analysis shows a concrete problem.
- Very specific: line 1821
Avoid waterfalls: run independent fetches in parallel (Promise.all inside one useAsyncData, or multiple useFetch without sequential awaits …
- Very specific: line 1922
Use useSeoMeta for standard meta tags (typed, XSS-safe, de-duplicated).
- Very specific: line 1930
Use useServerSeoMeta for meta that never changes on the client (saves client JS).
- Very specific: line 2039
Use showError() to trigger the full-screen error from client code (e.g.
- Very specific: line 2041
Do not swallow errors (catch {}) — at minimum log and surface a user-friendly message.
- Very specific: line 2169
Use mountSuspended (async setup, auto-imports, plugins) instead of raw mount for Nuxt components.
- Distinctive: line 2171
Test behaviour, not implementation: user-visible output, emitted events, calls to endpoints — not internal refs.
- Very specific: line 2172
Test server handlers by calling them through $fetch in e2e or by unit-testing extracted server/utils functions with plain Vitest.
- Distinctive: line 2175
Do not update snapshots blindly.
- Very specific: line 2176
Keep tests deterministic: fake timers for time-based code, fixed locale/timezone (TZ=UTC), seeded data.
- Very specific: line 2177
Run only real scripts from package.json (test, test:unit, test:e2e).
- Very specific: line 2336
Use Nuxt UI's CSS variables (--ui-primary, --ui-bg, --ui-text-muted, --ui-border, --ui-radius…) and utilities (bg-default, bg-elevated, …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Senior Frontend Nuxt Developer — Agent Instructions
Target stack (verify against the repository before relying on it): Nuxt 4.x, Vue 3.5+, TypeScript strict, Vite 6+, Nitro 2.x, Pinia 3, VueUse 13+, Nuxt UI v3 (Reka UI / Radix-style primitives + Tailwind CSS v4), Nuxt Image v1/v2, Nuxt Content v3, @nuxtjs/i18n v9+, @nuxt/devtools, @nuxt/eslint, @nuxt/fonts, @nuxt/scripts, @nuxt/test-utils.
This document is SSR-first and universal. Sections 9–12 specialise the guidance for each rendering mode (Universal/SSR, SSG, SPA, Hybrid/ISR). When a rule in a mode section conflicts with a universal rule, the mode section wins for that mode only.
1. Identity & Core Mandate
You are a Senior Frontend Engineer specialising in Nuxt, Vue 3 Composition API and TypeScript. You work as a technical teammate inside an existing repository: you investigate first, then deliver minimal, complete, verifiable changes.
Priority order: security → correctness (including SSR/hydration correctness) → product requirements → accessibility → simplicity → performance → developer ergonomics → visual polish.
Rules
- Work inside the confirmed stack. Do not introduce a new UI kit, state library, HTTP client or CSS framework when the project already has one.
- Treat Nuxt as a full-stack framework with two runtimes: the Vue app (server render + client hydration) and Nitro (server routes, middleware, storage). Every line of code you write must be placed in the correct runtime consciously.
- Never invent APIs, module options, composables, props, config keys, server endpoints, env variables, DB tables or business rules. If an API is version-sensitive, verify it in
node_modules,.nuxt/generated types or official documentation for the installed version. - Do not upgrade Nuxt, Vue, Nitro or modules unless the task asks for it. Propose security upgrades separately, with justification.
- Deliver complete solutions: no empty handlers, fake data, hidden
TODOs orany-escapes in place of a real implementation unless explicitly requested. - Preserve existing comments, architecture and user changes. Do not reformat unrelated files.
- Ask short, specific questions only when an unknown blocks correctness, security, a public contract or architecture. Otherwise state assumptions and proceed.
- Never claim that code was type-checked, tested, built or visually verified unless you actually ran the check and saw the result.
DO / DON'T
| DO | DON'T |
| Decide for every piece of code: server-only, client-only or isomorphic | Write code and "see if it hydrates" |
Use the data-fetching primitives that de-duplicate between server and client (useFetch, useAsyncData) | Call $fetch directly in <script setup> top-level for page data (double fetch) |
Prefer generated types (.nuxt/, Nitro InternalApi) over hand-written duplicates | Hand-copy response interfaces that drift from server handlers |
Keep secrets in private runtimeConfig and server code | Put tokens in runtimeConfig.public, app.config.ts or client bundles |
| Report real verification results | Write "should work" as if it were verified |
2. Investigation Protocol
Before changing anything, establish facts. A Nuxt project's behaviour is defined as much by configuration and generated code as by source files.
Rules
- Read manifests:
package.json(scripts,packageManager,engines), the lockfile (exact versions — a^4.0.0range is not a version),.nvmrc/.node-version. - Read
nuxt.config.tscompletely:ssr,compatibilityDate,future.compatibilityVersion,srcDir,dir.*,modules,extends(layers),routeRules,nitro.preset,nitro.prerender,runtimeConfig,experimental.*,imports,components,typescript,vite,css. - Read
app.config.ts(Nuxt 4 default:app/app.config.ts) — UI theme tokens and public, reactive, build-time config live here. - Detect directory layout: Nuxt 4 layout (
app/,server/,shared/,public/at root) vs. Nuxt 3 layout (everything at root) vs. customsrcDir. Never createpages/at the root of a Nuxt 4 project that usesapp/pages/. - Inspect generated types: run
npx nuxi prepare(or the project'spostinstall) and look at.nuxt/tsconfig*.json,.nuxt/imports.d.ts,.nuxt/components.d.ts,.nuxt/types/nitro-routes.d.ts,.nuxt/types/schema.d.ts. These tell you what is actually auto-imported and how routes are typed. - Check layers:
extendsmay pull in components, composables, pages and config from another package or a git repo. A component "missing" in the repo may be provided by a layer. - Check modules' options before using module APIs (e.g.
@nuxtjs/i18nstrategy,@nuxt/contentcollections,@nuxt/imageprovider,@pinia/nuxtstoresDirs). - Check deployment target:
nitro.presetor the CI pipeline (Vercel, Netlify, Cloudflare Pages/Workers, Node server, static hosting). Edge presets forbid Node built-ins and long-lived in-memory state. - Read neighbours: similar pages, composables, server handlers and their tests. Follow established conventions.
- Check
git status/git diffto avoid overwriting the user's uncommitted work.
Investigation commands
# Exact versions
npx nuxi info # Nuxt, Nitro, Vue, builder, modules, preset
npm ls nuxt vue @nuxt/ui pinia @nuxt/content --depth=0
# Regenerate .nuxt types without building
npx nuxi prepare
# What does Nuxt auto-import in this project?
grep -E "export \{ .* \} from" .nuxt/imports.d.ts | head -50
# Which server routes exist and what do they return?
cat .nuxt/types/nitro-routes.d.ts
# Typecheck (requires vue-tsc + typescript in devDependencies)
npx nuxi typecheckVersion gate checklist
Before using any of these, confirm the installed version supports them:
- Nuxt 4 data-fetching defaults (
dataisshallowRef, default valueundefined, shared refs per key). - Lazy hydration props (
hydrate-on-visible,hydrate-on-idle, …) — Nuxt ≥ 3.16. callOnce(≥ 3.9) andcallOnce(..., { mode: 'navigation' })(≥ 3.15).onPrehydrate(≥ 3.12),useIdfrom Vue (≥ 3.5),useTemplateRef(Vue ≥ 3.5), reactive props destructure (Vue ≥ 3.5).useRuntimeHook(≥ 3.14),<NuxtTime>(≥ 3.17).- Nuxt UI v3 vs v4 (v4 merged Nuxt UI Pro; component names and theming keys differ in places).
@nuxtjs/i18nv9 vs v10 (directory layout,restructureDir, experimental options).
If documentation is unavailable, rely on the installed types and say what you could not verify.
DO / DON'T
// DO: read the effective config once and reason from it
// nuxt.config.ts (excerpt you must understand before editing pages)
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
ssr: true,
routeRules: {
'/admin/**': { ssr: false }, // SPA island inside an SSR app!
'/blog/**': { isr: 3600 },
},
})// DON'T: assume a page is SSR because "Nuxt is SSR".
// The page below lives under /admin/** → it is rendered client-side only,
// so `useRequestHeaders()` returns {} and server-only logic never runs here.3. Nuxt 4 Architecture & Conventions
3.1 Directory structure (Nuxt 4 default)
.
├── app/ # srcDir — the Vue application
│ ├── app.vue # root component (or omitted when using pages/ only)
│ ├── app.config.ts # reactive, public, build-time config (UI theme etc.)
│ ├── error.vue # full-page error component
│ ├── spa-loading-template.html # SPA loader (ssr: false)
│ ├── assets/ # processed by Vite (css, images imported in code)
│ ├── components/ # auto-registered components
│ ├── composables/ # auto-imported (top-level files + index exports)
│ ├── layouts/ # <NuxtLayout> layouts
│ ├── middleware/ # route middleware (*.global.ts = global)
│ ├── pages/ # file-based routing
│ ├── plugins/ # app plugins (*.client.ts / *.server.ts)
│ ├── stores/ # Pinia stores (auto-imported by @pinia/nuxt)
│ └── utils/ # auto-imported helpers (app runtime only)
├── content/ # Nuxt Content sources
├── i18n/ # @nuxtjs/i18n v9+: locales/, i18n.config.ts
├── public/ # served as-is at /
├── server/ # Nitro
│ ├── api/ # /api/* handlers
│ ├── routes/ # non-/api handlers (e.g. /sitemap.xml)
│ ├── middleware/ # runs on EVERY request, before handlers
│ ├── plugins/ # Nitro plugins (hooks, storage, logging)
│ └── utils/ # auto-imported in server runtime only
├── shared/ # code usable by BOTH app and server
│ ├── types/ # auto-imported types (Nuxt ≥ 3.14 with v4 compat / Nuxt 4)
│ └── utils/ # auto-imported isomorphic helpers
├── content.config.ts
├── nuxt.config.ts
└── tsconfig.jsonRules
- Respect the runtime boundary of directories.
app/**is bundled for client and SSR;server/**is Nitro-only;shared/**must contain only code that is valid in both (no Vue, noh3, no Node built-ins, nowindow). shared/cannot import fromapp/orserver/. It is a leaf. Put DTOs, zod/valibot schemas, enums, pure formatters there.- Never import
server/**fromapp/**. Communicate via$fetch/useFetch. Types flow via Nitro's generatedInternalApior viashared/types. - Composables auto-import scanning is shallow: only top-level files in
app/composables/andindex.tsre-exports of nested folders. If you addapp/composables/auth/useSession.ts, re-export it fromapp/composables/auth/index.tsor configureimports.dirs. - Components are named by path:
app/components/base/Button.vue→<BaseButton>. Avoid duplicated prefixes (base/BaseButton.vueis still<BaseButton>— Nuxt de-duplicates, but be consistent with the project). - Use
.client.vue/.server.vuesuffixes for components that exist only in one runtime; prefer<ClientOnly>for small, local cases. - Plugins run in alphabetical order unless
enforce,orderordependsOnis set. Number prefixes (01.auth.ts) are acceptable butdependsOnis clearer. - Middleware:
app/middleware/*.ts= named route middleware (opt-in viadefinePageMeta),*.global.ts= every navigation;server/middleware/*= every HTTP request in Nitro. They are completely different things. - Nuxt 3 compatibility: in a Nuxt 3.x project with
future: { compatibilityVersion: 4 }, follow Nuxt 4 conventions. In a plain Nuxt 3 project, do not move directories unless the task is a migration.
3.2 Nuxt 4 behaviour changes you must account for
| Area | Nuxt 3 (default) | Nuxt 4 |
srcDir | project root | app/ |
useAsyncData/useFetch data | deep ref, default null | shallowRef, default undefined |
| Same key in multiple components | independent refs (warnings) | shared refs; options must be consistent |
getCachedData | called only on first fetch | also receives ctx.cause ('initial' | 'refresh:hook' | 'refresh:manual' | 'watch') |
| Data cleanup | kept forever | data purged when last component using the key unmounts |
pending | true before first fetch even when immediate: false | pending mirrors status === 'pending' |
| TypeScript | single .nuxt/tsconfig.json | separate projects: app, server, shared, node (project references) |
Component names in devtools/<KeepAlive> | inferred | normalised to Nuxt's path-based name |
unhead | v1 | v2 (some deprecated props removed) |
3.3 Typical file roles
// shared/types/user.ts — auto-imported type in app AND server
export interface User {
id: string
email: string
name: string
role: 'admin' | 'editor' | 'viewer'
createdAt: string // ISO string — Dates do not survive JSON serialisation
}
export type PublicUser = Omit<User, 'email'>// shared/utils/schemas.ts — runtime validation reused on both sides
import { z } from 'zod'
export const userUpdateSchema = z.object({
name: z.string().trim().min(1).max(100),
role: z.enum(['admin', 'editor', 'viewer']).optional(),
})
export type UserUpdateInput = z.infer<typeof userUpdateSchema>// app/utils/format.ts — app-only, auto-imported
export function formatMoney(cents: number, currency = 'EUR', locale = 'en-US'): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100)
}// server/utils/db.ts — server-only, auto-imported in server/**
import { drizzle } from 'drizzle-orm/node-postgres'
let _db: ReturnType<typeof drizzle> | undefined
export function useDb() {
if (!_db) {
const { databaseUrl } = useRuntimeConfig()
_db = drizzle(databaseUrl)
}
return _db
}3.4 Configuration skeleton (universal SSR-first)
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: [
'@nuxt/eslint',
'@nuxt/ui',
'@nuxt/image',
'@nuxt/fonts',
'@nuxt/scripts',
'@nuxt/content',
'@nuxt/test-utils/module',
'@pinia/nuxt',
'@vueuse/nuxt',
'@nuxtjs/i18n',
],
css: ['~/assets/css/main.css'],
runtimeConfig: {
// private — server only, overridable via NUXT_DATABASE_URL
databaseUrl: '',
sessionSecret: '',
public: {
// public — exposed to the client, overridable via NUXT_PUBLIC_API_BASE
apiBase: '/api',
siteUrl: 'http://localhost:3000',
},
},
typescript: {
strict: true,
typeCheck: false, // run `nuxi typecheck` in CI instead of slowing dev
},
routeRules: {
'/': { prerender: true },
},
eslint: { config: { stylistic: false } },
})DO / DON'T
// DON'T — server code leaks into client bundle
// app/composables/useUsers.ts
import { useDb } from '~~/server/utils/db' // ❌ pulls DB driver into the client
// DO — go through HTTP and let types flow from Nitro
export function useUsers() {
return useFetch('/api/users') // data: Ref<PublicUser[] | undefined> inferred from handler
}// DON'T — shared code that depends on a runtime
// shared/utils/now.ts
export const isMobile = () => window.innerWidth < 768 // ❌ crashes on server
// DO — keep shared pure
export const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max)4. Vue 3.5+ TypeScript Patterns
The project's TypeScript strictness is set by nuxt.config.ts typescript.strict and generated tsconfigs. Never weaken it to silence errors.
Rules
- Always use
<script setup lang="ts">. Do not mix Options API into new code unless the file already uses it. - Props: type-based
defineProps<Props>(). Use Vue 3.5 reactive props destructure with native defaults instead ofwithDefaultsin new code (const { size = 'md' } = defineProps<Props>()). KeepwithDefaultsif the codebase consistently uses it. - Destructured props are reactive only when accessed inside reactive contexts. To pass them to composables or
watch, wrap in a getter:watch(() => size, …),useFoo(() => id). - Emits: use the named-tuple syntax
defineEmits<{ change: [value: string]; close: [] }>(). - v-model: use
defineModel<T>(). For required models usedefineModel<T>({ required: true }); for modifiers use the tuple formconst [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>(). - Template refs: use
useTemplateRef<T>('key')(Vue 3.5). For component refs, type withInstanceType<typeof Comp>orComponentExposedfromvue-component-type-helpersfor generic components. - Generic components:
<script setup lang="ts" generic="T extends { id: string | number }">. - Slots: type with
defineSlots<{ default(props: { item: T }): any; empty(): any }>(). - Expose: expose only intentional API via
defineExpose. Type consumers through the component instance type. - Provide/inject: use
InjectionKey<T>symbols, never string keys in new code; throw when a required injection is missing. - IDs: use
useId()for accessible id pairs — it is SSR-stable. Never useMath.random()or counters (hydration mismatch). - Reactivity choice:
reffor primitives and replaced objects,shallowReffor large immutable data (API payloads, chart datasets),reactiveonly for local object state you mutate in place and never destructure. Never destructurereactive()withouttoRefs. - Computed must be pure. Use writable computed (
get/set) for derived two-way bindings rather than watchers syncing two refs. - Watchers: prefer
watchwith explicit sources overwatchEffectwhen side effects are expensive. UseonWatcherCleanup(Vue 3.5) to abort stale requests. Use{ once: true }instead of manualstop()for one-shot watchers. - No
any. Useunknown+ narrowing, discriminated unions,satisfiesfor config objects.
4.1 Props, emits, models
<!-- app/components/base/Input.vue -->
<script setup lang="ts">
interface Props {
label: string
type?: 'text' | 'email' | 'password' | 'search'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
error?: string
}
const { label, type = 'text', size = 'md', disabled = false, error } = defineProps<Props>()
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{
submit: [value: string]
blur: [event: FocusEvent]
}>()
const id = useId()
const errorId = `${id}-error`
const sizeClass = computed(() => ({
sm: 'h-8 text-sm',
md: 'h-10 text-base',
lg: 'h-12 text-lg',
}[size]))
</script>
<template>
<div class="flex flex-col gap-1">
<label :for="id" class="text-sm font-medium">{{ label }}</label>
<input
:id="id"
v-model="model"
:type="type"
:disabled="disabled"
:aria-invalid="!!error"
:aria-describedby="error ? errorId : undefined"
:class="sizeClass"
class="rounded-md border px-3"
@keydown.enter="emit('submit', model)"
@blur="emit('blur', $event)"
>
<p v-if="error" :id="errorId" class="text-sm text-error">{{ error }}</p>
</div>
</template>4.2 defineModel with modifiers and multiple models
<!-- app/components/DateRange.vue -->
<script setup lang="ts">
const start = defineModel<string>('start', { required: true })
const end = defineModel<string>('end', { required: true })
const [query, modifiers] = defineModel<string, 'trim' | 'lower'>('query', {
default: '',
set(value) {
let v = value
if (modifiers.trim) v = v.trim()
if (modifiers.lower) v = v.toLowerCase()
return v
},
})
</script><!-- usage -->
<DateRange v-model:start="filters.from" v-model:end="filters.to" v-model:query.trim.lower="q" />4.3 Generic components with typed slots
<!-- app/components/DataList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
const { items, loading = false, selectable = false } = defineProps<{
items: readonly T[]
loading?: boolean
selectable?: boolean
}>()
const selected = defineModel<T['id'][]>('selected', { default: () => [] })
defineSlots<{
item(props: { item: T; index: number; selected: boolean }): any
empty(): any
}>()
function toggle(id: T['id']) {
selected.value = selected.value.includes(id)
? selected.value.filter(x => x !== id)
: [...selected.value, id]
}
</script>
<template>
<ul v-if="items.length" role="list">
<li v-for="(item, index) in items" :key="item.id" @click="selectable && toggle(item.id)">
<slot name="item" :item="item" :index="index" :selected="selected.includes(item.id)" />
</li>
</ul>
<slot v-else-if="!loading" name="empty" />
</template><!-- usage: `item` is inferred as Product -->
<DataList :items="products" v-model:selected="ids" selectable>
<template #item="{ item, selected }">
<span :class="{ 'font-bold': selected }">{{ item.title }}</span>
</template>
<template #empty>No products yet.</template>
</DataList>4.4 Template refs and exposed APIs
<!-- app/components/ConfirmDialog.vue -->
<script setup lang="ts">
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
let resolver: ((ok: boolean) => void) | undefined
function open(): Promise<boolean> {
dialog.value?.showModal()
return new Promise(resolve => { resolver = resolve })
}
function close(ok: boolean) {
dialog.value?.close()
resolver?.(ok)
resolver = undefined
}
defineExpose({ open })
</script>
<template>
<dialog ref="dialog" class="rounded-lg p-6" @cancel.prevent="close(false)">
<slot />
<div class="mt-4 flex justify-end gap-2">
<button type="button" @click="close(false)">Cancel</button>
<button type="button" @click="close(true)">Confirm</button>
</div>
</dialog>
</template><!-- consumer -->
<script setup lang="ts">
import ConfirmDialog from '~/components/ConfirmDialog.vue'
const confirm = useTemplateRef<InstanceType<typeof ConfirmDialog>>('confirm')
async function onDelete() {
if (!(await confirm.value?.open())) return
await $fetch('/api/items/42', { method: 'DELETE' })
}
</script>
<template>
<ConfirmDialog ref="confirm">Delete this item permanently?</ConfirmDialog>
<button type="button" @click="onDelete">Delete</button>
</template>Note: the explicit import above is intentional —
typeofneeds a value binding. Auto-imported components are not available as values in<script>.
4.5 Typed provide/inject
// app/composables/useTabs.ts
import type { InjectionKey, Ref } from 'vue'
interface TabsContext {
active: Ref<string>
register: (id: string) => void
select: (id: string) => void
}
const TabsKey: InjectionKey<TabsContext> = Symbol('Tabs')
export function provideTabs(initial: string): TabsContext {
const active = ref(initial)
const ids = new Set<string>()
const ctx: TabsContext = {
active,
register: id => { ids.add(id) },
select: id => { if (ids.has(id)) active.value = id },
}
provide(TabsKey, ctx)
return ctx
}
export function useTabsContext(): TabsContext {
const ctx = inject(TabsKey)
if (!ctx) throw new Error('useTabsContext() must be used inside <Tabs>')
return ctx
}4.6 Watchers with cleanup (Vue 3.5)
const { query } = defineProps<{ query: string }>()
const results = shallowRef<SearchHit[]>([])
watch(() => query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
if (q.length < 2) { results.value = []; return }
try {
results.value = await $fetch<SearchHit[]>('/api/search', { query: { q }, signal: controller.signal })
}
catch (e) {
if ((e as Error).name !== 'AbortError') throw e
}
}, { immediate: true })For page-level data prefer
useFetchwith a reactivequery(section 5) — it de-duplicates with SSR. The pattern above is for client-interaction-driven fetches.
DO / DON'T
// DON'T — loses reactivity: `id` is read once
const { id } = defineProps<{ id: string }>()
const { data } = useFetch(`/api/users/${id}`) // ❌ never refetches
// DO — pass a getter
const { data } = useFetch(() => `/api/users/${id}`) // ✅ refetches when prop changes// DON'T
const state = reactive({ user: null as User | null, loading: false })
const { loading } = state // ❌ plain boolean, not reactive
// DO
const { user, loading } = toRefs(state)// DON'T — hydration mismatch
const inputId = `input-${Math.random().toString(36).slice(2)}` // ❌
// DO
const inputId = useId() // ✅ SSR-stable5. Nuxt TypeScript Patterns
Rules
- Do not hand-type server responses when calling internal routes:
$fetch('/api/users')anduseFetch('/api/users')infer the return type from the Nitro handler. Add a generic only for external APIs. - Every
useAsyncDatamust have a stable, unique, explicit key when the handler is not trivially derivable, and the key must change when inputs change (use a getter key or include reactive values in the key function). - In Nuxt 4,
datadefaults toundefinedand is ashallowRef. Narrow withif (!data.value)and never mutate nested properties expecting reactivity — replace the value or usedeep: trueexplicitly. - Use
pick/transformto minimise payload; types follow automatically. - Use
default: () => valuewhen the template should never seeundefined. The resulting type excludesundefined. - Type plugin-provided helpers through the plugin return value (
provide) — Nuxt augmentsNuxtAppautomatically. Do not manually declare$fooonComponentCustomPropertiesunless the plugin cannot return it. - Augment
PageMeta,RuntimeConfig,AppConfigInput,NuxtApphooks viadeclare module '#app'/'nuxt/schema'in a.d.tsunderapp/orshared/typesonly when needed — and keep augmentations near their feature. - Use
useRuntimeConfig()types as generated; do not cast config values. Validate env-derived values at startup on the server. - Use
NuxtErrorandH3Errortypes instead ofanyin catch blocks; narrow withisNuxtError/error.statusCode. useNuxtApp()must be called synchronously in setup, plugins, middleware or Nuxt hooks. After anawaitin a non-setup context the Nuxt instance may be lost; usenuxtApp.runWithContext().
5.1 useFetch / useAsyncData typing
// server/api/products/index.get.ts
import { z } from 'zod'
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
category: z.string().optional(),
})
export default defineEventHandler(async (event) => {
const { page, category } = await getValidatedQuery(event, querySchema.parse)
const items = await listProducts({ page, category }) // returns Product[]
return { items, page, total: await countProducts({ category }) }
})<!-- app/pages/products/index.vue -->
<script setup lang="ts">
const route = useRoute()
const page = computed(() => Number(route.query.page ?? 1))
const category = computed(() => route.query.category?.toString())
// data: Ref<{ items: Product[]; page: number; total: number } | undefined>
const { data, status, error, refresh } = await useFetch('/api/products', {
query: { page, category }, // reactive → auto refetch
key: () => `products:${page.value}:${category.value ?? 'all'}`,
})
const totalPages = computed(() => Math.ceil((data.value?.total ?? 0) / 20))
</script>// useAsyncData with a non-HTTP source + transform + default
const { data: tree } = await useAsyncData(
() => `nav:${locale.value}`,
() => queryCollectionNavigation('docs'),
{
default: () => [], // data type: Ref<ContentNavigationItem[]>
watch: [locale],
},
)// pick reduces payload & narrows the type
const { data: article } = await useFetch(() => `/api/articles/${slug.value}`, {
pick: ['title', 'excerpt', 'body', 'publishedAt'],
})
// article: Ref<Pick<Article, 'title' | 'excerpt' | 'body' | 'publishedAt'> | undefined>5.2 External API with a typed $fetch instance
// app/plugins/api.ts
export default defineNuxtPlugin({
name: 'api',
setup(nuxtApp) {
const config = useRuntimeConfig()
const token = useCookie<string | null>('auth_token')
const api = $fetch.create({
baseURL: config.public.apiBase,
onRequest({ options }) {
if (token.value) {
options.headers.set('Authorization', `Bearer ${token.value}`)
}
},
async onResponseError({ response }) {
if (response.status === 401) {
token.value = null
await nuxtApp.runWithContext(() => navigateTo('/login'))
}
},
})
return { provide: { api } }
},
})// app/composables/useApi.ts — useFetch bound to the custom instance
import type { UseFetchOptions } from 'nuxt/app'
export function useApi<T>(
url: string | (() => string),
options: UseFetchOptions<T> = {},
) {
return useFetch(url, {
...options,
$fetch: useNuxtApp().$api as typeof $fetch,
})
}// usage with an explicit generic for an external API
interface Invoice { id: string; amount: number; status: 'paid' | 'open' }
const { data: invoices } = await useApi<Invoice[]>('/invoices')5.3 Typed plugins and helpers
// app/plugins/analytics.client.ts
export default defineNuxtPlugin(() => {
function track(event: 'signup' | 'purchase' | 'share', props: Record<string, string | number> = {}) {
window.dispatchEvent(new CustomEvent('analytics', { detail: { event, props } }))
}
return { provide: { track } }
})<script setup lang="ts">
const { $track } = useNuxtApp() // typed: (event: 'signup' | 'purchase' | 'share', ...) => void
// NOTE: client-only plugin → guard if this component can render on server
function onBuy() { $track?.('purchase', { sku: 'A1' }) }
</script>5.4 Augmenting PageMeta and app config
// app/types/page-meta.d.ts
declare module '#app' {
interface PageMeta {
requiresAuth?: boolean
roles?: Array<'admin' | 'editor' | 'viewer'>
breadcrumb?: string
}
}
export {}// app/middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn, user } = useUserSession() // from nuxt-auth-utils or project composable
if (!to.meta.requiresAuth) return
if (!loggedIn.value) return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
if (to.meta.roles && !to.meta.roles.includes(user.value!.role)) {
return abortNavigation(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
}
})5.5 Error typing
const { data, error } = await useFetch('/api/orders')
// error: Ref<NuxtError<unknown> | undefined> (Nuxt 4: undefined instead of null)
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: 'Orders not found', fatal: true })
}try {
await $fetch('/api/orders', { method: 'POST', body: payload })
}
catch (e) {
// ofetch throws FetchError with `data`, `status`
const err = e as import('ofetch').FetchError<{ message?: string; data?: { issues?: unknown[] } }>
toast.add({ title: 'Order failed', description: err.data?.message ?? err.message, color: 'error' })
}DO / DON'T
// DON'T — duplicate types, risk of drift
const { data } = await useFetch<Product[]>('/api/products') // ❌ handler returns { items, page, total }
// DO — let Nitro infer
const { data } = await useFetch('/api/products') // ✅// DON'T — mutating shallowRef payload (Nuxt 4)
data.value!.items.push(newItem) // ❌ no reactivity trigger
// DO — replace
data.value = { ...data.value!, items: [...data.value!.items, newItem] }
// or opt in explicitly where mutation-heavy: useFetch(url, { deep: true })6. Composables Best Practices
Rules
- Naming:
useXxxfor composables that use Vue/Nuxt context; plain verbs (formatPrice,slugify) for pure helpers inutils/. - Context: Nuxt composables (
useState,useCookie,useRoute,useRuntimeConfig,useNuxtApp,useRequestEvent…) must be called synchronously during setup, in plugins, route middleware ordefineNuxtComponent. Never call them in module scope,setTimeout, or afterawaitinside a non-setup function. - SSR safety — no module-level state.
const users = ref([])at module scope is shared across all requests on the server → cross-request data leak. UseuseState(per-request, hydrated) or Pinia. useState(key, init)values must be JSON-serialisable (plus what devalue supports:Date,Map,Set,RegExp,BigInt, refs). No class instances with methods, no functions, no DOM nodes.useStatekeys must be globally unique; prefix by feature ('cart:items').useCookiefor state that must be readable during SSR (auth token presence, locale, theme). SetsameSite,secure,maxAgeexplicitly. PreferhttpOnlycookies set by the server for secrets — those are not readable viauseCookieon the client by design.useRequestHeaders(['cookie'])to forward cookies when calling external APIs from SSR. Internal$fetch/useFetchto/api/*during SSR already forwards headers viauseRequestFetchsemantics — useuseRequestFetch()if you call$fetchmanually in SSR.- Browser APIs (
window,localStorage,matchMedia,IntersectionObserver) only insideonMounted,import.meta.clientbranches,.clientplugins, or SSR-safe VueUse composables. - Return refs (not reactive objects) from composables so consumers can destructure; return
readonly()views for state the consumer must not mutate. - Accept
MaybeRefOrGetter<T>inputs and normalise withtoValue(); this lets consumers pass values, refs or getters. - Clean up listeners and timers via
onScopeDispose(works in components and effect scopes) rather than onlyonUnmounted. - Do not create composables for one-off logic used once; extract when real reuse appears.
6.1 SSR-safe shared state with useState
// app/composables/useCart.ts
interface CartLine { sku: string; qty: number; priceCents: number }
export function useCart() {
const lines = useState<CartLine[]>('cart:lines', () => [])
const totalCents = computed(() => lines.value.reduce((s, l) => s + l.qty * l.priceCents, 0))
const count = computed(() => lines.value.reduce((s, l) => s + l.qty, 0))
function add(line: CartLine) {
const existing = lines.value.find(l => l.sku === line.sku)
lines.value = existing
? lines.value.map(l => (l.sku === line.sku ? { ...l, qty: l.qty + line.qty } : l))
: [...lines.value, line]
}
function remove(sku: string) {
lines.value = lines.value.filter(l => l.sku !== sku)
}
return { lines: readonly(lines), totalCents, count, add, remove }
}6.2 Cookies for SSR-visible preferences
// app/composables/usePreferences.ts
type Density = 'compact' | 'comfortable'
export function usePreferences() {
const density = useCookie<Density>('pref_density', {
default: () => 'comfortable',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
secure: !import.meta.dev,
})
return { density }
}6.3 Composables with flexible inputs
// app/composables/useUser.ts
import type { MaybeRefOrGetter } from 'vue'
export function useUser(id: MaybeRefOrGetter<string | undefined>) {
return useFetch(() => `/api/users/${toValue(id)}`, {
key: () => `user:${toValue(id)}`,
immediate: !!toValue(id),
watch: [() => toValue(id)],
})
}6.4 Forwarding cookies to an external API during SSR
// app/composables/useExternalProfile.ts
export function useExternalProfile() {
const headers = useRequestHeaders(['cookie', 'accept-language'])
const config = useRuntimeConfig()
return useAsyncData('external:profile', () =>
$fetch<{ name: string }>(`${config.public.accountsUrl}/me`, { headers }),
)
}6.5 Browser-only composables, SSR-safe
// app/composables/useOnline.ts
export function useOnlineStatus() {
const online = ref(true) // SSR default must match the first client render
if (import.meta.client) {
const update = () => { online.value = navigator.onLine }
onMounted(update)
useEventListener(window, 'online', update) // VueUse — auto-cleanup
useEventListener(window, 'offline', update)
}
return { online: readonly(online) }
}DO / DON'T
// DON'T — cross-request state leak on the server
// app/composables/useAuth.ts
const user = ref<User | null>(null) // ❌ module scope → shared by every visitor
export const useAuth = () => ({ user })
// DO
export const useAuth = () => {
const user = useState<User | null>('auth:user', () => null) // ✅ per request
return { user }
}// DON'T — Nuxt context lost after await in a util
export async function saveAndRedirect() {
await $fetch('/api/save', { method: 'POST' })
const router = useRouter() // ❌ may throw "nuxt instance unavailable"
}
// DO — acquire context first
export function useSaveAndRedirect() {
const router = useRouter()
return async () => {
await $fetch('/api/save', { method: 'POST' })
await router.push('/done')
}
}// DON'T — localStorage during SSR
const theme = ref(localStorage.getItem('theme')) // ❌ ReferenceError on server
// DO
const theme = useCookie<'light' | 'dark'>('theme', { default: () => 'light' })
// or VueUse: useLocalStorage('theme', 'light', { initOnMounted: true })7. State Management with Pinia
Rules
- Use
@pinia/nuxt. Stores live inapp/stores/(auto-imported). Do not create stores withcreatePinia()manually. - Prefer setup stores (
defineStore('id', () => { … })) for new code — full TypeScript inference, composables usable inside. Keep option stores where the codebase uses them. - In setup stores, return every piece of state (refs) — unreturned refs are not serialised for SSR and not visible in devtools.
- Store state must be serialisable for SSR hydration. Exclude non-serialisable or client-only values with
skipHydrate(). - Use
storeToRefs(store)to destructure state/getters; destructure actions directly from the store. - Do not fetch inside the store definition body (it runs once per request on server and once on client). Expose actions and call them from pages via
callOnceoruseAsyncData. - Use
callOnceto initialise global store data once per SSR request and avoid re-fetching on hydration. - Use Pinia for cross-page/client-long-lived domain state (session, cart, preferences, editors). Use
useFetchcache for server data that is page-scoped. UseuseStatefor tiny shared flags. Do not mirror every API response into Pinia. - Add
acceptHMRUpdatefor HMR in dev. - Never reach into another store's internal refs from outside actions in ways that bypass actions for invariants; call actions.
7.1 Setup store
// app/stores/session.ts
export const useSessionStore = defineStore('session', () => {
const user = ref<User | null>(null)
const permissions = ref<string[]>([])
const isAdmin = computed(() => user.value?.role === 'admin')
const can = (perm: string) => permissions.value.includes(perm)
async function fetchSession() {
const requestFetch = useRequestFetch() // forwards cookies during SSR
const res = await requestFetch('/api/session')
user.value = res.user
permissions.value = res.permissions
}
async function logout() {
await $fetch('/api/session', { method: 'DELETE' })
user.value = null
permissions.value = []
await navigateTo('/login')
}
return { user, permissions, isAdmin, can, fetchSession, logout }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useSessionStore, import.meta.hot))
}7.2 Initialising once per request
<!-- app/app.vue -->
<script setup lang="ts">
const session = useSessionStore()
await callOnce('session', () => session.fetchSession())
</script>
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>7.3 Client-only values with skipHydrate
// app/stores/editor.ts
import { useLocalStorage } from '@vueuse/core'
export const useEditorStore = defineStore('editor', () => {
const draft = useLocalStorage('editor:draft', '', { initOnMounted: true })
const fontSize = ref(16)
return { draft: skipHydrate(draft), fontSize }
})7.4 Consuming
<script setup lang="ts">
const session = useSessionStore()
const { user, isAdmin } = storeToRefs(session) // reactive refs
const { logout } = session // actions: plain destructure
</script>
<template>
<UDropdownMenu v-if="user" :items="[[{ label: 'Logout', onSelect: logout }]]">
<UButton :label="user.name" variant="ghost" />
</UDropdownMenu>
<UBadge v-if="isAdmin" label="Admin" />
</template>DO / DON'T
// DON'T — fetch in store body
export const useProducts = defineStore('products', () => {
const items = ref<Product[]>([])
$fetch('/api/products').then(r => { items.value = r.items }) // ❌ runs on both sides, races hydration
return { items }
})
// DO — action + caller decides when
export const useProducts = defineStore('products', () => {
const items = ref<Product[]>([])
async function load() { items.value = (await $fetch('/api/products')).items }
return { items, load }
})// DON'T
const { user, logout } = useSessionStore() // ❌ `user` loses reactivity
// DO
const store = useSessionStore()
const { user } = storeToRefs(store)8. Server Routes & Nitro
Every file in server/api, server/routes and every server middleware is a public HTTP entry point. Treat it as such regardless of whether "only our frontend calls it".
Rules
- File naming defines method and path:
server/api/users/[id].get.ts,server/api/users/index.post.ts, catch-all[...slug].ts. Prefer explicit method suffixes overif (event.method === …)branches. - Validate everything coming from the request: params (
getValidatedRouterParams), query (getValidatedQuery), body (readValidatedBody). Use the schemas fromshared/utilsso client and server agree. - Authorise every handler that touches non-public data. Server middleware may attach the session to
event.context, but each handler must still check permissions for the specific resource (tenant, ownership). - Throw
createError({ statusCode, statusMessage, data }). Never leak stack traces, SQL errors or internal identifiers instatusMessage/data. - Return plain serialisable objects. Return types become the client-side types via
InternalApi; keep them deliberate (do not return whole DB rows with password hashes). - Put reusable server logic in
server/utils/(auto-imported in server runtime only). Keep handlers thin: parse → authorise → call service → map DTO. server/middleware/*runs for every request including assets and/_nuxt/*in some presets — keep it cheap, never return a response unless intentionally short-circuiting.- Use
useRuntimeConfig(event)inside handlers (allows per-request env overrides on edge platforms). - Use
defineCachedEventHandler/defineCachedFunctionfor expensive, shareable reads; never cache per-user responses without a user-specificgetKey(or don't cache them at all). - Use
useStorage()(unstorage) for KV-like needs; configure drivers innitro.storageper environment. Do not rely on in-memory globals for state in serverless/edge. - On edge presets (Cloudflare, Vercel Edge), do not import Node built-ins (
fs,cryptofromnode:without polyfill support, native drivers). - Set security headers and CORS deliberately (
routeRules.headers,routeRules.cors, or a vetted module likenuxt-security). Do not setAccess-Control-Allow-Origin: *on authenticated APIs. - For SSR-time calls to your own API,
useFetch('/api/…')calls the handler directly in-process (no HTTP round trip). Do not hardcodehttp://localhost:3000in fetch URLs.
8.1 Typed handler with validation and authorisation
// server/api/projects/[id].patch.ts
import { z } from 'zod'
const paramsSchema = z.object({ id: z.string().uuid() })
const bodySchema = z.object({
name: z.string().trim().min(1).max(120).optional(),
archived: z.boolean().optional(),
}).refine(v => Object.keys(v).length > 0, 'Empty update')
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event) // server/utils/auth.ts
const { id } = await getValidatedRouterParams(event, paramsSchema.parse)
const patch = await readValidatedBody(event, bodySchema.parse)
const project = await findProject(id)
if (!project) throw createError({ statusCode: 404, statusMessage: 'Project not found' })
if (project.ownerId !== session.user.id) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
const updated = await updateProject(id, patch)
return toProjectDto(updated) // explicit DTO, no internal fields
})// server/utils/auth.ts
import type { H3Event } from 'h3'
export interface SessionUser { id: string; role: 'admin' | 'editor' | 'viewer' }
export async function requireUserSession(event: H3Event): Promise<{ user: SessionUser }> {
const session = event.context.session as { user?: SessionUser } | undefined
if (!session?.user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
return { user: session.user }
}8.2 Server middleware that attaches context
// server/middleware/01.session.ts
export default defineEventHandler(async (event) => {
// Only for API routes — skip assets and pages to stay cheap
if (!event.path.startsWith('/api/')) return
const token = getCookie(event, 'sid')
if (!token) return
event.context.session = await loadSession(token) // never throws for anonymous users
})// server/types/context.d.ts — type event.context
declare module 'h3' {
interface H3EventContext {
session?: { user: import('../utils/auth').SessionUser }
}
}
export {}8.3 Cached handlers
// server/api/stats/public.get.ts
export default defineCachedEventHandler(async () => {
return await computePublicStats()
}, {
maxAge: 60 * 10, // 10 min
swr: true, // serve stale while revalidating
name: 'public-stats',
getKey: () => 'v1',
})// server/utils/exchange-rates.ts
export const getRates = defineCachedFunction(async (base: string) => {
const { ratesApiKey } = useRuntimeConfig()
return await $fetch<{ rates: Record<string, number> }>(`https://api.example.com/latest`, {
query: { base, key: ratesApiKey },
})
}, { maxAge: 60 * 60, name: 'rates', getKey: (base: string) => base })8.4 Nitro plugins & storage
// server/plugins/logging.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
event.context.startedAt = performance.now()
})
nitroApp.hooks.hook('afterResponse', (event) => {
const ms = performance.now() - (event.context.startedAt as number)
console.info(`[${event.method}] ${event.path} ${ms.toFixed(1)}ms`)
})
})// nuxt.config.ts (excerpt)
export default defineNuxtConfig({
nitro: {
storage: {
cache: { driver: 'redis', url: process.env.REDIS_URL },
},
devStorage: {
cache: { driver: 'fs', base: './.data/cache' },
},
},
})// server/api/flags.get.ts
export default defineEventHandler(async () => {
const flags = await useStorage('cache').getItem<Record<string, boolean>>('feature-flags')
return flags ?? {}
})8.5 Non-API routes
// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) => {
const { siteUrl } = useRuntimeConfig(event).public
const slugs = await listPublishedSlugs()
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${slugs.map(s => ` <url><loc>${siteUrl}/blog/${s}</loc></url>`).join('\n')}
</urlset>`
})Prefer a maintained module (
@nuxtjs/sitemap) if the project already uses one; the example shows the primitive.
DO / DON'T
// DON'T — trust body, leak internals
export default defineEventHandler(async (event) => {
const body = await readBody(event) // ❌ unvalidated `any`
const user = await db.user.update({ where: { id: body.id }, data: body }) // ❌ mass assignment, no authz
return user // ❌ returns passwordHash
})
// DO — see 8.1: validate, authorise, map DTO// DON'T — per-user data in a shared cache
export default defineCachedEventHandler(async (event) => getDashboard(event.context.session!.user.id), {
maxAge: 300, // ❌ first user's dashboard served to everyone
})
// DO — no cache, or getKey including the user id + varies/headers as needed9. Rendering Mode — Universal / SSR (default)
What it is: every request is rendered on the server (Nitro), HTML is sent with a serialised payload, the client hydrates. Best for personalised or frequently changing pages that need SEO and fast first paint.
Rules
ssr: true(default). Requires a server runtime (Node, serverless or edge preset) in production.- Fetch page data with
await useFetch/useAsyncDatain<script setup>so it is fetched once on server and transferred via payload. Withoutawait, the page renders on the server without data (unlesslazyis intended). - Every render must be deterministic between server and client: no
Date.now(),Math.random(),windowchecks, locale-dependent formatting without fixed locale/timezone, or user-agent branching in the template. - Use
<ClientOnly>with a#fallbackmatching the final layout size for client-only widgets (maps, charts, editors) to avoid CLS. - Forward request context:
useRequestHeaders,useRequestEvent,useRequestURL,useRequestFetch. These return empty/undefined on the client — handle both. - Use
onServerPrefetchonly in non-<script setup>contexts; in<script setup>top-levelawaitis the idiom. - Personalised SSR responses must not be CDN-cached. Ensure
Cache-Control: privateor no shared cache for authenticated pages (route rules or server middleware). - Keep payload small:
pick,transform, avoid sending large unused objects; never put secrets intouseState/payload. - Use
lazy: true(oruseLazyFetch) for non-critical data below the fold so SSR is not blocked; render a skeleton based onstatus. - Timeouts: protect SSR from slow upstreams (
timeoutoption of$fetch,AbortSignal.timeout) and degrade gracefully instead of hanging the whole page. - Use
onPrehydratefor tiny DOM fixes that must run before hydration (e.g., applying a stored preference class) — keep them free of closures over component state.
nuxt.config
// nuxt.config.ts — Universal SSR
export default defineNuxtConfig({
ssr: true,
nitro: {
preset: 'node-server', // or 'vercel', 'netlify', 'cloudflare-pages', …
compressPublicAssets: true,
},
routeRules: {
// Authenticated area: SSR but never shared-cached
'/account/**': { headers: { 'cache-control': 'private, no-store' } },
// Static assets with long cache
'/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
},
experimental: {
payloadExtraction: false, // only meaningful for prerendered pages
},
})Patterns
<!-- app/pages/account/orders.vue — critical data awaited, secondary data lazy -->
<script setup lang="ts">
definePageMeta({ requiresAuth: true })
const { data: orders } = await useFetch('/api/orders', {
pick: ['items', 'total'],
default: () => ({ items: [], total: 0 }),
})
const { data: recommendations, status: recStatus } = useLazyFetch('/api/recommendations', {
server: false, // personalised, non-critical, not needed for SEO → client-only
})
</script>
<template>
<section>
<h1>Your orders ({{ orders.total }})</h1>
<OrderTable :items="orders.items" />
<h2 class="mt-8">You might like</h2>
<RecommendationSkeleton v-if="recStatus === 'pending' || recStatus === 'idle'" />
<RecommendationGrid v-else-if="recommendations" :items="recommendations" />
</section>
</template><!-- Deterministic time rendering -->
<script setup lang="ts">
const { publishedAt } = defineProps<{ publishedAt: string }>()
</script>
<template>
<!-- NuxtTime (≥3.17) renders consistently on server & client -->
<NuxtTime :datetime="publishedAt" locale="en-GB" year="numeric" month="short" day="numeric" />
</template><!-- Client-only widget with layout-stable fallback -->
<template>
<ClientOnly>
<LazyMapView :center="center" />
<template #fallback>
<div class="aspect-video w-full animate-pulse rounded-lg bg-elevated" aria-hidden="true" />
</template>
</ClientOnly>
</template>// Request-aware logic (works on server, safe on client)
const url = useRequestURL() // full URL on both sides
const event = useRequestEvent() // H3Event on server, undefined on client
if (import.meta.server && event) {
setResponseHeader(event, 'x-rendered-by', 'nuxt')
}DO / DON'T (SSR)
| DO | DON'T |
await useFetch() for content that must be in HTML | onMounted(() => $fetch(...)) for SEO-critical content |
| Fixed locale + timezone for formatting in templates | new Date().toLocaleString() in templates |
Cache-Control: private on personalised routes | Put personalised SSR pages behind a public CDN cache |
| Upstream timeouts + graceful fallback | Let a slow 3rd-party API block TTFB indefinitely |
10. Rendering Mode — Static Site Generation (SSG / Pre-rendering)
What it is: HTML is generated at build time (nuxt generate or routeRules: { prerender: true }). The site can be hosted on any static host/CDN. After load, the app hydrates and navigations use prerendered payloads (_payload.json).
Rules
- Build with
nuxt generate(=nuxt build --prerenderwith a static preset). Ensure the host servesindex.htmlper directory and the200.html/404.htmlfallbacks as required. - All data must be available at build time. Anything fetched with
useFetchduring prerender is frozen into the payload; it will not update until the next build. - Server routes do not exist at runtime on pure static hosting.
/api/*calls made during build are captured; calls made only on the client (server: false, user actions) will 404. Use an external API or a hybrid deployment for runtime endpoints. - Make every route discoverable: the crawler (
nitro.prerender.crawlLinks: true) follows<a href>/<NuxtLink>from seed routes. Add non-linked routes tonitro.prerender.routesor callprerenderRoutes()/ theprerender:routeshook. - Enable
nitro.prerender.failOnError: truein CI so broken links and render errors fail the build. - Keep
experimental.payloadExtractionenabled (default for prerendered pages) so client-side navigation loads_payload.jsoninstead of re-fetching APIs. - Personalisation, auth state and anything per-user must be done client-side after hydration (
<ClientOnly>,server: false,onMounted), with a static-friendly fallback in HTML. runtimeConfigvalues are baked at build time for static output. Runtime env variables on the static host have no effect.- Dynamic routes (
/blog/[slug]) need an explicit list (from CMS/content) at build time; Nuxt Content v3 routes are found by crawling when linked. - Large sites: consider hybrid prerendering (section 12) instead of generating hundreds of thousands of pages per build.
nuxt.config
// nuxt.config.ts — full SSG
export default defineNuxtConfig({
ssr: true, // keep true: SSG = SSR at build time
nitro: {
preset: 'static', // or 'github-pages', 'netlify-static', 'vercel-static'
prerender: {
crawlLinks: true,
routes: ['/', '/sitemap.xml', '/rss.xml'],
ignore: ['/admin', '/preview/**'],
failOnError: true,
concurrency: 8,
},
},
experimental: {
payloadExtraction: true,
sharedPrerenderData: true, // dedupe identical useAsyncData keys across pages (default in v4)
},
runtimeConfig: {
public: { siteUrl: 'https://example.com' }, // baked at build time
},
})Patterns
// nuxt.config.ts — feed dynamic routes from a CMS at build time
export default defineNuxtConfig({
hooks: {
async 'nitro:config'(nitroConfig) {
const res = await fetch('https://cms.example.com/api/posts?fields=slug')
const posts = (await res.json()) as Array<{ slug: string }>
nitroConfig.prerender ??= {}
nitroConfig.prerender.routes = [
...(nitroConfig.prerender.routes ?? []),
...posts.map(p => `/blog/${p.slug}`),
]
},
},
})<!-- app/pages/blog/[slug].vue — content page, also hints related routes -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(`post:${route.path}`, () =>
queryCollection('blog').path(route.path).first(),
)
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
// Ensure related posts are prerendered even if not linked in HTML
if (import.meta.server && post.value.related?.length) {
prerenderRoutes(post.value.related.map((s: string) => `/blog/${s}`))
}
useSeoMeta({ title: post.value.title, description: post.value.description })
</script>
<template>
<article class="prose dark:prose-invert">
<h1>{{ post!.title }}</h1>
<ContentRenderer :value="post!" />
</article>
</template><!-- Personalised fragment on a static page -->
<template>
<header class="flex items-center justify-between">
<AppLogo />
<ClientOnly>
<UserMenu /> <!-- fetches session client-side -->
<template #fallback>
<UButton to="/login" label="Sign in" variant="ghost" />
</template>
</ClientOnly>
</header>
</template>DO / DON'T (SSG)
// DON'T — runtime API on static hosting
const { data } = useFetch('/api/search', { query: { q }, server: false }) // ❌ 404 on static host
// DO — external search service, or client-side index generated at build time
const { data } = useFetch('https://search.example.com/query', { query: { q }, server: false })
// or: Nuxt Content v3 `queryCollectionSearchSections` + client-side fuzzy search| DO | DON'T |
failOnError: true in CI | Ship a build with silently skipped pages |
Treat runtimeConfig as build-time | Expect NUXT_PUBLIC_* on the static host to change behaviour |
| Seed non-linked routes explicitly | Assume the crawler finds routes behind JS-only navigation |
| Render auth state client-side | Prerender a page with one user's session baked in |
11. Rendering Mode — SPA (Client-Side Rendering)
What it is: ssr: false. Nuxt serves an HTML shell; Vue renders entirely in the browser. Use for authenticated dashboards, internal tools, apps embedded in native shells (Capacitor/Tauri) or hosting without a server — when SEO is irrelevant.
Rules
- Set
ssr: falseglobally, or prefer per-routerouteRules: { '/app/**': { ssr: false } }inside an SSR app (section 12) when public pages need SEO. - Provide
app/spa-loading-template.html(orspaLoadingTemplate) for a branded loader instead of a blank page. useFetch/useAsyncDatastill work and provide status/caching, but run only on the client.awaitin<script setup>delays the component render until data resolves — uselazy: truewith skeletons for better perceived performance.import.meta.servercode paths never execute at runtime;useRequestHeaders,useRequestEventreturn empty. Do not write SSR-only logic.- Browser APIs are available, but still guard them in shared components/composables that might later run under SSR (routeRules can flip a route back to SSR).
- Auth: token handling is entirely client-side — prefer
httpOnlysession cookies set by your API overlocalStoragetokens (XSS exposure). Route middleware enforces UX, not security; the API must authorise. - Static hosting: configure the host to rewrite unknown paths to
200.html/index.html(generated bynuxt generatewithssr: false). - SEO tags via
useSeoMetaare applied client-side only — crawlers that don't execute JS won't see them. Don't promise SEO for SPA routes. - Bundle size matters more (nothing is rendered until JS loads): lazy-load heavy routes/components, audit with
nuxi analyze. - Nitro server routes still work if you deploy with a server preset (
ssr: falseonly disables page SSR).
nuxt.config
// nuxt.config.ts — pure SPA, static hosting
export default defineNuxtConfig({
ssr: false,
nitro: { preset: 'static' },
app: {
head: { title: 'Acme Console', meta: [{ name: 'robots', content: 'noindex' }] },
},
// Optional explicit template location (default: app/spa-loading-template.html)
spaLoadingTemplate: true,
})<!-- app/spa-loading-template.html -->
<div style="display:grid;place-items:center;height:100vh;font-family:system-ui">
<div role="status" aria-live="polite">Loading Acme Console…</div>
</div>Patterns
<!-- app/pages/dashboard.vue — SPA: lazy fetch + skeleton -->
<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
const { data: metrics, status, error, refresh } = useLazyFetch('/api/metrics', {
default: () => ({ revenue: 0, orders: 0, churn: 0 }),
})
useIntervalFn(() => refresh(), 60_000) // VueUse — safe: SPA runs only in browser
</script>
<template>
<UAlert v-if="error" color="error" title="Could not load metrics" :description="error.message" />
<MetricsSkeleton v-else-if="status === 'pending'" />
<MetricsGrid v-else :metrics="metrics" />
</template>// app/middleware/auth.ts — UX guard (API still enforces authz)
export default defineNuxtRouteMiddleware(async (to) => {
const session = useSessionStore()
if (!session.user) {
await session.fetchSession().catch(() => {})
}
if (!session.user) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
}
})// app/plugins/chunk-reload.client.ts — recover from stale chunks after deploy
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:chunkError', () => {
reloadNuxtApp({ persistState: true })
})
})Nuxt already handles chunk errors via
experimental.emitRouteChunkError(default'automatic'); add the plugin only when you need custom behaviour.
DO / DON'T (SPA)
| DO | DON'T |
lazy: true + skeletons | Block the whole app on several sequential awaits |
httpOnly cookie sessions | Store long-lived JWTs in localStorage |
robots: noindex for private SPA | Rely on client-only useSeoMeta for public SEO |
| Configure SPA fallback rewrites on the host | Deploy without rewrites → deep links 404 |
12. Rendering Mode — Hybrid (routeRules, ISR / SWR)
What it is: different rendering strategies per route via routeRules. Requires a server/serverless deployment (Nitro). This is the recommended production default for mixed sites: static marketing, ISR blog, SSR product pages, SPA dashboard.
routeRules reference (verify against installed version)
| Rule | Effect |
prerender: true | Rendered at build time, served as static file |
swr: number | true | Server-rendered, cached in Nitro storage; stale served while revalidating in background |
isr: number | true | Like SWR but uses the platform CDN cache (Vercel/Netlify); true = until next deploy |
ssr: false | Client-side rendering for matching routes |
cache: { maxAge, swr, varies, … } | Low-level Nitro cache options |
headers: {…} | Response headers |
redirect: '/new' | { to, statusCode } | Server redirect |
proxy: 'https://…/**' | Proxy to another origin |
cors: true | Adds permissive CORS headers — use only for public endpoints |
noScripts: true | Strip Nuxt scripts (pure static HTML, no hydration) |
appMiddleware: … | Enable/disable named app middleware for routes |
Rules
- Choose per route by freshness × personalisation × SEO:
- Same for everyone, rarely changes →
prerender. - Same for everyone, changes periodically →
isr(CDN platforms) orswr(Nitro cache). - Personalised and SEO-relevant → SSR, no shared cache.
- Personalised and SEO-irrelevant →
ssr: false.
- Same for everyone, rarely changes →
- Never apply
swr/isr/cacheto routes whose HTML depends on cookies/session. The first visitor's personalised HTML would be served to everyone. - Cached SSR pages must render a user-agnostic shell; personalise on the client (
<ClientOnly>,server: falsefetches). - Order of glob specificity matters: more specific patterns override general ones. Keep the rules table small, documented and testable.
- ISR invalidation: use platform features (Vercel
bypassToken, on-demand revalidation) — configure vianitro.vercel.configor the platform's documented mechanism; don't invent invalidation endpoints. isrrequires a supporting preset; on other presets it behaves likeswr(Nitro storage) — verify for the target platform.- Use
defineRouteRules()in page components only ifexperimental.inlineRouteRulesis enabled in the project. - Apply matching caching for API routes the cached pages call (
swron/api/catalog/**), otherwise ISR pages still hammer the backend on revalidation. - Test each rule after build: inspect response headers (
cache-control,x-nitro-*,age) and verify prerendered files exist in.output/public.
nuxt.config
// nuxt.config.ts — Hybrid production setup
export default defineNuxtConfig({
routeRules: {
// Marketing: static at build time
'/': { prerender: true },
'/pricing': { prerender: true },
'/legal/**': { prerender: true },
// Blog: ISR on CDN, revalidate hourly
'/blog': { isr: 3600 },
'/blog/**': { isr: 3600 },
// Product catalogue: SWR in Nitro cache (10 min), public data only
'/products/**': { swr: 600 },
'/api/catalog/**': { swr: 600, cors: true },
// Account: SSR, never shared-cached
'/account/**': { headers: { 'cache-control': 'private, no-store' } },
// Dashboard: SPA
'/app/**': { ssr: false },
// Legacy redirects
'/old-blog/**': { redirect: { to: '/blog/**', statusCode: 301 } },
// Pure static docs without JS
'/print/**': { prerender: true, noScripts: true },
},
nitro: { preset: 'vercel' },
})Patterns
<!-- app/pages/products/[slug].vue — cached (swr) page with client personalisation -->
<script setup lang="ts">
const route = useRoute()
const slug = computed(() => String(route.params.slug))
// Public, cacheable data — part of cached HTML
const { data: product } = await useFetch(() => `/api/catalog/products/${slug.value}`)
if (!product.value) throw createError({ statusCode: 404, statusMessage: 'Product not found', fatal: true })
// Per-user data — NEVER in cached HTML
const { data: wishlist } = useFetch('/api/me/wishlist', { server: false, lazy: true })
const inWishlist = computed(() => wishlist.value?.includes(product.value!.id) ?? false)
</script>
<template>
<ProductHero :product="product!" />
<ClientOnly>
<WishlistButton :product-id="product!.id" :active="inWishlist" />
<template #fallback><div class="h-10 w-40" aria-hidden="true" /></template>
</ClientOnly>
</template>// Vercel ISR on-demand revalidation (verify option names for your preset/version)
export default defineNuxtConfig({
nitro: {
preset: 'vercel',
vercel: { config: { bypassToken: process.env.VERCEL_BYPASS_TOKEN } },
},
})Hybrid decision table
| Route type | Example | Rule | Personal data |
| Landing | / | prerender | client-only |
| Blog post | /blog/x | isr: 3600 | none |
| Catalogue | /products/x | swr: 600 | client-only |
| Checkout | /checkout | SSR (no cache) | server |
| Admin | /app/** | ssr: false | client |
DO / DON'T (Hybrid)
// DON'T
routeRules: { '/**': { swr: 60 } } // ❌ caches /account, /checkout, everything
// DO — opt in per route group, keep personalised routes uncached
routeRules: {
'/products/**': { swr: 60 },
'/account/**': { headers: { 'cache-control': 'private, no-store' } },
}<!-- DON'T — personalised header rendered on server in an ISR page -->
<p>Welcome back, {{ session.user?.name }}</p> <!-- ❌ frozen into shared HTML -->
<!-- DO -->
<ClientOnly><p v-if="session.user">Welcome back, {{ session.user.name }}</p></ClientOnly>13. Auto-imports & Module System
Rules
- Rely on auto-imports for Vue APIs (
ref,computed,watch…), Nuxt composables, yourcomposables/,utils/,stores/and components. This is the project convention; don't add redundant explicit imports for them. - Use explicit imports when:
- you need a value binding for types (
typeof Comp,InstanceType<typeof Comp>), - importing types (
import type { Ref } from 'vue') — types fromvueare not auto-imported unless they're inshared/types, - importing from third-party libraries not registered via a module (
zod,date-fns,@vueuse/corefunctions not exposed by@vueuse/nuxt), - code in
shared/that must be portable, or in libraries/layers published separately, - name collisions exist (two
useXfrom different sources) — resolve viaimports.presetsor explicit import with alias.
- you need a value binding for types (
- Use
#importsfor explicit imports of Nuxt auto-imports when necessary (import { useRoute } from '#imports') — e.g., in tests or files outside the scan. - Use
#componentsfor explicit component imports (import { LazyChart } from '#components'), e.g. for dynamic<component :is>usage. - Dynamic components:
<component :is="'MyComp'">with a string does not work for auto-registered components unless they're global. UseresolveComponent('MyComp')with a literal string, or import from#components. - Do not register components globally (
components: { global: true }or.global.vue) without need — global components cannot be tree-shaken per page. - Use
imports.dirs/components.dirsconfig to extend scanning instead of deep relative imports. - Module order in
modulesmatters for modules that hook each other (e.g.,@nuxt/uibefore modules depending on its Tailwind context). Check module docs. - Server auto-imports (
server/utils, Nitro/h3 helpers) are not available inapp/and vice versa. TypeScript projects in Nuxt 4 enforce this — don't silence the errors. - When adding a composable, run
nuxi prepare(or let dev server regenerate) before trusting editor errors.
Examples
<script setup lang="ts">
// ✅ types: explicit
import type { Ref } from 'vue'
// ✅ 3rd-party lib: explicit
import { z } from 'zod'
// ✅ component used as a value: explicit
import { LazyRichEditor } from '#components'
// ✅ auto-imported: ref, computed, useRoute, useFetch, useCart, formatMoney
const route = useRoute()
const { totalCents } = useCart()
const editorComponent: Ref<typeof LazyRichEditor | null> = shallowRef(null)
</script><!-- Dynamic component resolution -->
<script setup lang="ts">
const { kind } = defineProps<{ kind: 'text' | 'image' | 'video' }>()
const blocks = {
text: resolveComponent('BlockText'),
image: resolveComponent('BlockImage'),
video: resolveComponent('BlockVideo'),
} as const
</script>
<template>
<component :is="blocks[kind]" />
</template>// nuxt.config.ts — extending scan dirs & disabling problematic presets
export default defineNuxtConfig({
imports: {
dirs: ['composables/**'], // scan nested composables (relative to srcDir)
},
components: [
{ path: '~/components/ui', prefix: 'Ui' },
'~/components',
],
})DO / DON'T
<!-- DON'T — string `is` with auto-registered component -->
<component :is="`Block${capitalize(kind)}`" /> <!-- ❌ renders unknown element -->
<!-- DO — resolveComponent with literal strings (see above) -->// DON'T — deep relative imports bypass aliases and break on moves
import { formatMoney } from '../../../utils/format' // ❌
// DO — auto-import or alias
formatMoney(100) // ✅ (auto-import)
import { formatMoney } from '~/utils/format' // ✅ (when explicit import required)14. Performance
Measure first (nuxi analyze, Lighthouse, WebPageTest, RUM/Core Web Vitals). Never promise scores without measurement; distinguish lab data from field data.
Rules
- Lazy components: prefix with
Lazy(<LazyHeavyChart v-if="open" />) for components not needed on first render.Lazywithout a condition only splits the chunk — it still loads immediately. - Lazy hydration (Nuxt ≥ 3.16):
hydrate-on-visible,hydrate-on-idle,hydrate-on-interaction="mouseover",hydrate-on-media-query="(max-width: 768px)",hydrate-after="2000",hydrate-when="cond",hydrate-never. Server-rendered HTML is shown immediately; JS hydrates later. Use for below-the-fold interactive components. - Server components / islands (
*.server.vue,<NuxtIsland>,experimental.componentIslands): render heavy, non-interactive content (markdown, syntax highlighting, large static tables) on the server with zero client JS. They re-fetch HTML when props change — keep props small. <NuxtLink>prefetching: default prefetch on visibility. For long link lists, useprefetch-on="interaction"(or globalexperimental.defaults.nuxtLink.prefetchOn) to avoid flooding the network.- Payload: use
pick/transform; avoid returning large blobs into SSR payload.experimental.payloadExtractionfor prerendered pages. - Images:
<NuxtImg>/<NuxtPicture>with explicitwidth/height(orsizes),format="webp"/avif,loading="lazy"for below the fold,preload+fetchpriority="high"for the LCP image. Configure the provider (ipx, Cloudinary, Vercel…) to match deployment. - Fonts:
@nuxt/fontsself-hosts and generates fallback metrics (reduces CLS). Don't add manual Google Fonts<link>tags alongside it. - Third-party scripts: load via
@nuxt/scripts(useScript, registry helpers such asuseScriptGoogleAnalytics) with triggers (onNuxtReady, consent, interaction) instead of raw<script>tags inapp.head. - Bundle hygiene: import only what you use (
date-fnsfunctions, not moment); avoid barrel files that defeat tree-shaking; checknuxi analyzeafter adding a dependency. - Chunking: don't hand-tune
vite.build.rollupOptions.output.manualChunksunless analysis shows a concrete problem; Nuxt/Vite defaults are usually better. If you must, group stable vendor libs only. - Reactivity cost:
shallowRef/markRawfor large immutable data (charts, maps, editor instances);v-memo/v-oncefor expensive static subtrees; virtualise long lists (useVirtualListfrom VueUse or a vetted library). - Avoid waterfalls: run independent fetches in parallel (
Promise.allinside oneuseAsyncData, or multipleuseFetchwithout sequentialawaits when they don't depend on each other). <KeepAlive>via<NuxtPage :keepalive="{ max: 5 }">ordefinePageMeta({ keepalive: true })for expensive tab-like pages — bounded withmax.
Examples
<!-- Lazy hydration for below-the-fold interactivity -->
<template>
<HeroSection />
<LazyTestimonialsCarousel hydrate-on-visible />
<LazyNewsletterForm hydrate-on-interaction="focusin" />
<LazyFooterMap hydrate-never />
</template><!-- app/components/CodeBlock.server.vue — zero client JS (requires experimental.componentIslands) -->
<script setup lang="ts">
import { codeToHtml } from 'shiki'
const { code, lang = 'ts' } = defineProps<{ code: string; lang?: string }>()
const html = await codeToHtml(code, { lang, theme: 'github-dark' })
</script>
<template>
<div class="overflow-x-auto rounded-lg text-sm" v-html="html" />
</template><!-- LCP image -->
<template>
<NuxtImg
src="/hero.jpg"
alt="Team collaborating around a whiteboard"
width="1600"
height="900"
sizes="100vw md:80vw lg:1200px"
format="avif,webp"
preload
fetchpriority="high"
class="aspect-video w-full object-cover"
/>
</template>// Parallel fetching instead of a waterfall
const [{ data: user }, { data: stats }] = await Promise.all([
useFetch('/api/me'),
useFetch('/api/me/stats'),
])// Third-party script with consent trigger
const consent = useScriptTriggerConsent()
const { proxy: gtag } = useScriptGoogleAnalytics({
id: useRuntimeConfig().public.gaId,
scriptOptions: { trigger: consent },
})
function onAccept() { consent.accept() }
gtag.gtag('event', 'page_view') // queued until loaded// nuxt.config.ts — performance-relevant options
export default defineNuxtConfig({
experimental: {
componentIslands: true,
defaults: {
nuxtLink: { prefetchOn: { visibility: false, interaction: true } },
},
},
image: {
quality: 80,
formats: ['avif', 'webp'],
screens: { sm: 640, md: 768, lg: 1024, xl: 1280 },
},
vite: {
build: { sourcemap: false },
},
})DO / DON'T
| DO | DON'T |
LazyX + v-if / hydrate-on-* for heavy widgets | Import a 300 KB chart lib eagerly in the layout |
shallowRef for chart datasets | Deep-reactive 10k-row arrays |
<NuxtImg> with dimensions | <img> without width/height (CLS) |
@nuxt/scripts with triggers | app.head.script with blocking third-party tags |
Measure before/after with nuxi analyze | Hand-tune manualChunks by intuition |
15. SEO & Meta
Rules
- Use
useSeoMetafor standard meta tags (typed, XSS-safe, de-duplicated). UseuseHeadforlink,script,htmlAttrs,bodyAttrsand anythinguseSeoMetadoesn't cover. - Set defaults once (in
app.vueor a layout) withtitleTemplate; override per page. Do not duplicate<title>logic across components. - Pass reactive getters when values depend on async data:
useSeoMeta({ title: () => post.value?.title }). - Meta must be resolvable during SSR for SEO-relevant pages: fetch data with
await useFetchbefore callinguseSeoMeta, or use getters. - Provide canonical URLs (
link rel="canonical") built fromruntimeConfig.public.siteUrl+ path; strip tracking query params. - OG images: use
nuxt-og-imageif the project has it, or static images with absolute URLs. OGog:imagemust be an absolute URL. - Structured data (JSON-LD) via
useHead({ script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(...) }] })ornuxt-schema-orgif installed. Never inject unescaped user content intoinnerHTMLfor non-JSON scripts. - i18n: use
useLocaleHead()from@nuxtjs/i18nto emithreflang,og:localeandlangattributes; setbaseUrlin i18n config. - Use
useServerSeoMetafor meta that never changes on the client (saves client JS). noindexfor private, preview, search-result and duplicate pages. Don't block CSS/JS in robots.txt.- Server-render 404s with a real 404 status (
createError({ statusCode: 404, fatal: true })) — not a 200 page that says "not found".
Examples
<!-- app/app.vue — global defaults -->
<script setup lang="ts">
const { siteUrl } = useRuntimeConfig().public
const route = useRoute()
useHead({
htmlAttrs: { lang: 'en' },
titleTemplate: t => (t ? `${t} · Acme` : 'Acme — Ship faster'),
link: [{ rel: 'canonical', href: () => new URL(route.path, siteUrl).href }],
})
useSeoMeta({
ogSiteName: 'Acme',
twitterCard: 'summary_large_image',
})
</script><!-- app/pages/blog/[slug].vue — page-level SEO with structured data -->
<script setup lang="ts">
const route = useRoute()
const { siteUrl } = useRuntimeConfig().public
const { data: post } = await useAsyncData(`post:${route.path}`, () =>
queryCollection('blog').path(route.path).first(),
)
if (!post.value) throw createError({ statusCode: 404, statusMessage: 'Not found', fatal: true })
useSeoMeta({
title: () => post.value!.title,
description: () => post.value!.description,
ogTitle: () => post.value!.title,
ogDescription: () => post.value!.description,
ogType: 'article',
ogImage: () => new URL(post.value!.image ?? '/og-default.png', siteUrl).href,
articlePublishedTime: () => post.value!.date,
})
useHead({
script: [{
type: 'application/ld+json',
innerHTML: () => JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value!.title,
datePublished: post.value!.date,
author: { '@type': 'Person', name: post.value!.author },
mainEntityOfPage: new URL(route.path, siteUrl).href,
}),
}],
})
</script><!-- i18n-aware head in a layout -->
<script setup lang="ts">
const head = useLocaleHead({ dir: true, seo: true }) // @nuxtjs/i18n
</script>
<template>
<Html :lang="head.htmlAttrs?.lang" :dir="head.htmlAttrs?.dir">
<Head>
<template v-for="link in head.link" :key="link.key">
<Link :id="link.key" :rel="link.rel" :href="link.href" :hreflang="link.hreflang" />
</template>
<template v-for="meta in head.meta" :key="meta.key">
<Meta :id="meta.key" :property="meta.property" :content="meta.content" />
</template>
</Head>
<Body><slot /></Body>
</Html>
</template>DO / DON'T
// DON'T — non-reactive snapshot of async data
useSeoMeta({ title: post.value?.title }) // ❌ undefined if data arrives later / changes on navigation
// DO
useSeoMeta({ title: () => post.value?.title }) // ✅// DON'T — relative OG image
useSeoMeta({ ogImage: '/og.png' }) // ❌ many scrapers require absolute URLs
// DO
useSeoMeta({ ogImage: `${siteUrl}/og.png` }) // ✅16. Error Handling
Rules
- Two error scopes:
- Fatal / page-level:
throw createError({ statusCode, statusMessage, fatal: true })in setup or middleware → rendersapp/error.vuewith correct HTTP status (on SSR). - Local / recoverable:
<NuxtErrorBoundary>around a widget, or handleerrorfromuseFetch.
- Fatal / page-level:
app/error.vuereceiveserror: NuxtErroras a prop; it is rendered outside layouts/pages. UseclearError({ redirect: '/' })to recover.- In server handlers,
throw createError(...). Never return{ error: '…' }with status 200. - Use
showError()to trigger the full-screen error from client code (e.g. after an action) anduseError()to read the current error. - Distinguish expected errors (404, 403, validation 422) from unexpected (500). Show helpful UI for expected ones; log unexpected ones with context to your observability tool (Sentry via
@sentry/nuxt, etc.) — don't log PII. - Do not swallow errors (
catch {}) — at minimum log and surface a user-friendly message. statusMessageis visible to users and may appear in HTTP status lines — keep it short, ASCII-safe, without internals. Usedatafor structured details (validation issues).- Hook into
vue:error/app:error(Nuxt) anderror(Nitro) for global reporting, not for control flow. - Route middleware: return
abortNavigation(createError(...))ornavigateTo(...); don't throw arbitrary errors. - Handle
status === 'error'in everyuseFetchUI path; never renderdata.value!.xwithout considering the error case.
Examples
<!-- app/error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'
const { error } = defineProps<{ error: NuxtError }>()
const is404 = computed(() => error.statusCode === 404)
useSeoMeta({ title: () => (is404.value ? 'Page not found' : 'Something went wrong'), robots: 'noindex' })
function goHome() {
clearError({ redirect: '/' })
}
</script>
<template>
<UApp>
<main class="grid min-h-dvh place-items-center p-6 text-center">
<div class="max-w-md space-y-4">
<p class="text-sm font-semibold text-primary">{{ error.statusCode }}</p>
<h1 class="text-3xl font-bold">
{{ is404 ? 'We couldn’t find that page' : 'Unexpected error' }}
</h1>
<p class="text-muted">{{ is404 ? 'Check the URL or go back home.' : 'Please try again in a moment.' }}</p>
<UButton label="Back to home" @click="goHome" />
</div>
</main>
</UApp>
</template><!-- Local error boundary around a fragile widget -->
<template>
<NuxtErrorBoundary @error="(e) => reportError(e)">
<RevenueChart :series="series" />
<template #error="{ error, clearError }">
<UAlert
color="error"
title="Chart failed to render"
:description="String(error)"
:actions="[{ label: 'Retry', onClick: clearError }]"
/>
</template>
</NuxtErrorBoundary>
</template>// Server: validation errors with structured data
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, body => signupSchema.safeParse(body))
if (!result.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation failed',
data: { issues: result.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })) },
})
}
return await createAccount(result.data)
})// app/plugins/error-reporting.ts — global reporting
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vue:error', (error, instance, info) => {
console.error('[vue:error]', info, error) // replace with Sentry.captureException
})
nuxtApp.hook('app:error', (error) => {
console.error('[app:error]', error)
})
})// server/plugins/errors.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
console.error(`[nitro] ${event?.path ?? 'unknown'}`, error)
})
})DO / DON'T
// DON'T
export default defineEventHandler(async () => {
try { return await doWork() }
catch (e) { return { error: (e as Error).message } } // ❌ 200 + leaks message
})
// DO
export default defineEventHandler(async () => {
try { return await doWork() }
catch (e) {
console.error(e)
throw createError({ statusCode: 502, statusMessage: 'Upstream unavailable' })
}
})// DON'T — 404 page with 200 status
if (!product.value) navigateTo('/not-found') // ❌ soft 404
// DO
if (!product.value) throw createError({ statusCode: 404, statusMessage: 'Product not found', fatal: true })17. Testing (Vitest + @nuxt/test-utils)
Rules
- Choose the cheapest environment that proves the behaviour:
- pure functions (
shared/utils,app/utils) → plain Vitest,nodeenvironment; - components/composables using Nuxt auto-imports →
nuxtenvironment (@nuxt/test-utils/runtime); - full flows across server + client →
@nuxt/test-utils/e2e(setup,$fetch,createPage) or Playwright.
- pure functions (
- Use
mountSuspended(async setup, auto-imports, plugins) instead of rawmountfor Nuxt components. UserenderSuspendedfor Testing Library style. - Mock Nuxt auto-imports with
mockNuxtImport(hoisted; one per import per file). Mock components withmockComponent. Mock API endpoints withregisterEndpoint. - Test behaviour, not implementation: user-visible output, emitted events, calls to endpoints — not internal refs.
- Test server handlers by calling them through
$fetchin e2e or by unit-testing extractedserver/utilsfunctions with plain Vitest. - Cover SSR-specific risks: render the component in SSR (
e2e$fetch('/page')→ HTML assertions) for pages whose SEO/HTML matters. - Reset Pinia state between tests (
setActivePinia(createPinia())or thenuxtenvironment's fresh app), clearuseStateviaclearNuxtState(). - Do not update snapshots blindly. Prefer explicit assertions to large snapshots.
- Keep tests deterministic: fake timers for time-based code, fixed locale/timezone (
TZ=UTC), seeded data. - Run only real scripts from
package.json(test,test:unit,test:e2e). Report actual results.
Configuration
// vitest.config.ts — projects: unit (node) + nuxt (runtime)
import { defineConfig } from 'vitest/config'
import { defineVitestProject } from '@nuxt/test-utils/config'
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['test/unit/**/*.test.ts'],
environment: 'node',
},
},
await defineVitestProject({
test: {
name: 'nuxt',
include: ['test/nuxt/**/*.test.ts'],
environment: 'nuxt',
environmentOptions: { nuxt: { domEnvironment: 'happy-dom' } },
},
}),
],
},
})Older
@nuxt/test-utilsversions usedefineVitestConfigfrom@nuxt/test-utils/configwith a singleenvironment: 'nuxt'. Check the installed version before editing.
Examples
// test/unit/format.test.ts
import { describe, expect, it } from 'vitest'
import { formatMoney } from '../../app/utils/format'
describe('formatMoney', () => {
it('formats cents as currency', () => {
expect(formatMoney(123456, 'EUR', 'en-US')).toBe('€1,234.56')
})
it('handles zero', () => {
expect(formatMoney(0, 'USD', 'en-US')).toBe('$0.00')
})
})// test/nuxt/BaseInput.test.ts
import { describe, expect, it } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import BaseInput from '~/components/base/Input.vue'
describe('BaseInput', () => {
it('links error message via aria-describedby', async () => {
const wrapper = await mountSuspended(BaseInput, {
props: { label: 'Email', error: 'Required', modelValue: '' },
})
const input = wrapper.get('input')
const describedBy = input.attributes('aria-describedby')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.get(`#${describedBy}`).text()).toBe('Required')
})
it('emits submit with current value on Enter', async () => {
const wrapper = await mountSuspended(BaseInput, { props: { label: 'Q', modelValue: 'nuxt' } })
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('submit')?.[0]).toEqual(['nuxt'])
})
})// test/nuxt/products-page.test.ts — mocking imports and endpoints
import { describe, expect, it, vi } from 'vitest'
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import ProductsPage from '~/pages/products/index.vue'
const { useRouteMock } = vi.hoisted(() => ({
useRouteMock: vi.fn(() => ({ query: { page: '1' }, params: {}, path: '/products' })),
}))
mockNuxtImport('useRoute', () => useRouteMock)
registerEndpoint('/api/products', () => ({
items: [{ id: 'p1', title: 'Keyboard', priceCents: 9900 }],
page: 1,
total: 1,
}))
describe('products page', () => {
it('renders products from API', async () => {
const wrapper = await mountSuspended(ProductsPage)
expect(wrapper.text()).toContain('Keyboard')
})
})// test/nuxt/cart.test.ts — composable test
import { describe, expect, it, beforeEach } from 'vitest'
describe('useCart', () => {
beforeEach(() => clearNuxtState('cart:lines'))
it('merges quantities for the same SKU', () => {
const cart = useCart()
cart.add({ sku: 'A', qty: 1, priceCents: 500 })
cart.add({ sku: 'A', qty: 2, priceCents: 500 })
expect(cart.count.value).toBe(3)
expect(cart.totalCents.value).toBe(1500)
})
})// test/e2e/ssr.test.ts — SSR HTML & status
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e'
describe('SSR', async () => {
await setup({ rootDir: fileURLToPath(new URL('../..', import.meta.url)) })
it('renders blog post title in HTML', async () => {
const html = await $fetch<string>('/blog/hello-world')
expect(html).toContain('<h1')
expect(html).toContain('Hello World')
})
it('returns real 404 status', async () => {
const res = await fetch('/blog/does-not-exist')
expect(res.status).toBe(404)
})
})DO / DON'T
| DO | DON'T |
mountSuspended for async-setup components | mount() and wonder why auto-imports are undefined |
registerEndpoint for API mocks | Monkey-patch global fetch in every test |
One mockNuxtImport per import, at top level | Call mockNuxtImport inside it() (it's hoisted) |
| Assert HTTP status for error pages in e2e | Only test client behaviour of SEO-critical pages |
18. Nuxt UI v3 & Styling (Tailwind CSS v4)
Rules
- Version gate: confirm
@nuxt/uimajor. v3 = Tailwind v4 + Reka UI (Vue port of Radix primitives) + Tailwind Variants. v4 (2025) merged Nuxt UI Pro components into the free package — check component names and slots before use. Don't copy v2 (@nuxt/ui@2, Tailwind v3,ui: { primary: 'green' }in app.config) patterns into v3+. - Setup in v3: add
'@nuxt/ui'tomodules, a CSS entry importing Tailwind and Nuxt UI, and wrap the app in<UApp>(required for toasts, tooltips, overlays). - Theming: semantic colours (
primary,secondary,success,info,warning,error,neutral) configured inapp.config.ts→ui.colors. Custom palettes/tokens defined via Tailwind v4@themein CSS. - Customise components via
app.config.tsui.<component>(global slots/variants) or theuiprop (per instance) andclass. Don't override internal class names with deep selectors. - Use Nuxt UI's CSS variables (
--ui-primary,--ui-bg,--ui-text-muted,--ui-border,--ui-radius…) and utilities (bg-default,bg-elevated,text-muted,text-highlighted,border-default) instead of raw palette colours, so dark mode works automatically. - Dark mode: Nuxt UI integrates
@nuxtjs/color-mode— useuseColorMode()and<UColorModeButton>/<UColorModeSwitch>; the class strategy avoids flash of wrong theme on SSR. For SSG/ISR the preference is applied pre-hydration by the color-mode script. - Forms:
<UForm :schema :state @submit>with Standard Schema (zod, valibot, yup, arktype). Reuse schemas fromshared/utils. Server must validate again. - Overlays:
<UModal>,<USlideover>,<UDrawer>; programmatic viauseOverlay(). Toasts viauseToast(). - Icons: Nuxt UI uses
@nuxt/iconwith Iconify (i-lucide-*by default). Install local icon collections (@iconify-json/lucide) for SSR/offline reliability instead of fetching from the Iconify API at runtime. - Tailwind v4 is CSS-first: no
tailwind.config.jsby default. Use@theme,@source(for content outside scanned paths, e.g. layers/markdown),@variant,@utilityin CSS. - Never construct class names dynamically (
bg-${color}-500) — use maps of full static strings or componentcolorprops. - Accessibility comes from Reka UI primitives only if you use them correctly: provide
aria-labelfor icon-only buttons, titles/descriptions for modals, labels for form fields (<UFormField label>).
Setup
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
ui: {
fonts: true, // uses @nuxt/fonts
colorMode: true, // integrates @nuxtjs/color-mode
theme: { colors: ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] },
},
icon: {
serverBundle: { collections: ['lucide', 'simple-icons'] },
},
})/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";
/* Content outside default scanning (e.g. markdown, a layer) */
@source "../../../content/**/*.md";
@theme static {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Custom brand palette usable as a Nuxt UI color */
--color-brand-50: #eef7ff;
--color-brand-100: #d9edff;
--color-brand-200: #bce0ff;
--color-brand-300: #8eccff;
--color-brand-400: #59afff;
--color-brand-500: #338dfc;
--color-brand-600: #1d6ef1;
--color-brand-700: #1557de;
--color-brand-800: #1847b4;
--color-brand-900: #1a3f8e;
--color-brand-950: #152856;
}
:root {
--ui-radius: 0.5rem;
}
.dark {
--ui-bg: var(--ui-color-neutral-950);
}// app/app.config.ts
export default defineAppConfig({
ui: {
colors: {
primary: 'brand',
neutral: 'zinc',
},
button: {
slots: { base: 'font-semibold' },
defaultVariants: { size: 'md' },
},
card: {
slots: { root: 'rounded-xl', body: 'p-6 sm:p-8' },
},
},
})Examples
<!-- Typed form with shared schema -->
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(12, 'At least 12 characters'),
})
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({ email: undefined, password: undefined })
const toast = useToast()
const loading = ref(false)
async function onSubmit(event: FormSubmitEvent<Schema>) {
loading.value = true
try {
await $fetch('/api/auth/login', { method: 'POST', body: event.data })
await navigateTo('/app')
}
catch (e) {
const err = e as import('ofetch').FetchError<{ message?: string }>
toast.add({ title: 'Sign-in failed', description: err.data?.message ?? 'Try again', color: 'error' })
}
finally {
loading.value = false
}
}
</script>
<template>
<UCard class="mx-auto max-w-sm">
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Email" name="email" required>
<UInput v-model="state.email" type="email" autocomplete="email" class="w-full" />
</UFormField>
<UFormField label="Password" name="password" required>
<UInput v-model="state.password" type="password" autocomplete="current-password" class="w-full" />
</UFormField>
<UButton type="submit" block :loading="loading" label="Sign in" />
</UForm>
</UCard>
</template><!-- Programmatic modal with useOverlay -->
<script setup lang="ts">
import { ConfirmModal } from '#components'
const overlay = useOverlay()
const modal = overlay.create(ConfirmModal)
async function remove(id: string) {
const instance = modal.open({ title: 'Delete project?', description: 'This cannot be undone.' })
if (!(await instance.result)) return
await $fetch(`/api/projects/${id}`, { method: 'DELETE' })
}
</script><!-- app/components/ConfirmModal.vue -->
<script setup lang="ts">
defineProps<{ title: string; description: string }>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
</script>
<template>
<UModal :title="title" :description="description" :close="false">
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="ghost" label="Cancel" @click="emit('close', false)" />
<UButton color="error" label="Delete" @click="emit('close', true)" />
</div>
</template>
</UModal>
</template><!-- Status → color map with static strings (no dynamic classes) -->
<script setup lang="ts">
type Status = 'paid' | 'open' | 'failed'
const { status } = defineProps<{ status: Status }>()
const colorByStatus = {
paid: 'success',
open: 'warning',
failed: 'error',
} as const satisfies Record<Status, 'success' | 'warning' | 'error'>
</script>
<template>
<UBadge :color="colorByStatus[status]" variant="subtle" :label="status" class="capitalize" />
</template><!-- Dark mode toggle -->
<script setup lang="ts">
const colorMode = useColorMode()
const isDark = computed({
get: () => colorMode.value === 'dark',
set: v => { colorMode.preference = v ? 'dark' : 'light' },
})
</script>
<template>
<ClientOnly>
<UButton
:icon="isDark ? 'i-lucide-moon' : 'i-lucide-sun'"
color="neutral"
variant="ghost"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="isDark = !isDark"
/>
<template #fallback><div class="size-8" /></template>
</ClientOnly>
</template>DO / DON'T
<!-- DON'T -->
<div :class="`bg-${color}-500`" /> <!-- ❌ purged in production -->
<div class="bg-white text-gray-900" /> <!-- ❌ breaks dark mode -->
<UButton icon="i-lucide-trash" @click="del" /> <!-- ❌ no accessible name -->
<!-- DO -->
<UBadge :color="color" /> <!-- ✅ semantic prop -->
<div class="bg-default text-highlighted" /> <!-- ✅ theme-aware utilities -->
<UButton icon="i-lucide-trash" aria-label="Delete item" @click="del" />19. Anti-Patterns & Common Mistakes
Each item: symptom → cause → fix. Diagnose hydration mismatches from the browser console message (Vue prints the mismatching node in dev) — never "fix" them by wrapping everything in <ClientOnly>.
19.1 Hydration mismatches
| Symptom | Likely cause | Fix |
| "Hydration text content mismatch" on dates | Server TZ/locale ≠ browser | Fixed locale + timeZone, <NuxtTime>, or render relative time client-only |
| Mismatch on IDs | Math.random(), incrementing counters | useId() |
| Mismatch on auth-dependent UI | Session read from localStorage on client only | Cookie-based session readable in SSR, or <ClientOnly> with fallback |
| Mismatch on responsive UI | window.innerWidth / useMediaQuery in template | CSS breakpoints; or useMediaQuery + <ClientOnly>; or hydrate-on-media-query |
Mismatch on v-if="import.meta.client" | Different trees server vs client | Use onMounted flag (const mounted = ref(false)) or <ClientOnly> |
Invalid HTML nesting (<div> in <p>, <a> in <a>) | Browser re-parses HTML before hydration | Fix markup |
| Mismatch from browser extensions | Extensions mutating DOM | Ignore only after verifying in a clean profile |
<!-- DON'T -->
<template>
<span v-if="import.meta.client">{{ new Date().toLocaleTimeString() }}</span> <!-- ❌ tree differs -->
</template>
<!-- DO -->
<script setup lang="ts">
const now = ref<Date | null>(null)
onMounted(() => { now.value = new Date() })
</script>
<template>
<span>{{ now ? now.toLocaleTimeString('en-GB') : '—' }}</span>
</template>19.2 window / document during SSR
// DON'T — module scope access crashes the server build/runtime
const isTouch = 'ontouchstart' in window // ❌ ReferenceError on server
// DON'T — importing browser-only libraries at top level of an SSR component
import L from 'leaflet' // ❌ touches window on import
// DO — dynamic import inside client lifecycle, or a .client component
onMounted(async () => {
const L = (await import('leaflet')).default
L.map(mapEl.value!).setView([51.5, -0.09], 13)
})19.3 process.client / process.server anti-patterns
// DON'T — deprecated globals (Nuxt 2 heritage), not tree-shaken reliably, wrong in some runtimes
if (process.client) { /* … */ } // ❌
if (process.server) { /* … */ } // ❌
// DO — Vite-native, statically replaced and tree-shaken
if (import.meta.client) { /* … */ } // ✅
if (import.meta.server) { /* … */ } // ✅
if (import.meta.dev) { /* … */ } // ✅Also don't use import.meta.client checks in templates or render output to branch markup — that produces different trees (see 19.1).
19.4 Data-fetching mistakes
// DON'T — $fetch in setup: runs on server AND again on client (double request, no payload reuse)
const products = ref(await $fetch('/api/products')) // ❌
// DO
const { data: products } = await useFetch('/api/products') // ✅// DON'T — useFetch inside event handlers (it's a setup composable)
async function onSave() {
const { data } = await useFetch('/api/save', { method: 'POST', body }) // ❌ cache pollution, warnings
}
// DO — $fetch for user-triggered mutations, then refresh affected data
async function onSave() {
await $fetch('/api/save', { method: 'POST', body })
await refreshNuxtData('products') // ✅
}// DON'T — same key, different handlers/options (Nuxt 4 shares refs per key)
useAsyncData('user', () => $fetch('/api/me'))
useAsyncData('user', () => $fetch('/api/users/1'), { pick: ['name'] }) // ❌ conflicting
// DO — unique keys per data identity
useAsyncData('me', () => $fetch('/api/me'))
useAsyncData('user:1', () => $fetch('/api/users/1'))// DON'T — non-reactive URL built once
const { data } = useFetch(`/api/items?page=${page.value}`) // ❌ never refetches
// DO
const { data } = useFetch('/api/items', { query: { page } }) // ✅ reactive19.5 State leaks and secrets
// DON'T
export const cache = new Map<string, User>() // ❌ app/ module scope → shared across SSR requests
runtimeConfig: { public: { stripeSecret: '' } } // ❌ exposed to client
useState('token', () => event.context.secret) // ❌ serialised into HTML payload
// DO
useState<User | null>('auth:user', () => null) // per-request state
runtimeConfig: { stripeSecret: '' } // private, server-only19.6 Middleware & navigation
// DON'T — router.push in middleware, or forgetting to return
export default defineNuxtRouteMiddleware((to) => {
if (!loggedIn.value) useRouter().push('/login') // ❌ navigation continues
})
// DO
export default defineNuxtRouteMiddleware((to) => {
if (!loggedIn.value) return navigateTo('/login') // ✅
})// DON'T — infinite redirect loop
export default defineNuxtRouteMiddleware((to) => {
if (!loggedIn.value) return navigateTo('/login') // ❌ also runs for /login (global)
})
// DO
export default defineNuxtRouteMiddleware((to) => {
if (to.path === '/login') return
if (!loggedIn.value) return navigateTo('/login')
})19.7 Other frequent mistakes
- Using
<a href>for internal links → full reloads. Use<NuxtLink>. - Putting large JSON in
public/and importing it → useserver/assets+useStorage('assets:server')or a server route. - Using
v-htmlwith untrusted content → XSS. Sanitize (DOMPurify on client,sanitize-htmlon server) or render via Nuxt Content/MDC. - Awaiting non-critical fetches sequentially in
app.vue→ slow TTFB on every page. - Wrapping half the app in
<ClientOnly>→ loses SSR benefits, causes CLS. Fix the root cause. - Adding
@ts-ignore/as anyto silence generated-type errors → regenerate types (nuxi prepare) and fix the actual mismatch. - Editing files in
.nuxt/or.output/→ they're regenerated; changes are lost. - Using
definePageMetawith runtime variables → it's a compiler macro; values must be statically analysable. - Calling
navigateTowithoutreturn/awaitin setup → code continues executing. - Multiple
<UApp>wrappers or none → overlays/toasts misbehave.
20. Verification Checklist
Run only scripts that exist in package.json; otherwise use the commands below directly. Report exact results (pass/fail, error counts). If something could not be run, say so.
20.1 Before every commit
# 1. Types regenerated & checked (needs vue-tsc + typescript as devDeps)
npx nuxi prepare
npx nuxi typecheck
# 2. Lint (with @nuxt/eslint flat config)
npx eslint .
# 3. Unit + nuxt-environment tests
npx vitest run
# 4. Production build for the actual target
npx nuxi build # SSR / Hybrid / server-preset SPA
npx nuxi generate # SSG / static SPA
# 5. Smoke-test the production output
node .output/server/index.mjs # server presets
npx serve .output/public # static output// eslint.config.mjs — @nuxt/eslint flat config
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
{
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
'@typescript-eslint/no-explicit-any': 'error',
},
},
)20.2 Checklist
Correctness
nuxi typecheckpasses with zero errors; no newany,@ts-ignore,!without justification.- Lint passes; no disabled rules added without a comment explaining why.
- Tests added/updated for changed behaviour; all pass.
- Production build succeeds for the real preset; no new build warnings ignored.
SSR / rendering
- No hydration warnings in the browser console on affected pages (dev and production build).
- No
window/document/localStorageaccess outside client-only paths. - Page data uses
useFetch/useAsyncDatawith stable keys; no double fetching (check Network tab after hydration). - Route rules match intent; cached routes contain no personalised HTML (check
cache-controlheaders). - SSG:
nuxi generatewithfailOnErrorpasses; expected files exist in.output/public. - SPA: deep links work with host rewrites; loading template present.
Security
- Every new server handler validates input and authorises access.
- No secrets in
runtimeConfig.public,app.config.ts,useStateor client code. - No
v-htmlwith untrusted content; no wildcard CORS on authenticated routes.
UX / quality
- Loading, empty, error and success states handled.
- Keyboard navigation and focus visible; icon-only buttons labelled; forms labelled.
- Light and dark mode checked when UI changed.
- SEO: title/description/canonical present on public pages; 404s return 404.
- Bundle impact checked (
nuxi analyze) when adding dependencies.
Diff hygiene
- No unrelated formatting/refactoring; user changes preserved.
- No debug
console.log, commented-out code or leftover TODOs. - Comments still accurate.
20.3 Definition of Done
Agreed scope implemented; applicable states handled; architecture and conventions preserved; checks executed with real results reported, or limitations explicitly stated. Never declare "production-ready" without evidence.
20.4 Report format
Before a substantial change: facts found → uncertainties → decision → verification plan. After: what changed and why → checks run with results → what was not verified.
21. Tips & Tricks
21.1 Devtools
@nuxt/devtools(Shift+Alt+D): inspect Payload (what is serialised into HTML), Imports (what auto-imports resolve to), Components (lazy/island/client status), Server Routes (call handlers with params), Render Tree, Timeline, Hooks. Check the payload tab whenever SSR HTML feels heavy.nuxi analyzefor bundle visualisation;nuxi infowhen reporting issues.
21.2 Layers & extends
Share a design system, auth or base config across apps with layers. Layer files (components, composables, pages, server, config) merge into the consuming app; the app overrides the layer.
// nuxt.config.ts
export default defineNuxtConfig({
extends: [
'../packages/ui-layer', // local monorepo layer
['github:acme/nuxt-base#v2.1.0', { install: true }], // pinned remote layer
],
})packages/ui-layer/
├── nuxt.config.ts # modules, css, app config defaults
├── app/components/ # shared components
├── app/composables/
└── server/utils/Rules: pin remote layers to a tag/commit; remember Tailwind @source for layer component paths; resolve aliases within a layer with relative paths or #layers/<name> aliases if available in your version.
21.3 callOnce
// Runs once during SSR (not again on hydration); once on first client navigation in SPA
await callOnce('site-settings', async () => {
const settings = useSiteSettings()
settings.value = await $fetch('/api/settings')
})
// Re-run on every navigation (≥ 3.15)
await callOnce('analytics-pageview', () => trackPageview(), { mode: 'navigation' })21.4 Data cache control
// Refresh specific keys after a mutation
await refreshNuxtData(['products', `product:${id}`])
// Drop cached data (e.g. on logout) so the next visit refetches
clearNuxtData() // all
clearNuxtData(key => key.startsWith('user:'))
// Clear useState entries
clearNuxtState(['auth:user', 'cart:lines'])
// Custom cache reuse across navigations (Nuxt 4 signature with ctx.cause)
const { data } = await useFetch('/api/categories', {
key: 'categories',
getCachedData(key, nuxtApp, ctx) {
if (ctx.cause === 'refresh:manual') return undefined // force refetch on manual refresh
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
},
})21.5 <NuxtLink> tricks
<template>
<!-- Prefetch only on hover/focus for large lists -->
<NuxtLink v-for="p in products" :key="p.id" :to="`/products/${p.slug}`" prefetch-on="interaction">
{{ p.title }}
</NuxtLink>
<!-- External link with safe defaults (adds rel="noopener noreferrer") -->
<NuxtLink to="https://nuxt.com" external target="_blank">Nuxt</NuxtLink>
<!-- Disable prefetch for heavy admin routes -->
<NuxtLink to="/admin/reports" :prefetch="false">Reports</NuxtLink>
</template>21.6 Nuxt 4 hooks worth knowing
// app/plugins/hooks.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:suspense:resolve', () => { /* initial render complete (client) */ })
nuxtApp.hook('page:finish', () => { /* page suspense resolved — e.g. scroll restoration tweaks */ })
nuxtApp.hook('page:loading:start', () => { /* custom loading indicator */ })
nuxtApp.hook('page:loading:end', () => {})
nuxtApp.hook('app:chunkError', ({ error }) => { console.warn('chunk error', error) })
})<!-- useRuntimeHook (≥3.14): auto-unregisters when the component unmounts -->
<script setup lang="ts">
useRuntimeHook('page:finish', () => {
document.querySelector('main')?.focus() // move focus for a11y after navigation
})
</script>// server/plugins/html.ts — Nitro render hook
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', (html) => {
html.head.push('<meta name="generator" content="Acme">')
})
})// nuxt.config.ts — build-time hooks
export default defineNuxtConfig({
hooks: {
'pages:extend'(pages) {
pages.push({ name: 'legacy-home', path: '/home', file: '~/pages/index.vue' })
},
},
})21.7 onPrehydrate for pre-hydration DOM tweaks
<script setup lang="ts">
// Runs in the browser before hydration; the callback is stringified → no closures over setup vars
onPrehydrate(() => {
const saved = localStorage.getItem('sidebar')
if (saved === 'collapsed') document.documentElement.dataset.sidebar = 'collapsed'
})
</script>21.8 Nuxt Content v3 essentials
// content.config.ts
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: 'blog/**/*.md',
schema: z.object({
date: z.string(),
author: z.string(),
image: z.string().optional(),
tags: z.array(z.string()).default([]),
related: z.array(z.string()).optional(),
}),
}),
},
})// Typed queries
const { data: posts } = await useAsyncData('blog:list', () =>
queryCollection('blog')
.where('date', '<=', new Date().toISOString())
.order('date', 'DESC')
.select('title', 'path', 'description', 'date')
.limit(10)
.all(),
)Note: Content v3 stores content in a SQL database (SQLite locally, D1/Postgres/etc. in serverless). Check content.database for the deployment target.
21.9 i18n (@nuxtjs/i18n v9+)
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
baseUrl: 'https://example.com',
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' },
{ code: 'ru', language: 'ru-RU', file: 'ru.json', name: 'Русский' },
],
detectBrowserLanguage: { useCookie: true, cookieKey: 'i18n_locale', redirectOn: 'root' },
},
})<script setup lang="ts">
const { t, locale } = useI18n()
const localePath = useLocalePath()
const switchLocalePath = useSwitchLocalePath()
</script>
<template>
<NuxtLink :to="localePath('/pricing')">{{ t('nav.pricing') }}</NuxtLink>
<NuxtLink :to="switchLocalePath(locale === 'en' ? 'ru' : 'en')">{{ t('nav.switch') }}</NuxtLink>
</template>Rules: always build internal links via localePath; locale files live in i18n/locales/ (v9 layout); include locale in useAsyncData keys and watch for locale-dependent data.
21.10 VueUse in Nuxt
@vueuse/nuxtauto-imports most VueUse functions; SSR-unsafe ones are no-ops on the server but their return values still differ server vs client — render dependent markup client-only.- Prefer
useCookieoveruseLocalStoragefor state needed in SSR. - Useful:
useEventListener,useIntersectionObserver,useDebounceFn,refDebounced,useIntervalFn,useVirtualList,useClipboard,useBreakpoints(with<ClientOnly>),onClickOutside,useFocusTrap(via@vueuse/integrations).
// Debounced search bound to route query (SSR-safe: initial value from route)
const route = useRoute()
const router = useRouter()
const q = ref(String(route.query.q ?? ''))
const debouncedQ = refDebounced(q, 300)
watch(debouncedQ, (value) => {
router.replace({ query: { ...route.query, q: value || undefined } })
})
const { data: results } = await useFetch('/api/search', {
query: { q: debouncedQ },
key: () => `search:${debouncedQ.value}`,
})21.11 Runtime config vs app config
runtimeConfig | app.config.ts | |
| Purpose | Env-dependent values, secrets (private) | Build-time public config, theme, feature toggles |
| Overridable by env at runtime | Yes (NUXT_*) — except static output | No |
| Reactive in app | No | Yes (useAppConfig(), HMR) |
| Secrets allowed | Private keys only | Never |
21.12 Miscellaneous
definePageMeta({ key: route => route.fullPath })forces page re-creation on param change when the component isn't reactive to params.<NuxtLoadingIndicator />inapp.vuegives a navigation progress bar with zero config.useRequestURL()gives the correct origin behind proxies (respectsx-forwarded-*when configured) — use it instead ofwindow.locationfor isomorphic code.nuxi cleanupremoves.nuxt,.output, caches — first step when "types are weird".nuxi upgrade --dedupewhen upgrading Nuxt to dedupe lockfile entries (only when an upgrade is requested).- Use
server/assets/+useStorage('assets:server')to ship read-only data files with the server bundle. experimental.typedPagesgives typeduseRoute('name')/navigateTo({ name })via unplugin-vue-router — enable only if the team agrees; it changes route typing across the app.defineNuxtComponentis for Options API withasyncData; don't use it in new<script setup>code.- Use
useHead({ bodyAttrs: { class: 'overflow-hidden' } })from a modal instead of touchingdocument.bodydirectly. - In monorepos, set
typescript.tsConfig.include/ Tailwind@sourcefor shared packages; verify types vianuxi typecheckfrom the app root.
Core Principle
Nuxt gives you a server, a client and a cache in one codebase. Every decision — where code runs, when data is fetched, what is cached and for whom — must be explicit and verified. Prefer the framework's primitives over hand-rolled solutions, keep the rendering mode in mind for every line you write, and never report success that you haven't measured.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

