Promptheus/rules53 rule sets · CC0Promptheus hub ↗

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.

vuenuxttypescriptssr

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 pnpm 11 (pure ESM; minimumReleaseAge supply-chain guard on by default); commit pnpm-lock.yaml.
  • Nuxt 4.4.xsrcDir defaults to app/, 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 defineModel are 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 flips strict, ESM, and es2025 lib on by default — do not silently loosen them. No any; use unknown + narrowing. The Go-native compiler (tsgo, TS 7) reached RC and is shipping, but keep vue-tsc for SFC type-checking — tsgo does not yet understand .vue files.
  • Pinia 3.0.x (@pinia/nuxt) for client state; Pinia Colada for async/server-state caching when you outgrow raw useAsyncData. Never Vuex.
  • Zod 4 for runtime validation at every server boundary and for runtimeConfig parsing. VueUse 14 (requires Vue 3.5+) for composable utilities instead of hand-rolling. $fetch/ofetch for 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/e2e for E2E.
  • @nuxt/eslint (flat config eslint.config.mjs, ESLint 10 — flat config only, legacy .eslintrc removed; 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 manually import anything in components/, composables/, utils/, or server/utils/ — they auto-import. Import types explicitly with import type.
  • Naming: components PascalCase, multi-word (UserCard.vue, never Card.vue) to avoid native-element clashes; composables useX.ts returning an object of refs/functions; Pinia stores useXStore; 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 via nuxt.config.ts (defineNuxtConfig). Prefer named exports; reserve export default for .vue files and Nuxt config/handler factories.
  • Opt into typed routes with experimental.typedPages: true for typed NuxtLink and useRoute().params. Type-check with nuxi typecheck (vue-tsc) — plain tsc does not understand SFCs.

Components: script setup, props, emits, v-model

  • <script setup lang="ts"> only. No Options API, no data()/methods/mixins, no this., no export default component 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 manual modelValue prop + update:modelValue emit:
    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 a ref in setup; to change a parent value, emit or use defineModel. Mutating a prop object's fields is still mutation — clone or emit.
  • Template refs: const el = useTemplateRef('elName') matched to ref="elName". Use DOM refs only for imperative actions (focus, scroll, measure) — never to store or drive state.
  • Type cross-cutting context with provide/inject + an InjectionKey<T>; prefer slots/props over deep prop-drilling. Expose imperative APIs with defineExpose only when a parent genuinely needs one.
  • defineOptions({ inheritAttrs: false }) and defineSlots<T>() when needed.

Reactivity

  • ref() for primitives and for objects you may reassign. Reserve reactive() for a cohesive object you never reassign — never reassign a reactive (breaks the proxy), and never reactive([]) for a list you replace wholesale (use ref([])). Never reactive()/ref() a module-level singleton for request state (leaks across requests on the server).
  • shallowRef/shallowReactive for large payloads or external instances (maps, editors) you swap wholesale; readonly when exposing state consumers must not mutate.
  • computed for derived state — pure, no side effects, no async, no API calls.
  • watch with an explicit source (getter) when you need old/new values or lazy firing; watchEffect for 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/toRef for reactive objects, storeToRefs for stores. Destructuring a reactive/store directly yields dead values.
    const { user, isAdmin } = storeToRefs(useUserStore()) // reactive state/getters
    const { login, logout } = useUserStore()               // actions: destructure directly
    
  • watch/watchEffect created in setup auto-dispose. Tear down anything you create manually (listeners, intervals, observers) in onScopeDispose/onUnmounted.

State: Pinia setup stores + Nuxt state

  • Pinia setup stores only (no options-store state/getters/actions object, 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. computed in 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-level let/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 programmatic navigateTo (return it from middleware) — never raw <a> or window.location for internal links. Per-page config via definePageMeta({ layout, middleware }). Prefer routeRules in config (routeRules: { '/blog/**': { isr: 3600 } }) for caching/ISR/SSR toggles.
  • Fetch initial data with useFetch / useAsyncData at the top of setup — never in onMounted/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: useFetch data is a shallowRef (pass deep: true only if you mutate nested fields), and data is cleared to null while status === 'pending' on refetch. Guard templates on status/error; do not try/catch the happy path — handle error in-template.
  • useFetch = useAsyncData + $fetch for one URL. Use useAsyncData(key, () => $fetch(...)) to compose/transform. Give a stable key when the URL is dynamic so SSR↔client hydration matches. Trim the payload with pick/transform; lazy: true for non-blocking nav; server: false for client-only calls.
  • Use raw $fetch only 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/*.ts with defineEventHandler. Validate every input, throw createError, share logic via auto-imported server/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) and runtimeConfig.public (client-exposed) in nuxt.config.ts; read via useRuntimeConfig(event). Override at deploy with NUXT_* / NUXT_PUBLIC_*. Never read process.env in app/component code.
  • Extract reusable client logic into app/composables/ (one useX per concern, DOM-free so it runs on server and client). SEO/head via useSeoMeta/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-utils mount. Stub auto-imports with mockNuxtImport; fake server routes with registerEndpoint. 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. Prefer getByRole/getByText-style queries.
  • Composables: exercise inside mountSuspended or a withSetup harness — never call a composable using useState/useFetch outside a Nuxt/Vue context.
  • Pinia stores: setActivePinia(createPinia()) in beforeEach; assert state/getters after actions.
  • Server routes: setup() a test Nuxt app, then hit endpoints with $fetch and 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 via createError. Treat query, params, body, and headers as hostile. Never interpolate input into SQL/shell — use parameterized queries.
  • Secrets live in server-only runtimeConfig (never runtimeConfig.public, never NUXT_PUBLIC_*). .env is git-ignored. Anything a browser $fetch can reach is public.
  • XSS: avoid v-html. If unavoidable, sanitize with DOMPurify first. Never build v-html from user/API strings unsanitized — keep data in escaped {{ }}.
  • Auth tokens in httpOnly cookies set server-side: setCookie(event, 'token', jwt, { httpOnly: true, secure: true, sameSite: 'lax' }). Not localStorage, and not the client useCookie composable (an httpOnly cookie is invisible to JS). Never persist tokens in useState/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: createError with a safe statusMessage, log details server-side only; in error.vue show statusCode/statusMessage, not stack traces. Add security headers (CSP, HSTS, X-Content-Type-Options) and rate limiting via routeRules/nuxt-security. Never forward client-controlled URLs into internal $fetch unchecked (SSRF); scope DB queries to the authenticated user.

Do

  • Write <script setup lang="ts"> with full typing on props, emits, defineModel, refs, useFetch generics, and store state.
  • Fetch page data with useFetch/useAsyncData and a stable key; handle status/error in-template; use $fetch for imperative calls.
  • Keep reactivity across destructure via storeToRefs/toRefs.
  • Put shared cross-request state in Pinia or useState; put env in useRuntimeConfig.
  • Give every v-for a stable domain :key (an id, never the array index).
  • Prefer computed over watch; use watch only for genuine side effects; clean up manual effects in onScopeDispose.
  • Colocate reusable logic in composables/ and shared server logic in server/utils/; extract repeated markup into child components.
  • Let @nuxt/eslint + nuxi typecheck gate 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/createduseFetch/useAsyncData.
  • $fetch for SSR page-load data (double fetch) → useFetch.
  • Mutating props, reassigning a reactive(...), or reactive([]) for replaceable lists → emit/defineModel, and ref([]).
  • Destructuring a store/reactive directly (dead reactivity) → storeToRefs/toRefs.
  • :key="index" in v-for, and v-if + v-for on the same element → stable keys; move the filter to a computed.
  • any, non-null ! to silence types, and unvalidated readBody → typed generics + Zod.
  • process.env in components, secrets in runtimeConfig.public, tokens in localStorage, raw user strings in v-html.
  • window.location/raw <a> for internal nav → NuxtLink/navigateTo. Manual import of 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 under app/). 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.client needed?
  • 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 editing runtimeConfig shape.
  • 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.log or 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.

Back to top ↑