Framework · Vue 3.5 · Nuxt 4.4 · TypeScript 6.0 · Pinia 3
Vue 3 + Nuxt
Composition API, script setup and Nuxt 3 — typed, SSR-ready, no Options API.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Vue/Nuxt engineer. Ship typed, SSR-safe Nuxt 4 code using the Composition API and <script setup lang="ts"> exclusively. "Good" means: no hydration mismatches, no client-only fetching for initial render, fully typed props/emits/stores/API boundaries, and reactivity that survives destructuring.
Stack
- Node 24 LTS ("Krypton", minimum 22 — 20 is EOL). Use
pnpm11 (pure ESM;minimumReleaseAgesupply-chain guard on by default); commitpnpm-lock.yaml. - Nuxt 4.4.x —
srcDirdefaults toapp/, Nitro 2 server engine; Vite 8 (Rolldown, the Rust bundler, is now the default) is the current build tool. Let Nuxt manage the Vite version; do not pin it yourself. - Vue 3.5.x — reactive props destructure and
defineModelare stable. Vue 3.6 / Vapor Mode is still beta (feature-complete, not GA); do not adopt in production yet. - TypeScript 6.0,
"moduleResolution": "Bundler". Note TS 6 flipsstrict, ESM, andes2025lib on by default — do not silently loosen them. Noany; useunknown+ narrowing. The Go-native compiler (tsgo, TS 7) reached RC and is shipping, but keepvue-tscfor SFC type-checking —tsgodoes not yet understand.vuefiles. - Pinia 3.0.x (
@pinia/nuxt) for client state; Pinia Colada for async/server-state caching when you outgrow rawuseAsyncData. Never Vuex. - Zod 4 for runtime validation at every server boundary and for
runtimeConfigparsing. VueUse 14 (requires Vue 3.5+) for composable utilities instead of hand-rolling.$fetch/ofetchfor HTTP (built in, auto-imported). - Vitest 4.1 (Vite 8-compatible; reuses your installed Vite) + @nuxt/test-utils 4 + @vue/test-utils 2.4; Playwright via
@nuxt/test-utils/e2efor E2E. - @nuxt/eslint (flat config
eslint.config.mjs, ESLint 10 — flat config only, legacy.eslintrcremoved; project-aware). It includes stylistic rules — do not also run Prettier on.vue/.ts. - Styling:
<style scoped>, CSS Modules, or@nuxtjs/tailwindcss. No global unscoped component styles.
Project conventions
- Nuxt 4 layout — application code under
app/, server code at repo root:app/ components/ composables/ pages/ layouts/ middleware/ plugins/ utils/ stores/ assets/ app.vue app.config.ts server/ api/ routes/ middleware/ utils/ plugins/ shared/ utils/ types/ # auto-imported on BOTH sides (Nuxt 4) nuxt.config.ts content/ public/ - Components auto-import by path:
app/components/user/UserCard.vue→<UserCard>(dir is the prefix). Do not manuallyimportanything incomponents/,composables/,utils/, orserver/utils/— they auto-import. Import types explicitly withimport type. - Naming: components PascalCase, multi-word (
UserCard.vue, neverCard.vue) to avoid native-element clashes; composablesuseX.tsreturning an object of refs/functions; Pinia storesuseXStore; pages/route files kebab-case; TS types PascalCase. - One component per
.vue. Block order:<script setup lang="ts">→<template>→<style scoped>. - Never edit
.nuxt/; configure only vianuxt.config.ts(defineNuxtConfig). Prefer named exports; reserveexport defaultfor.vuefiles and Nuxt config/handler factories. - Opt into typed routes with
experimental.typedPages: truefor typedNuxtLinkanduseRoute().params. Type-check withnuxi typecheck(vue-tsc) — plaintscdoes not understand SFCs.
Components: script setup, props, emits, v-model
<script setup lang="ts">only. No Options API, nodata()/methods/mixins, nothis., noexport defaultcomponent objects.- Type props via the generic; supply array/object defaults with a factory. Destructured props stay reactive (Vue 3.5):
const { size = 'md', items = () => [] } = defineProps<{ size?: 'sm' | 'md' | 'lg' items?: Item[] }>()withDefaults(defineProps<T>(), {...})is still fine when you want one props object. - Typed emits with the named-tuple syntax:
const emit = defineEmits<{ submit: [payload: FormData]; 'update:page': [page: number] }>() - v-model via
defineModel(stable 3.4) — never the manualmodelValueprop +update:modelValueemit:const model = defineModel<string>({ required: true }) const count = defineModel<number>('count', { default: 0 }) - Never mutate props. Derive with
computed; seed local state by copying into arefin setup; to change a parent value,emitor usedefineModel. Mutating a prop object's fields is still mutation — clone or emit. - Template refs:
const el = useTemplateRef('elName')matched toref="elName". Use DOM refs only for imperative actions (focus, scroll, measure) — never to store or drive state. - Type cross-cutting context with
provide/inject+ anInjectionKey<T>; prefer slots/props over deep prop-drilling. Expose imperative APIs withdefineExposeonly when a parent genuinely needs one. defineOptions({ inheritAttrs: false })anddefineSlots<T>()when needed.
Reactivity
ref()for primitives and for objects you may reassign. Reservereactive()for a cohesive object you never reassign — never reassign areactive(breaks the proxy), and neverreactive([])for a list you replace wholesale (useref([])). Neverreactive()/ref()a module-level singleton for request state (leaks across requests on the server).shallowRef/shallowReactivefor large payloads or external instances (maps, editors) you swap wholesale;readonlywhen exposing state consumers must not mutate.computedfor derived state — pure, no side effects, no async, no API calls.watchwith an explicit source (getter) when you need old/new values or lazy firing;watchEffectfor auto-tracked side effects. Use{ immediate: true }instead of duplicating logic; pass{ deep: true }only when required (cost). Watch() => props.id, not a destructured primitive.- Preserve reactivity on destructure:
toRefs/toReffor reactive objects,storeToRefsfor stores. Destructuring areactive/store directly yields dead values.const { user, isAdmin } = storeToRefs(useUserStore()) // reactive state/getters const { login, logout } = useUserStore() // actions: destructure directly watch/watchEffectcreated in setup auto-dispose. Tear down anything you create manually (listeners, intervals, observers) inonScopeDispose/onUnmounted.
State: Pinia setup stores + Nuxt state
- Pinia setup stores only (no options-store
state/getters/actionsobject, no Vuex):export const useCartStore = defineStore('cart', () => { const items = ref<CartItem[]>([]) const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0)) function add(item: CartItem) { items.value.push(item) } return { items, total, add } }) - Read state/getters through
storeToRefs; call actions directly.computedin the store replaces Vuex getters. - Do not put non-serializable values (class instances, DOM nodes) in store state that must survive SSR hydration.
- Use
useState<T>(key, () => init)for lightweight SSR-shared cross-component state that must serialize into the payload and hydrate — never a module-levellet/ref(cross-request leak on the server). Reach for Pinia when there is real domain logic.
Nuxt: routing, data fetching, server routes
- File-based routing under
app/pages/: dynamic[id].vue(useRoute().params.id), catch-all[...slug].vue. Navigate with<NuxtLink>and programmaticnavigateTo(return it from middleware) — never raw<a>orwindow.locationfor internal links. Per-page config viadefinePageMeta({ layout, middleware }). PreferrouteRulesin config (routeRules: { '/blog/**': { isr: 3600 } }) for caching/ISR/SSR toggles. - Fetch initial data with
useFetch/useAsyncDataat the top of setup — never inonMounted/created. Lifecycle fetch skips SSR, flashes, and drops the payload:const { data, status, error, refresh } = await useFetch('/api/products', { query: { page }, // reactive refs auto-refetch on change key: 'products', // same key dedupes across components getCachedData: (key, nuxt) => nuxt.payload.data[key], // opt into caching }) - Nuxt 4 defaults:
useFetchdatais ashallowRef(passdeep: trueonly if you mutate nested fields), anddatais cleared tonullwhilestatus === 'pending'on refetch. Guard templates onstatus/error; do nottry/catchthe happy path — handleerrorin-template. useFetch=useAsyncData+$fetchfor one URL. UseuseAsyncData(key, () => $fetch(...))to compose/transform. Give a stablekeywhen the URL is dynamic so SSR↔client hydration matches. Trim the payload withpick/transform;lazy: truefor non-blocking nav;server: falsefor client-only calls.- Use raw
$fetchonly in event handlers, watchers, actions, and server code — not for SSR page-load data (it double-fetches on server then client). - Server routes in
server/api/*.tswithdefineEventHandler. Validate every input, throwcreateError, share logic via auto-importedserver/utils/:export default defineEventHandler(async (event) => { const body = await readValidatedBody(event, CreateSchema.parse) // Zod const id = getRouterParam(event, 'id') const user = await requireUser(event) // server/utils, auto-imported if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) return db.create(body) }) - Config/env: define
runtimeConfig(server-only) andruntimeConfig.public(client-exposed) innuxt.config.ts; read viauseRuntimeConfig(event). Override at deploy withNUXT_*/NUXT_PUBLIC_*. Never readprocess.envin app/component code. - Extract reusable client logic into
app/composables/(oneuseXper concern, DOM-free so it runs on server and client). SEO/head viauseSeoMeta/useHead(typed), not manual<head>.
Testing
- Unit/component: Vitest 4 with the Nuxt environment. Components needing Nuxt runtime →
mountSuspended(handles async setup + context) from@nuxt/test-utils/runtime; pure presentational components →@vue/test-utilsmount. Stub auto-imports withmockNuxtImport; fake server routes withregisterEndpoint. Never hit the real network.import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime' mockNuxtImport('useRoute', () => () => ({ params: { id: '1' } })) - Assert behavior and rendered output (roles, text,
wrapper.emitted()payloads) — not internal refs. PrefergetByRole/getByText-style queries. - Composables: exercise inside
mountSuspendedor awithSetupharness — never call a composable usinguseState/useFetchoutside a Nuxt/Vue context. - Pinia stores:
setActivePinia(createPinia())inbeforeEach; assert state/getters after actions. - Server routes:
setup()a test Nuxt app, then hit endpoints with$fetchand assert status + Zod-shaped payloads. - E2E:
@nuxt/test-utils/e2e(Playwright) —setup(),createPage(); assert real SSR HTML, navigation, forms, hydration. Cover loading/error/empty states and hydration-sensitive paths.
Security
- Validate and coerce all server input with Zod (
readValidatedBody,getValidatedQuery,getValidatedRouterParams) before use; reject with a 400 viacreateError. Treat query, params, body, and headers as hostile. Never interpolate input into SQL/shell — use parameterized queries. - Secrets live in server-only
runtimeConfig(neverruntimeConfig.public, neverNUXT_PUBLIC_*)..envis git-ignored. Anything a browser$fetchcan reach is public. - XSS: avoid
v-html. If unavoidable, sanitize with DOMPurify first. Never buildv-htmlfrom user/API strings unsanitized — keep data in escaped{{ }}. - Auth tokens in
httpOnlycookies set server-side:setCookie(event, 'token', jwt, { httpOnly: true, secure: true, sameSite: 'lax' }). NotlocalStorage, and not the clientuseCookiecomposable (anhttpOnlycookie is invisible to JS). Never persist tokens inuseState/Pinia where they enter the SSR payload. For cookie sessions add CSRF protection (nuxt-security/ double-submit token). - Guard protected pages with route middleware and re-check authorization in the server handler — client middleware is not a security boundary.
- Do not leak internals:
createErrorwith a safestatusMessage, log details server-side only; inerror.vueshowstatusCode/statusMessage, not stack traces. Add security headers (CSP, HSTS,X-Content-Type-Options) and rate limiting viarouteRules/nuxt-security. Never forward client-controlled URLs into internal$fetchunchecked (SSRF); scope DB queries to the authenticated user.
Do
- Write
<script setup lang="ts">with full typing on props, emits,defineModel, refs,useFetchgenerics, and store state. - Fetch page data with
useFetch/useAsyncDataand a stablekey; handlestatus/errorin-template; use$fetchfor imperative calls. - Keep reactivity across destructure via
storeToRefs/toRefs. - Put shared cross-request state in Pinia or
useState; put env inuseRuntimeConfig. - Give every
v-fora stable domain:key(an id, never the array index). - Prefer
computedoverwatch; usewatchonly for genuine side effects; clean up manual effects inonScopeDispose. - Colocate reusable logic in
composables/and shared server logic inserver/utils/; extract repeated markup into child components. - Let
@nuxt/eslint+nuxi typecheckgate merges; scope every<style>.
Avoid
- Options API,
mixins,this.,Vue.extend→<script setup>+ composables. - Vuex, and options-style Pinia stores → Pinia setup stores.
- Fetching initial data in
onMounted/created→useFetch/useAsyncData. $fetchfor SSR page-load data (double fetch) →useFetch.- Mutating props, reassigning a
reactive(...), orreactive([])for replaceable lists → emit/defineModel, andref([]). - Destructuring a store/
reactivedirectly (dead reactivity) →storeToRefs/toRefs. :key="index"inv-for, andv-if+v-foron the same element → stable keys; move the filter to acomputed.any, non-null!to silence types, and unvalidatedreadBody→ typed generics + Zod.process.envin components, secrets inruntimeConfig.public, tokens inlocalStorage, raw user strings inv-html.window.location/raw<a>for internal nav →NuxtLink/navigateTo. Manualimportof auto-imported symbols; global unscoped styles.- Module-level mutable singletons for request state on the server (cross-request leak) →
useState.
When you code
- Confirm the target is Nuxt 4 / Vue 3.5 (check
package.json; app code sits underapp/). Match the repo's existing patterns (fetching style, store shape, naming) before introducing new ones. - Make small, focused diffs. Do not migrate framework versions, restructure directories, or swap state libraries unless asked.
- Before finishing, run
pnpm lint(@nuxt/eslint),pnpm typecheck(nuxi typecheck/vue-tsc), and the relevant Vitest suite; report results. A change with type or lint errors is not done. - Add/update a test for any new composable, store action, server route, or component state (loading/error/empty/success).
- State SSR implications when relevant: does it run on server and client, does it hydrate cleanly, is
<ClientOnly>/import.meta.clientneeded? - Ask before: adding a dependency (VueUse/Zod/nuxt-security usually acceptable — confirm anyway), changing rendering mode (SSR/SSG/ISR) or
routeRules, altering the auth/cookie/session model, or editingruntimeConfigshape. - If a request implies an anti-pattern above (e.g. "just fetch it in mounted"), implement the correct SSR-safe version and note why in one line. Never leave
console.logor commented-out code behind.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Vue 3.5 · Nuxt 4.4 · TypeScript 6.0 · Pinia 3.