agentleFS
techbuzzztechbuzzz/awesome-opencode/skills/nuxt-dev/SKILL.md

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

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
  1. Distinctive:

    Treat Nuxt as a full-stack framework with two runtimes: the Vue app (server render + client hydration) and Nitro (server routes, …

    line 18
  2. Distinctive:

    Never invent APIs, module options, composables, props, config keys, server endpoints, env variables, DB tables or business rules.

    line 19
  3. Distinctive:

    Do not upgrade Nuxt, Vue, Nitro or modules unless the task asks for it.

    line 20
  4. Distinctive:

    Ask short, specific questions only when an unknown blocks correctness, security, a public contract or architecture.

    line 23
  5. Distinctive:

    Never claim that code was type-checked, tested, built or visually verified unless you actually ran the check and saw the result.

    line 24
  6. Distinctive:

    Before changing anything, establish facts.

    line 40
  7. Very specific:

    Inspect generated types: run npx nuxi prepare (or the project's postinstall) and look at .nuxt/tsconfig*.json, .nuxt/imports.d.ts, …

    line 48
  8. Very specific:

    Check modules' options: before using module APIs (e.g.

    line 50
  9. Distinctive:

    Before using any of these, confirm the installed version supports them:

    line 77
  10. Distinctive:

    If documentation is unavailable, rely on the installed types and say what you could not verify.

    line 87
  11. Very specific:

    Composables auto-import scanning is shallow: only top-level files in app/composables/ and index.ts re-exports of nested folders.

    line 154
  12. Very specific:

    Always use <script setup lang="ts">.

    line 301
  13. Very specific:

    Emits: use the named-tuple syntax defineEmits<{ change: [value: string].

    line 304
  14. Very specific:

    v-model: use defineModel<T>(). For required models use defineModel<T>({ required: true }).

    line 305
  15. Very specific:

    Template refs: use useTemplateRef<T>('key') (Vue 3.5).

    line 306
  16. Very specific:

    Provide/inject: use InjectionKey<T> symbols, never string keys in new code.

    line 310
  17. Very specific:

    IDs: use useId() for accessible id pairs — it is SSR-stable.

    line 311
  18. Very specific:

    Computed: must be pure. Use writable computed (get/set) for derived two-way bindings rather than watchers syncing two refs.

    line 313
  19. Very specific:

    Watchers: prefer watch with explicit sources over watchEffect when side effects are expensive.

    line 314
  20. Very specific:

    Do not hand-type server responses: when calling internal routes: $fetch('/api/users') and useFetch('/api/users') infer the return type from …

    line 580
  21. Very specific:

    Every useAsyncData must have a stable, unique, explicit key when the handler is not trivially derivable, and the key must change when …

    line 581
  22. Very specific:

    Use pick / transform to minimise payload.

    line 583
  23. Very specific:

    Use default: () => value when the template should never see undefined.

    line 584
  24. Very specific:

    Use useRuntimeConfig() types as generated.

    line 587
  25. Very specific:

    Use NuxtError and H3Error types instead of any in catch blocks.

    line 588
  26. Distinctive:

    Do not create composables for one-off logic used once.

    line 801
  27. Very specific:

    Use @pinia/nuxt. Stores live in app/stores/ (auto-imported).

    line 937
  28. Very specific:

    Use storeToRefs(store) to destructure state/getters.

    line 941
  29. Very specific:

    Use callOnce to initialise global store data once per SSR request and avoid re-fetching on hydration.

    line 943
  30. Very specific:

    Use Pinia for cross-page/client-long-lived domain state (session, cart, preferences, editors).

    line 944
  31. Very specific:

    Add acceptHMRUpdate for HMR in dev.

    line 945
  32. Distinctive:

    Never reach into another store's internal refs from outside actions in ways that bypass actions for invariants.

    line 946
  33. Very specific:

    Every file in server/api, server/routes and every server middleware is a public HTTP entry point.

    line 1060
  34. Very specific:

    Put reusable server logic in server/utils/ (auto-imported in server runtime only).

    line 1069
  35. Very specific:

    Use useRuntimeConfig(event) inside handlers (allows per-request env overrides on edge platforms).

    line 1071
  36. Very specific:

    Use defineCachedEventHandler / defineCachedFunction for expensive, shareable reads.

    line 1072
  37. Very specific:

    Use useStorage() (unstorage) for KV-like needs.

    line 1073
  38. Distinctive:

    What it is: every request is rendered on the server (Nitro), HTML is sent with a serialised payload, the client hydrates.

    line 1247
  39. Very specific:

    Every render must be deterministic between server and client: no Date.now(), Math.random(), window checks, locale-dependent formatting …

    line 1253
  40. Very specific:

    Use <ClientOnly> with a #fallback matching the final layout size for client-only widgets (maps, charts, editors) to avoid CLS.

    line 1254
  41. Very specific:

    Use onServerPrefetch only in non-<script setup> contexts.

    line 1256
  42. Very specific:

    Keep payload small: pick, transform, avoid sending large unused objects.

    line 1258
  43. Very specific:

    Use lazy: true (or useLazyFetch) for non-critical data below the fold so SSR is not blocked.

    line 1259
  44. Very specific:

    Use onPrehydrate for tiny DOM fixes that must run before hydration (e.g., applying a stored preference class) — keep them free of closures …

    line 1261
  45. Very specific:

    Keep experimental.payloadExtraction enabled (default for prerendered pages) so client-side navigation loads _payload.json instead of …

    line 1369
  46. Very specific:

    Use defineRouteRules() in page components only if experimental.inlineRouteRules is enabled in the project.

    line 1609
  47. Very specific:

    Test each rule after build: inspect response headers (cache-control, x-nitro-*, age) and verify prerendered files exist in .output/public.

    line 1611
  48. Distinctive:

    Use explicit imports: when:

    line 1721
  49. Very specific:

    Use #imports for explicit imports of Nuxt auto-imports when necessary (import { useRoute } from '#imports') — e.g., in tests or files …

    line 1727
  50. Very specific:

    Use #components for explicit component imports (import { LazyChart } from '#components'), e.g.

    line 1728
  51. Very specific:

    Do not register components globally (components: { global: true } or .global.vue) without need — global components cannot be tree-shaken …

    line 1730
  52. Very specific:

    Use imports.dirs / components.dirs config to extend scanning instead of deep relative imports.

    line 1731
  53. Very specific:

    When adding a composable, run nuxi prepare (or let dev server regenerate) before trusting editor errors.

    line 1734
  54. Very specific:

    <NuxtLink> prefetching: default prefetch on visibility.

    line 1813
  55. Very specific:

    Payload: use pick/transform; avoid returning large blobs into SSR payload.

    line 1814
  56. Very specific:

    Bundle hygiene: import only what you use (date-fns functions, not moment).

    line 1818
  57. Very specific:

    Chunking: don't hand-tune vite.build.rollupOptions.output.manualChunks unless analysis shows a concrete problem.

    line 1819
  58. Very specific:

    Avoid waterfalls: run independent fetches in parallel (Promise.all inside one useAsyncData, or multiple useFetch without sequential awaits …

    line 1821
  59. Very specific:

    Use useSeoMeta for standard meta tags (typed, XSS-safe, de-duplicated).

    line 1922
  60. Very specific:

    Use useServerSeoMeta for meta that never changes on the client (saves client JS).

    line 1930
  61. Very specific:

    Use showError() to trigger the full-screen error from client code (e.g.

    line 2039
  62. Very specific:

    Do not swallow errors (catch {}) — at minimum log and surface a user-friendly message.

    line 2041
  63. Very specific:

    Use mountSuspended (async setup, auto-imports, plugins) instead of raw mount for Nuxt components.

    line 2169
  64. Distinctive:

    Test behaviour, not implementation: user-visible output, emitted events, calls to endpoints — not internal refs.

    line 2171
  65. Very specific:

    Test server handlers by calling them through $fetch in e2e or by unit-testing extracted server/utils functions with plain Vitest.

    line 2172
  66. Distinctive:

    Do not update snapshots blindly.

    line 2175
  67. Very specific:

    Keep tests deterministic: fake timers for time-based code, fixed locale/timezone (TZ=UTC), seeded data.

    line 2176
  68. Very specific:

    Run only real scripts from package.json (test, test:unit, test:e2e).

    line 2177
  69. Very specific:

    Use Nuxt UI's CSS variables (--ui-primary, --ui-bg, --ui-text-muted, --ui-border, --ui-radius…) and utilities (bg-default, bg-elevated, …

    line 2336

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

  1. 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.
  2. 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.
  3. 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.
  4. Do not upgrade Nuxt, Vue, Nitro or modules unless the task asks for it. Propose security upgrades separately, with justification.
  5. Deliver complete solutions: no empty handlers, fake data, hidden TODOs or any-escapes in place of a real implementation unless explicitly requested.
  6. Preserve existing comments, architecture and user changes. Do not reformat unrelated files.
  7. Ask short, specific questions only when an unknown blocks correctness, security, a public contract or architecture. Otherwise state assumptions and proceed.
  8. 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

DODON'T
Decide for every piece of code: server-only, client-only or isomorphicWrite 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 duplicatesHand-copy response interfaces that drift from server handlers
Keep secrets in private runtimeConfig and server codePut tokens in runtimeConfig.public, app.config.ts or client bundles
Report real verification resultsWrite "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

  1. Read manifests: package.json (scripts, packageManager, engines), the lockfile (exact versions — a ^4.0.0 range is not a version), .nvmrc/.node-version.
  2. Read nuxt.config.ts completely: ssr, compatibilityDate, future.compatibilityVersion, srcDir, dir.*, modules, extends (layers), routeRules, nitro.preset, nitro.prerender, runtimeConfig, experimental.*, imports, components, typescript, vite, css.
  3. Read app.config.ts (Nuxt 4 default: app/app.config.ts) — UI theme tokens and public, reactive, build-time config live here.
  4. Detect directory layout: Nuxt 4 layout (app/, server/, shared/, public/ at root) vs. Nuxt 3 layout (everything at root) vs. custom srcDir. Never create pages/ at the root of a Nuxt 4 project that uses app/pages/.
  5. Inspect generated types: run npx nuxi prepare (or the project's postinstall) 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.
  6. Check layers: extends may 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.
  7. Check modules' options before using module APIs (e.g. @nuxtjs/i18n strategy, @nuxt/content collections, @nuxt/image provider, @pinia/nuxt storesDirs).
  8. Check deployment target: nitro.preset or the CI pipeline (Vercel, Netlify, Cloudflare Pages/Workers, Node server, static hosting). Edge presets forbid Node built-ins and long-lived in-memory state.
  9. Read neighbours: similar pages, composables, server handlers and their tests. Follow established conventions.
  10. Check git status / git diff to 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 typecheck

Version gate checklist

Before using any of these, confirm the installed version supports them:

  • Nuxt 4 data-fetching defaults (data is shallowRef, default value undefined, shared refs per key).
  • Lazy hydration props (hydrate-on-visible, hydrate-on-idle, …) — Nuxt ≥ 3.16.
  • callOnce (≥ 3.9) and callOnce(..., { mode: 'navigation' }) (≥ 3.15).
  • onPrehydrate (≥ 3.12), useId from 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/i18n v9 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.json

Rules

  1. 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, no h3, no Node built-ins, no window).
  2. shared/ cannot import from app/ or server/. It is a leaf. Put DTOs, zod/valibot schemas, enums, pure formatters there.
  3. Never import server/** from app/**. Communicate via $fetch/useFetch. Types flow via Nitro's generated InternalApi or via shared/types.
  4. Composables auto-import scanning is shallow: only top-level files in app/composables/ and index.ts re-exports of nested folders. If you add app/composables/auth/useSession.ts, re-export it from app/composables/auth/index.ts or configure imports.dirs.
  5. Components are named by path: app/components/base/Button.vue → <BaseButton>. Avoid duplicated prefixes (base/BaseButton.vue is still <BaseButton> — Nuxt de-duplicates, but be consistent with the project).
  6. Use .client.vue / .server.vue suffixes for components that exist only in one runtime; prefer <ClientOnly> for small, local cases.
  7. Plugins run in alphabetical order unless enforce, order or dependsOn is set. Number prefixes (01.auth.ts) are acceptable but dependsOn is clearer.
  8. Middleware: app/middleware/*.ts = named route middleware (opt-in via definePageMeta), *.global.ts = every navigation; server/middleware/* = every HTTP request in Nitro. They are completely different things.
  9. 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

AreaNuxt 3 (default)Nuxt 4
srcDirproject rootapp/
useAsyncData/useFetch datadeep ref, default nullshallowRef, default undefined
Same key in multiple componentsindependent refs (warnings)shared refs; options must be consistent
getCachedDatacalled only on first fetchalso receives ctx.cause ('initial' | 'refresh:hook' | 'refresh:manual' | 'watch')
Data cleanupkept foreverdata purged when last component using the key unmounts
pendingtrue before first fetch even when immediate: falsepending mirrors status === 'pending'
TypeScriptsingle .nuxt/tsconfig.jsonseparate projects: app, server, shared, node (project references)
Component names in devtools/<KeepAlive>inferrednormalised to Nuxt's path-based name
unheadv1v2 (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

  1. Always use <script setup lang="ts">. Do not mix Options API into new code unless the file already uses it.
  2. Props: type-based defineProps<Props>(). Use Vue 3.5 reactive props destructure with native defaults instead of withDefaults in new code (const { size = 'md' } = defineProps<Props>()). Keep withDefaults if the codebase consistently uses it.
  3. 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).
  4. Emits: use the named-tuple syntax defineEmits<{ change: [value: string]; close: [] }>().
  5. v-model: use defineModel<T>(). For required models use defineModel<T>({ required: true }); for modifiers use the tuple form const [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>().
  6. Template refs: use useTemplateRef<T>('key') (Vue 3.5). For component refs, type with InstanceType<typeof Comp> or ComponentExposed from vue-component-type-helpers for generic components.
  7. Generic components: <script setup lang="ts" generic="T extends { id: string | number }">.
  8. Slots: type with defineSlots<{ default(props: { item: T }): any; empty(): any }>().
  9. Expose: expose only intentional API via defineExpose. Type consumers through the component instance type.
  10. Provide/inject: use InjectionKey<T> symbols, never string keys in new code; throw when a required injection is missing.
  11. IDs: use useId() for accessible id pairs — it is SSR-stable. Never use Math.random() or counters (hydration mismatch).
  12. Reactivity choice: ref for primitives and replaced objects, shallowRef for large immutable data (API payloads, chart datasets), reactive only for local object state you mutate in place and never destructure. Never destructure reactive() without toRefs.
  13. Computed must be pure. Use writable computed (get/set) for derived two-way bindings rather than watchers syncing two refs.
  14. Watchers: prefer watch with explicit sources over watchEffect when side effects are expensive. Use onWatcherCleanup (Vue 3.5) to abort stale requests. Use { once: true } instead of manual stop() for one-shot watchers.
  15. No any. Use unknown + narrowing, discriminated unions, satisfies for 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 — typeof needs 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 useFetch with a reactive query (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-stable

5. Nuxt TypeScript Patterns

Rules

  1. Do not hand-type server responses when calling internal routes: $fetch('/api/users') and useFetch('/api/users') infer the return type from the Nitro handler. Add a generic only for external APIs.
  2. Every useAsyncData must 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).
  3. In Nuxt 4, data defaults to undefined and is a shallowRef. Narrow with if (!data.value) and never mutate nested properties expecting reactivity — replace the value or use deep: true explicitly.
  4. Use pick / transform to minimise payload; types follow automatically.
  5. Use default: () => value when the template should never see undefined. The resulting type excludes undefined.
  6. Type plugin-provided helpers through the plugin return value (provide) — Nuxt augments NuxtApp automatically. Do not manually declare $foo on ComponentCustomProperties unless the plugin cannot return it.
  7. Augment PageMeta, RuntimeConfig, AppConfigInput, NuxtApp hooks via declare module '#app' / 'nuxt/schema' in a .d.ts under app/ or shared/types only when needed — and keep augmentations near their feature.
  8. Use useRuntimeConfig() types as generated; do not cast config values. Validate env-derived values at startup on the server.
  9. Use NuxtError and H3Error types instead of any in catch blocks; narrow with isNuxtError / error.statusCode.
  10. useNuxtApp() must be called synchronously in setup, plugins, middleware or Nuxt hooks. After an await in a non-setup context the Nuxt instance may be lost; use nuxtApp.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

  1. Naming: useXxx for composables that use Vue/Nuxt context; plain verbs (formatPrice, slugify) for pure helpers in utils/.
  2. Context: Nuxt composables (useState, useCookie, useRoute, useRuntimeConfig, useNuxtApp, useRequestEvent…) must be called synchronously during setup, in plugins, route middleware or defineNuxtComponent. Never call them in module scope, setTimeout, or after await inside a non-setup function.
  3. SSR safety — no module-level state. const users = ref([]) at module scope is shared across all requests on the server → cross-request data leak. Use useState (per-request, hydrated) or Pinia.
  4. 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.
  5. useState keys must be globally unique; prefix by feature ('cart:items').
  6. useCookie for state that must be readable during SSR (auth token presence, locale, theme). Set sameSite, secure, maxAge explicitly. Prefer httpOnly cookies set by the server for secrets — those are not readable via useCookie on the client by design.
  7. useRequestHeaders(['cookie']) to forward cookies when calling external APIs from SSR. Internal $fetch / useFetch to /api/* during SSR already forwards headers via useRequestFetch semantics — use useRequestFetch() if you call $fetch manually in SSR.
  8. Browser APIs (window, localStorage, matchMedia, IntersectionObserver) only inside onMounted, import.meta.client branches, .client plugins, or SSR-safe VueUse composables.
  9. Return refs (not reactive objects) from composables so consumers can destructure; return readonly() views for state the consumer must not mutate.
  10. Accept MaybeRefOrGetter<T> inputs and normalise with toValue(); this lets consumers pass values, refs or getters.
  11. Clean up listeners and timers via onScopeDispose (works in components and effect scopes) rather than only onUnmounted.
  12. 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

  1. Use @pinia/nuxt. Stores live in app/stores/ (auto-imported). Do not create stores with createPinia() manually.
  2. Prefer setup stores (defineStore('id', () => { … })) for new code — full TypeScript inference, composables usable inside. Keep option stores where the codebase uses them.
  3. In setup stores, return every piece of state (refs) — unreturned refs are not serialised for SSR and not visible in devtools.
  4. Store state must be serialisable for SSR hydration. Exclude non-serialisable or client-only values with skipHydrate().
  5. Use storeToRefs(store) to destructure state/getters; destructure actions directly from the store.
  6. 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 callOnce or useAsyncData.
  7. Use callOnce to initialise global store data once per SSR request and avoid re-fetching on hydration.
  8. Use Pinia for cross-page/client-long-lived domain state (session, cart, preferences, editors). Use useFetch cache for server data that is page-scoped. Use useState for tiny shared flags. Do not mirror every API response into Pinia.
  9. Add acceptHMRUpdate for HMR in dev.
  10. 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

  1. 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 over if (event.method === …) branches.
  2. Validate everything coming from the request: params (getValidatedRouterParams), query (getValidatedQuery), body (readValidatedBody). Use the schemas from shared/utils so client and server agree.
  3. 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).
  4. Throw createError({ statusCode, statusMessage, data }). Never leak stack traces, SQL errors or internal identifiers in statusMessage/data.
  5. 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).
  6. Put reusable server logic in server/utils/ (auto-imported in server runtime only). Keep handlers thin: parse → authorise → call service → map DTO.
  7. server/middleware/* runs for every request including assets and /_nuxt/* in some presets — keep it cheap, never return a response unless intentionally short-circuiting.
  8. Use useRuntimeConfig(event) inside handlers (allows per-request env overrides on edge platforms).
  9. Use defineCachedEventHandler / defineCachedFunction for expensive, shareable reads; never cache per-user responses without a user-specific getKey (or don't cache them at all).
  10. Use useStorage() (unstorage) for KV-like needs; configure drivers in nitro.storage per environment. Do not rely on in-memory globals for state in serverless/edge.
  11. On edge presets (Cloudflare, Vercel Edge), do not import Node built-ins (fs, crypto from node: without polyfill support, native drivers).
  12. Set security headers and CORS deliberately (routeRules.headers, routeRules.cors, or a vetted module like nuxt-security). Do not set Access-Control-Allow-Origin: * on authenticated APIs.
  13. For SSR-time calls to your own API, useFetch('/api/…') calls the handler directly in-process (no HTTP round trip). Do not hardcode http://localhost:3000 in 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 needed

9. 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

  1. ssr: true (default). Requires a server runtime (Node, serverless or edge preset) in production.
  2. Fetch page data with await useFetch / useAsyncData in <script setup> so it is fetched once on server and transferred via payload. Without await, the page renders on the server without data (unless lazy is intended).
  3. Every render must be deterministic between server and client: no Date.now(), Math.random(), window checks, locale-dependent formatting without fixed locale/timezone, or user-agent branching in the template.
  4. Use <ClientOnly> with a #fallback matching the final layout size for client-only widgets (maps, charts, editors) to avoid CLS.
  5. Forward request context: useRequestHeaders, useRequestEvent, useRequestURL, useRequestFetch. These return empty/undefined on the client — handle both.
  6. Use onServerPrefetch only in non-<script setup> contexts; in <script setup> top-level await is the idiom.
  7. Personalised SSR responses must not be CDN-cached. Ensure Cache-Control: private or no shared cache for authenticated pages (route rules or server middleware).
  8. Keep payload small: pick, transform, avoid sending large unused objects; never put secrets into useState/payload.
  9. Use lazy: true (or useLazyFetch) for non-critical data below the fold so SSR is not blocked; render a skeleton based on status.
  10. Timeouts: protect SSR from slow upstreams (timeout option of $fetch, AbortSignal.timeout) and degrade gracefully instead of hanging the whole page.
  11. Use onPrehydrate for 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)

DODON'T
await useFetch() for content that must be in HTMLonMounted(() => $fetch(...)) for SEO-critical content
Fixed locale + timezone for formatting in templatesnew Date().toLocaleString() in templates
Cache-Control: private on personalised routesPut personalised SSR pages behind a public CDN cache
Upstream timeouts + graceful fallbackLet 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

  1. Build with nuxt generate (= nuxt build --prerender with a static preset). Ensure the host serves index.html per directory and the 200.html/404.html fallbacks as required.
  2. All data must be available at build time. Anything fetched with useFetch during prerender is frozen into the payload; it will not update until the next build.
  3. 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.
  4. Make every route discoverable: the crawler (nitro.prerender.crawlLinks: true) follows <a href> / <NuxtLink> from seed routes. Add non-linked routes to nitro.prerender.routes or call prerenderRoutes() / the prerender:routes hook.
  5. Enable nitro.prerender.failOnError: true in CI so broken links and render errors fail the build.
  6. Keep experimental.payloadExtraction enabled (default for prerendered pages) so client-side navigation loads _payload.json instead of re-fetching APIs.
  7. 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.
  8. runtimeConfig values are baked at build time for static output. Runtime env variables on the static host have no effect.
  9. Dynamic routes (/blog/[slug]) need an explicit list (from CMS/content) at build time; Nuxt Content v3 routes are found by crawling when linked.
  10. 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
DODON'T
failOnError: true in CIShip a build with silently skipped pages
Treat runtimeConfig as build-timeExpect NUXT_PUBLIC_* on the static host to change behaviour
Seed non-linked routes explicitlyAssume the crawler finds routes behind JS-only navigation
Render auth state client-sidePrerender 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

  1. Set ssr: false globally, or prefer per-route routeRules: { '/app/**': { ssr: false } } inside an SSR app (section 12) when public pages need SEO.
  2. Provide app/spa-loading-template.html (or spaLoadingTemplate) for a branded loader instead of a blank page.
  3. useFetch / useAsyncData still work and provide status/caching, but run only on the client. await in <script setup> delays the component render until data resolves — use lazy: true with skeletons for better perceived performance.
  4. import.meta.server code paths never execute at runtime; useRequestHeaders, useRequestEvent return empty. Do not write SSR-only logic.
  5. 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).
  6. Auth: token handling is entirely client-side — prefer httpOnly session cookies set by your API over localStorage tokens (XSS exposure). Route middleware enforces UX, not security; the API must authorise.
  7. Static hosting: configure the host to rewrite unknown paths to 200.html/index.html (generated by nuxt generate with ssr: false).
  8. SEO tags via useSeoMeta are applied client-side only — crawlers that don't execute JS won't see them. Don't promise SEO for SPA routes.
  9. Bundle size matters more (nothing is rendered until JS loads): lazy-load heavy routes/components, audit with nuxi analyze.
  10. Nitro server routes still work if you deploy with a server preset (ssr: false only 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)

DODON'T
lazy: true + skeletonsBlock the whole app on several sequential awaits
httpOnly cookie sessionsStore long-lived JWTs in localStorage
robots: noindex for private SPARely on client-only useSeoMeta for public SEO
Configure SPA fallback rewrites on the hostDeploy 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)

RuleEffect
prerender: trueRendered at build time, served as static file
swr: number | trueServer-rendered, cached in Nitro storage; stale served while revalidating in background
isr: number | trueLike SWR but uses the platform CDN cache (Vercel/Netlify); true = until next deploy
ssr: falseClient-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: trueAdds permissive CORS headers — use only for public endpoints
noScripts: trueStrip Nuxt scripts (pure static HTML, no hydration)
appMiddleware: …Enable/disable named app middleware for routes

Rules

  1. Choose per route by freshness × personalisation × SEO:
    • Same for everyone, rarely changes → prerender.
    • Same for everyone, changes periodically → isr (CDN platforms) or swr (Nitro cache).
    • Personalised and SEO-relevant → SSR, no shared cache.
    • Personalised and SEO-irrelevant → ssr: false.
  2. Never apply swr/isr/cache to routes whose HTML depends on cookies/session. The first visitor's personalised HTML would be served to everyone.
  3. Cached SSR pages must render a user-agnostic shell; personalise on the client (<ClientOnly>, server: false fetches).
  4. Order of glob specificity matters: more specific patterns override general ones. Keep the rules table small, documented and testable.
  5. ISR invalidation: use platform features (Vercel bypassToken, on-demand revalidation) — configure via nitro.vercel.config or the platform's documented mechanism; don't invent invalidation endpoints.
  6. isr requires a supporting preset; on other presets it behaves like swr (Nitro storage) — verify for the target platform.
  7. Use defineRouteRules() in page components only if experimental.inlineRouteRules is enabled in the project.
  8. Apply matching caching for API routes the cached pages call (swr on /api/catalog/**), otherwise ISR pages still hammer the backend on revalidation.
  9. 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 typeExampleRulePersonal data
Landing/prerenderclient-only
Blog post/blog/xisr: 3600none
Catalogue/products/xswr: 600client-only
Checkout/checkoutSSR (no cache)server
Admin/app/**ssr: falseclient

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

  1. Rely on auto-imports for Vue APIs (ref, computed, watch…), Nuxt composables, your composables/, utils/, stores/ and components. This is the project convention; don't add redundant explicit imports for them.
  2. Use explicit imports when:
    • you need a value binding for types (typeof Comp, InstanceType<typeof Comp>),
    • importing types (import type { Ref } from 'vue') — types from vue are not auto-imported unless they're in shared/types,
    • importing from third-party libraries not registered via a module (zod, date-fns, @vueuse/core functions not exposed by @vueuse/nuxt),
    • code in shared/ that must be portable, or in libraries/layers published separately,
    • name collisions exist (two useX from different sources) — resolve via imports.presets or explicit import with alias.
  3. Use #imports for explicit imports of Nuxt auto-imports when necessary (import { useRoute } from '#imports') — e.g., in tests or files outside the scan.
  4. Use #components for explicit component imports (import { LazyChart } from '#components'), e.g. for dynamic <component :is> usage.
  5. Dynamic components: <component :is="'MyComp'"> with a string does not work for auto-registered components unless they're global. Use resolveComponent('MyComp') with a literal string, or import from #components.
  6. Do not register components globally (components: { global: true } or .global.vue) without need — global components cannot be tree-shaken per page.
  7. Use imports.dirs / components.dirs config to extend scanning instead of deep relative imports.
  8. Module order in modules matters for modules that hook each other (e.g., @nuxt/ui before modules depending on its Tailwind context). Check module docs.
  9. Server auto-imports (server/utils, Nitro/h3 helpers) are not available in app/ and vice versa. TypeScript projects in Nuxt 4 enforce this — don't silence the errors.
  10. 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

  1. Lazy components: prefix with Lazy (<LazyHeavyChart v-if="open" />) for components not needed on first render. Lazy without a condition only splits the chunk — it still loads immediately.
  2. 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.
  3. 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.
  4. <NuxtLink> prefetching: default prefetch on visibility. For long link lists, use prefetch-on="interaction" (or global experimental.defaults.nuxtLink.prefetchOn) to avoid flooding the network.
  5. Payload: use pick/transform; avoid returning large blobs into SSR payload. experimental.payloadExtraction for prerendered pages.
  6. Images: <NuxtImg> / <NuxtPicture> with explicit width/height (or sizes), 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.
  7. Fonts: @nuxt/fonts self-hosts and generates fallback metrics (reduces CLS). Don't add manual Google Fonts <link> tags alongside it.
  8. Third-party scripts: load via @nuxt/scripts (useScript, registry helpers such as useScriptGoogleAnalytics) with triggers (onNuxtReady, consent, interaction) instead of raw <script> tags in app.head.
  9. Bundle hygiene: import only what you use (date-fns functions, not moment); avoid barrel files that defeat tree-shaking; check nuxi analyze after adding a dependency.
  10. Chunking: don't hand-tune vite.build.rollupOptions.output.manualChunks unless analysis shows a concrete problem; Nuxt/Vite defaults are usually better. If you must, group stable vendor libs only.
  11. Reactivity cost: shallowRef/markRaw for large immutable data (charts, maps, editor instances); v-memo / v-once for expensive static subtrees; virtualise long lists (useVirtualList from VueUse or a vetted library).
  12. Avoid waterfalls: run independent fetches in parallel (Promise.all inside one useAsyncData, or multiple useFetch without sequential awaits when they don't depend on each other).
  13. <KeepAlive> via <NuxtPage :keepalive="{ max: 5 }"> or definePageMeta({ keepalive: true }) for expensive tab-like pages — bounded with max.

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

DODON'T
LazyX + v-if / hydrate-on-* for heavy widgetsImport a 300 KB chart lib eagerly in the layout
shallowRef for chart datasetsDeep-reactive 10k-row arrays
<NuxtImg> with dimensions<img> without width/height (CLS)
@nuxt/scripts with triggersapp.head.script with blocking third-party tags
Measure before/after with nuxi analyzeHand-tune manualChunks by intuition

15. SEO & Meta

Rules

  1. Use useSeoMeta for standard meta tags (typed, XSS-safe, de-duplicated). Use useHead for link, script, htmlAttrs, bodyAttrs and anything useSeoMeta doesn't cover.
  2. Set defaults once (in app.vue or a layout) with titleTemplate; override per page. Do not duplicate <title> logic across components.
  3. Pass reactive getters when values depend on async data: useSeoMeta({ title: () => post.value?.title }).
  4. Meta must be resolvable during SSR for SEO-relevant pages: fetch data with await useFetch before calling useSeoMeta, or use getters.
  5. Provide canonical URLs (link rel="canonical") built from runtimeConfig.public.siteUrl + path; strip tracking query params.
  6. OG images: use nuxt-og-image if the project has it, or static images with absolute URLs. OG og:image must be an absolute URL.
  7. Structured data (JSON-LD) via useHead({ script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(...) }] }) or nuxt-schema-org if installed. Never inject unescaped user content into innerHTML for non-JSON scripts.
  8. i18n: use useLocaleHead() from @nuxtjs/i18n to emit hreflang, og:locale and lang attributes; set baseUrl in i18n config.
  9. Use useServerSeoMeta for meta that never changes on the client (saves client JS).
  10. noindex for private, preview, search-result and duplicate pages. Don't block CSS/JS in robots.txt.
  11. 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

  1. Two error scopes:
    • Fatal / page-level: throw createError({ statusCode, statusMessage, fatal: true }) in setup or middleware → renders app/error.vue with correct HTTP status (on SSR).
    • Local / recoverable: <NuxtErrorBoundary> around a widget, or handle error from useFetch.
  2. app/error.vue receives error: NuxtError as a prop; it is rendered outside layouts/pages. Use clearError({ redirect: '/' }) to recover.
  3. In server handlers, throw createError(...). Never return { error: '…' } with status 200.
  4. Use showError() to trigger the full-screen error from client code (e.g. after an action) and useError() to read the current error.
  5. 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.
  6. Do not swallow errors (catch {}) — at minimum log and surface a user-friendly message.
  7. statusMessage is visible to users and may appear in HTTP status lines — keep it short, ASCII-safe, without internals. Use data for structured details (validation issues).
  8. Hook into vue:error / app:error (Nuxt) and error (Nitro) for global reporting, not for control flow.
  9. Route middleware: return abortNavigation(createError(...)) or navigateTo(...); don't throw arbitrary errors.
  10. Handle status === 'error' in every useFetch UI path; never render data.value!.x without 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

  1. Choose the cheapest environment that proves the behaviour:
    • pure functions (shared/utils, app/utils) → plain Vitest, node environment;
    • components/composables using Nuxt auto-imports → nuxt environment (@nuxt/test-utils/runtime);
    • full flows across server + client → @nuxt/test-utils/e2e (setup, $fetch, createPage) or Playwright.
  2. Use mountSuspended (async setup, auto-imports, plugins) instead of raw mount for Nuxt components. Use renderSuspended for Testing Library style.
  3. Mock Nuxt auto-imports with mockNuxtImport (hoisted; one per import per file). Mock components with mockComponent. Mock API endpoints with registerEndpoint.
  4. Test behaviour, not implementation: user-visible output, emitted events, calls to endpoints — not internal refs.
  5. Test server handlers by calling them through $fetch in e2e or by unit-testing extracted server/utils functions with plain Vitest.
  6. Cover SSR-specific risks: render the component in SSR (e2e $fetch('/page') → HTML assertions) for pages whose SEO/HTML matters.
  7. Reset Pinia state between tests (setActivePinia(createPinia()) or the nuxt environment's fresh app), clear useState via clearNuxtState().
  8. Do not update snapshots blindly. Prefer explicit assertions to large snapshots.
  9. Keep tests deterministic: fake timers for time-based code, fixed locale/timezone (TZ=UTC), seeded data.
  10. 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-utils versions use defineVitestConfig from @nuxt/test-utils/config with a single environment: '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

DODON'T
mountSuspended for async-setup componentsmount() and wonder why auto-imports are undefined
registerEndpoint for API mocksMonkey-patch global fetch in every test
One mockNuxtImport per import, at top levelCall mockNuxtImport inside it() (it's hoisted)
Assert HTTP status for error pages in e2eOnly test client behaviour of SEO-critical pages

18. Nuxt UI v3 & Styling (Tailwind CSS v4)

Rules

  1. Version gate: confirm @nuxt/ui major. 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+.
  2. Setup in v3: add '@nuxt/ui' to modules, a CSS entry importing Tailwind and Nuxt UI, and wrap the app in <UApp> (required for toasts, tooltips, overlays).
  3. Theming: semantic colours (primary, secondary, success, info, warning, error, neutral) configured in app.config.ts → ui.colors. Custom palettes/tokens defined via Tailwind v4 @theme in CSS.
  4. Customise components via app.config.ts ui.<component> (global slots/variants) or the ui prop (per instance) and class. Don't override internal class names with deep selectors.
  5. 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.
  6. Dark mode: Nuxt UI integrates @nuxtjs/color-mode — use useColorMode() 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.
  7. Forms: <UForm :schema :state @submit> with Standard Schema (zod, valibot, yup, arktype). Reuse schemas from shared/utils. Server must validate again.
  8. Overlays: <UModal>, <USlideover>, <UDrawer>; programmatic via useOverlay(). Toasts via useToast().
  9. Icons: Nuxt UI uses @nuxt/icon with 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.
  10. Tailwind v4 is CSS-first: no tailwind.config.js by default. Use @theme, @source (for content outside scanned paths, e.g. layers/markdown), @variant, @utility in CSS.
  11. Never construct class names dynamically (bg-${color}-500) — use maps of full static strings or component color props.
  12. Accessibility comes from Reka UI primitives only if you use them correctly: provide aria-label for 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

SymptomLikely causeFix
"Hydration text content mismatch" on datesServer TZ/locale ≠ browserFixed locale + timeZone, <NuxtTime>, or render relative time client-only
Mismatch on IDsMath.random(), incrementing countersuseId()
Mismatch on auth-dependent UISession read from localStorage on client onlyCookie-based session readable in SSR, or <ClientOnly> with fallback
Mismatch on responsive UIwindow.innerWidth / useMediaQuery in templateCSS breakpoints; or useMediaQuery + <ClientOnly>; or hydrate-on-media-query
Mismatch on v-if="import.meta.client"Different trees server vs clientUse onMounted flag (const mounted = ref(false)) or <ClientOnly>
Invalid HTML nesting (<div> in <p>, <a> in <a>)Browser re-parses HTML before hydrationFix markup
Mismatch from browser extensionsExtensions mutating DOMIgnore 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 } })     // ✅ reactive

19.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-only

19.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

  1. Using <a href> for internal links → full reloads. Use <NuxtLink>.
  2. Putting large JSON in public/ and importing it → use server/assets + useStorage('assets:server') or a server route.
  3. Using v-html with untrusted content → XSS. Sanitize (DOMPurify on client, sanitize-html on server) or render via Nuxt Content/MDC.
  4. Awaiting non-critical fetches sequentially in app.vue → slow TTFB on every page.
  5. Wrapping half the app in <ClientOnly> → loses SSR benefits, causes CLS. Fix the root cause.
  6. Adding @ts-ignore / as any to silence generated-type errors → regenerate types (nuxi prepare) and fix the actual mismatch.
  7. Editing files in .nuxt/ or .output/ → they're regenerated; changes are lost.
  8. Using definePageMeta with runtime variables → it's a compiler macro; values must be statically analysable.
  9. Calling navigateTo without return/await in setup → code continues executing.
  10. 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 typecheck passes with zero errors; no new any, @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/localStorage access outside client-only paths.
  • Page data uses useFetch/useAsyncData with stable keys; no double fetching (check Network tab after hydration).
  • Route rules match intent; cached routes contain no personalised HTML (check cache-control headers).
  • SSG: nuxi generate with failOnError passes; 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, useState or client code.
  • No v-html with 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 analyze for bundle visualisation; nuxi info when 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/nuxt auto-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 useCookie over useLocalStorage for 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

runtimeConfigapp.config.ts
PurposeEnv-dependent values, secrets (private)Build-time public config, theme, feature toggles
Overridable by env at runtimeYes (NUXT_*) — except static outputNo
Reactive in appNoYes (useAppConfig(), HMR)
Secrets allowedPrivate keys onlyNever

21.12 Miscellaneous

  1. definePageMeta({ key: route => route.fullPath }) forces page re-creation on param change when the component isn't reactive to params.
  2. <NuxtLoadingIndicator /> in app.vue gives a navigation progress bar with zero config.
  3. useRequestURL() gives the correct origin behind proxies (respects x-forwarded-* when configured) — use it instead of window.location for isomorphic code.
  4. nuxi cleanup removes .nuxt, .output, caches — first step when "types are weird".
  5. nuxi upgrade --dedupe when upgrading Nuxt to dedupe lockfile entries (only when an upgrade is requested).
  6. Use server/assets/ + useStorage('assets:server') to ship read-only data files with the server bundle.
  7. experimental.typedPages gives typed useRoute('name') / navigateTo({ name }) via unplugin-vue-router — enable only if the team agrees; it changes route typing across the app.
  8. defineNuxtComponent is for Options API with asyncData; don't use it in new <script setup> code.
  9. Use useHead({ bodyAttrs: { class: 'overflow-hidden' } }) from a modal instead of touching document.body directly.
  10. In monorepos, set typescript.tsConfig.include / Tailwind @source for shared packages; verify types via nuxi typecheck from 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.