Vue 3 with Nuxt is the framework that lets you ship a marketing landing page in a morning and a tangled mess of half-converted Options API components, prop mutations, and SSR hydration mismatches by the end of the quarter. The first regression is almost always a component that was started in Composition API with <script setup>, then "fixed" by someone pasting an Options API data() block underneath because that was the syntax they remembered — two reactivity systems in the same file, props read from this.foo in one method and from the props argument in another, lifecycle hooks split between mounted() and onMounted(), and a watch defined in both watch: and watchEffect() that fires twice on every change. The second is the developer who reaches into props.user.name = 'X' to "just update the display" — the parent's reactive object mutates silently, the child remounts later and the value snaps back, and the bug report says "the name keeps reverting." The third is a Nuxt 3 page that calls await fetch('/api/posts') inside <script setup> instead of useFetch — the request fires once on the server during SSR, the hydration payload doesn't include the result, then it fires again on the client, the user sees a flash of empty list, and DevTools shows duplicate network calls on every navigation.
Then you add an AI assistant.
Cursor and Claude Code were trained on Vue code that spans a decade — Vue 2 single-file components with export default { data() { return {...} }, methods: {...}, computed: {...} }, Vue.extend() factories, mixins that inject created() hooks into every consumer and produce name collisions silently, Vuex 3 modules with state / mutations / actions / getters and string-typed commit('SET_USER', payload) calls, this.$emit('input', value) from a child that the parent listens to as @input with no compile-time check that the event even exists, <template> blocks whose template-ref types resolve to any, untyped props: ['title', 'count'] declared as runtime arrays with no TypeScript inference, <nuxt-link to="/about"> from Nuxt 2 next to Nuxt 3's <NuxtLink>, ~/components/Foo.vue imports that broke when Nuxt 3 changed alias resolution, components without <script setup> that use defineComponent({ setup() { return {...} } }) with everything manually returned, watch: { foo: { handler, deep: true } } declared with deep because "the test passed only with deep: true" rather than because the data shape required it, two-way bindings via v-model plus :value plus @input written by hand instead of defineModel(), and the recurring habit of reaching for Object.assign(state, newState) to "force reactivity" because the AI saw that pattern in a 2019 Stack Overflow answer. Ask for "a profile page with a form to edit the user," and you get a defineComponent with data() { return { user: {} } }, an axios.get in mounted(), a methods: { save() { axios.put(...) } }, props declared as a string array, and a <template> that mutates this.user.name directly on input — Vue 3 syntax wrapped around Vue 2 thinking. It runs. It is not the Vue you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Vue 3 + Nuxt 3 looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Vue Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For Vue 3 + Nuxt 3 I recommend modular rules so single-file-component conventions don't bleed into composable / store / server-route code, and so cross-cutting concerns like reactivity and SSR stay visible:
.cursor/rules/
vue-components.mdc # script setup, Composition API only
vue-props-emits.mdc # typed defineProps, defineEmits, defineModel
vue-reactivity.mdc # ref vs reactive, computed, watch, scope
vue-pinia.mdc # Pinia setup stores, storeToRefs, scoping
vue-composables.mdc # use*.ts, return refs, scope cleanup
vue-nuxt-data.mdc # useFetch / useAsyncData / useState / SSR
vue-router.mdc # typed routes, lazy load, definePageMeta
vue-testing.mdc # Vitest + Vue Testing Library + Playwright + MSW
Frontmatter controls activation: globs: ["**/*.vue", "composables/**/*.ts", "stores/**/*.ts", "server/**/*.ts", "nuxt.config.*"] with alwaysApply: false. Now the rules.
Rule 1: Composition API + <script setup> Only — No Options API, No Mixins, No Vue.extend()
Vue 3 supports both Options API and Composition API, and the Vue docs are deliberately neutral about which to pick. That neutrality is exactly why Cursor's training is split — half the examples in its corpus are export default { data, methods, computed }, half are <script setup> with ref and computed. Ask for a small component and you get whichever one the model happened to weight higher this prompt. Mixed Options/Composition in the same file is a maintenance nightmare: this means different things in different methods, lifecycle hooks fire in unpredictable orders, mixins inject names that collide silently with locally-defined refs. The rule: every component is <script setup lang="ts"> with the Composition API. No Options API. No mixins. Cross-component logic lives in composables/use*.ts.
The rule:
Every Vue component is a single-file component with `<script setup lang="ts">`.
There is no Options API in this codebase. The following are forbidden:
- `export default { data() { return {...} } }` blocks.
- `methods: {}`, `computed: {}`, `watch: {}` Options API blocks.
- `mixins: [SomeMixin]` — replaced by composables.
- `extends: Base` — replaced by composition + composables.
- `Vue.extend({...})` — Vue 2 era, removed in modern code.
- `defineComponent({ setup() { return {...} } })` with manual return —
use `<script setup>` instead.
Every component file structure:
<script setup lang="ts">
// imports
// defineProps / defineEmits / defineModel
// composables (use*)
// refs / computed / watch / lifecycle
// local helpers
</script>
<template>
<!-- template -->
</template>
<style scoped>
/* style */
</style>
`lang="ts"` is mandatory on every <script setup>. JS-only components are
not accepted.
Cross-component logic — anything that would have been a mixin in Vue 2 —
goes into `composables/use*.ts`. A composable returns refs, computeds,
and functions. Multiple components import and call the composable; each
gets its own reactive state instance.
Lifecycle hooks: `onMounted`, `onBeforeUnmount`, `onUpdated`, `onErrorCaptured`
imported from 'vue'. Never the Options API equivalents.
Template refs: `const root = useTemplateRef('root')` (Vue 3.5+) over the
older `const root = ref<HTMLElement | null>(null)` + `<div ref="root">`
pattern. The string identifier matches the template's `ref` attribute.
`<script setup>` exposes everything top-level to the template
automatically. Do NOT add a `defineExpose({...})` unless a parent needs
to call methods on this child via a template ref. When you do, defineExpose
is the explicit, narrow surface — never expose internals broadly.
Functional / render-function components only when JSX adds clarity (a
component that takes `as` prop and renders different elements). Default
is single-file component.
`useId()` (Vue 3.5+) for stable SSR-safe IDs. Never `Math.random()` or
incrementing counters for `for`/`id` attribute pairing.
Generic components: `<script setup lang="ts" generic="T extends {id: number}">`
to type a list-rendering component without losing inference.
Before — Options API + mixin, Vue 2 thinking in Vue 3 syntax:
<script lang="ts">
import Vue from 'vue';
import { LoggerMixin } from '@/mixins/logger';
export default Vue.extend({
name: 'UserCard',
mixins: [LoggerMixin],
props: ['user', 'editable'],
data() {
return { editing: false, draft: { name: '' } };
},
computed: {
displayName(): string { return this.draft.name || this.user.name; },
},
methods: {
startEdit() { this.editing = true; this.draft.name = this.user.name; },
save() { this.$emit('save', this.draft); this.editing = false; },
},
mounted() { this.log('mounted UserCard'); },
});
</script>
<template>
<div>
<p>{{ displayName }}</p>
<button v-if="editable" @click="startEdit">Edit</button>
<input v-if="editing" v-model="draft.name" />
<button v-if="editing" @click="save">Save</button>
</div>
</template>
Vue.extend is gone in Vue 3. Mixin injects log() invisibly. Props are untyped. $emit('save', ...) has no compile-time check.
After — <script setup>, typed everything, composable for logging:
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useLogger } from '@/composables/useLogger';
interface User { id: number; name: string }
const props = defineProps<{ user: User; editable?: boolean }>();
const emit = defineEmits<{ save: [user: User] }>();
const { log } = useLogger('UserCard');
const editing = ref(false);
const draftName = ref('');
const displayName = computed(() => draftName.value || props.user.name);
function startEdit() {
editing.value = true;
draftName.value = props.user.name;
}
function save() {
emit('save', { ...props.user, name: draftName.value });
editing.value = false;
}
onMounted(() => log('mounted'));
</script>
<template>
<div>
<p>{{ displayName }}</p>
<button v-if="editable" @click="startEdit">Edit</button>
<input v-if="editing" v-model="draftName" />
<button v-if="editing" @click="save">Save</button>
</div>
</template>
Reactive intent is explicit. log is imported, not magic. save event is type-checked at call site. No prop mutation — draftName is a separate ref.
Rule 2: Typed defineProps + defineEmits + defineModel — Generics Syntax, Never Mutate Props
Vue's runtime props declaration (props: { title: { type: String, required: true } }) survives in Vue 3 but loses every advantage of TypeScript. Cursor will write defineProps(['title', 'count']) because that pattern compiles, then mutate props.count = props.count + 1 in a method because nothing told it not to. Vue 3.4+ added defineModel for two-way bindings — Cursor still emits the manual props.modelValue + emit('update:modelValue', x) boilerplate because that's what the older Vue 3 docs show. The rule: every prop and emit declaration uses the generics syntax for compile-time typing, defaults come from withDefaults, two-way bindings use defineModel, and props are immutable inside the child.
The rule:
Always use the generics syntax for defineProps and defineEmits:
const props = defineProps<{ user: User; editable?: boolean }>();
const emit = defineEmits<{
save: [user: User];
cancel: [];
delete: [id: number];
}>();
Never the runtime arrays / objects:
defineProps(['user', 'editable']) // forbidden
defineProps({ user: Object, editable: Boolean }) // forbidden
Defaults via withDefaults:
const props = withDefaults(
defineProps<{ size?: 'sm' | 'md' | 'lg'; disabled?: boolean }>(),
{ size: 'md', disabled: false }
);
Required vs optional encoded in the type — `?` for optional. Don't repeat
required information at runtime.
Props are immutable. Inside the child, `props.foo = ...` is a bug.
- Need a local mutable copy? `const draft = ref(structuredClone(props.foo))`.
- Need to "edit" parent state? Emit an event the parent handles, OR
use `defineModel()` for two-way binding (Vue 3.4+).
- Need a derived value? `computed(() => transform(props.foo))`.
Two-way binding via defineModel (Vue 3.4+):
// child
const value = defineModel<string>({ required: true });
// parent
<ChildInput v-model="user.name" />
`defineModel` returns a writable ref. Reading it is `value.value`,
writing is `value.value = newValue`. The framework wires up the
emit. Multiple v-models: `defineModel<string>('first')`,
`defineModel<string>('last')`, used as `v-model:first` / `v-model:last`.
Emit signatures use the tuple form:
defineEmits<{ change: [value: string, index: number] }>();
The function call signature `(e: 'change', value: string, index: number)
=> void` is also valid but more verbose. Tuple form preferred.
Slot props are typed via defineSlots (Vue 3.3+):
defineSlots<{
default(props: { user: User }): any;
actions(props: { onSave: () => void }): any;
}>();
Avoid `<template #default="{ user }">` with implicit any — defineSlots
gives the consumer compile-time inference.
Validators (runtime): only when type alone can't express the constraint
(an enum that the API also validates). Otherwise type narrowing in the
parent is the contract.
`v-model` on native inputs uses `defineModel`. Custom input components
expose `defineModel` for the parent to bind. NEVER manage modelValue via
props + emit by hand — defineModel is the only correct path post-Vue 3.4.
Before — array props, prop mutation, manual v-model emit:
<script setup>
const props = defineProps(['modelValue', 'maxLength']);
const emit = defineEmits(['update:modelValue']);
function onInput(e) {
let v = e.target.value;
if (v.length > props.maxLength) v = v.slice(0, props.maxLength);
props.modelValue = v; // mutating prop, silently broken
emit('update:modelValue', v);
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>
Untyped. Mutates the prop. Manually wires up v-model. No lang="ts".
After — typed defineModel, immutable props, derived defaults:
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{ maxLength?: number }>(),
{ maxLength: 100 }
);
const value = defineModel<string>({ required: true });
function onInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
value.value = v.slice(0, props.maxLength);
}
const remaining = computed(() => props.maxLength - value.value.length);
</script>
<template>
<input :value="value" @input="onInput" />
<small>{{ remaining }} characters remaining</small>
</template>
Compile-time typed. defineModel handles the v-model contract. props.maxLength is read-only, default applied via withDefaults. remaining derives instead of duplicating state.
Rule 3: Reactivity Discipline — ref By Default, reactive Rarely, computed For Derivations
Vue 3's reactivity primitives (ref, reactive, computed, watch, watchEffect, shallowRef, shallowReactive, toRefs, toRef, effectScope) are powerful and easily misused. Cursor's failure mode is consistent: it picks reactive({...}) for every object because it "looks cleaner" (no .value), then destructures it (const { count } = state), loses reactivity, and adds a watch(state, ..., { deep: true }) to compensate. Or it reaches for Object.assign(state, newState) because someone on Stack Overflow said that "forces reactivity." The rule: prefer ref for everything (primitives and objects), use reactive only for objects you mutate in place (and carefully), computed for derivations, watch only when a side effect needs to fire on a specific change, shallowRef / shallowReactive for large frozen data.
The rule:
Default to `ref` for every piece of state — primitives, objects, arrays.
const count = ref(0);
const user = ref<User | null>(null);
const items = ref<Item[]>([]);
Reading: `count.value`. Writing: `count.value = n` or
`items.value.push(...)`. The `.value` is annoying for ten minutes; the
clarity is forever.
`reactive` is reserved for cases where you genuinely want a mutable
object identity that's passed around:
- A long-lived store-like object kept across function calls.
- A form-state object passed to multiple sibling helpers.
For most components, `ref<T>(...)` is the right call.
Reactive objects lose reactivity when destructured:
const state = reactive({ count: 0 });
const { count } = state; // count is now a NUMBER, not reactive
Use `toRefs(state)` to destructure as refs:
const { count } = toRefs(state);
`computed` for any derived value. Two rules:
1. A `computed` is read-only by default; if you need write, use the
getter/setter form: `computed({ get, set })`.
2. A `computed` does NOT have side effects. Anything async, anything
that mutates, anything that calls fetch — that's `watch` or
`watchEffect`, not `computed`.
`watch` is for explicit reactions to identified sources:
watch(user, (newUser, oldUser) => { ... });
watch([userId, filter], ([id, f]) => { ... });
watch(() => props.user.id, (id) => { ... }); // getter form
`watchEffect` is for "run this effect, track whatever it reads":
watchEffect(() => { fetchPosts(filter.value, page.value); });
Use `watch` when you need oldValue or want explicit deps. Use
`watchEffect` when deps would be tedious to enumerate.
`{ deep: true }` is a code smell. If you need deep, your data shape is
probably wrong. Acceptable cases: arrays of objects where you legitimately
care about mutation in any element. Bad cases: "the test only passed with
deep" — fix the data, not the watch.
`{ immediate: true }` runs the watcher once on setup. Use it; do not
duplicate the body in `onMounted`.
`shallowRef` / `shallowReactive` for large objects whose internals don't
need fine-grained tracking (a parsed AST, a chart dataset, a CodeMirror
instance). Reactivity only triggers on the top-level reference.
`toRef` / `toRefs` at composable boundaries — return refs from
composables so consumers can destructure without losing reactivity:
function useUser() {
const state = reactive({ name: '', age: 0 });
return toRefs(state);
}
// consumer
const { name, age } = useUser(); // both refs
Never: `Object.assign(state, newState)` to "force reactivity". If a ref
isn't updating, the cause is destructuring or aliasing — fix that.
Effect scope (effectScope, onScopeDispose) for composables that create
watchers and need to clean up when the consumer unmounts. The framework
handles this for component-scoped effects; manual scopes are for shared
composables (singletons used by multiple components).
`markRaw` for objects you explicitly DON'T want to be reactive (a Class
instance from a third-party SDK, a Map you're using as a cache).
Wrapping a Class in reactive is a perf trap — the proxy intercepts every
property access.
Before — reactive abuse, destructure breaks tracking, deep watch hammer:
<script setup>
import { reactive, watch } from 'vue';
const state = reactive({ count: 0, user: { name: 'A' } });
const { count, user } = state; // both lose reactivity
watch(state, (newState) => {
fetchSomething(newState.count);
}, { deep: true }); // fires on every nested change
</script>
<template>
<p>{{ count }} {{ user.name }}</p> <!-- never updates -->
</template>
Destructure broke the link. Deep watch fires on every keystroke. Template renders stale values forever.
After — refs, computed for derivation, focused watch:
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const count = ref(0);
const user = ref({ name: 'A' });
const greeting = computed(() => `Hi, ${user.value.name}! Count: ${count.value}`);
watch(count, (n) => fetchSomething(n)); // fires only when count changes
function increment() { count.value++; }
</script>
<template>
<p>{{ greeting }}</p>
<button @click="increment">+</button>
</template>
Reactivity is intact. The watch fires only on the dependency that matters. The derivation is explicit.
Rule 4: Pinia For State — Setup Stores, storeToRefs, Scoped Per Feature, Never Vuex
Pinia is the official Vue state-management library since 2022; Vuex is in maintenance. Cursor still emits Vuex 3 modules with state / mutations / actions / getters and string-typed commit('SET_USER', payload) calls because that's what most of the corpus contains. When it does pick Pinia, it defaults to the Options-style defineStore('id', { state, getters, actions }) because the Pinia docs show that first — losing the cleaner setup syntax and TypeScript inference. The rule: every store uses defineStore('id', () => { ... }) (setup syntax), one store per feature scope, getters via computed, actions are plain functions, consumers destructure with storeToRefs to preserve reactivity.
The rule:
State management uses Pinia. Vuex (any version) is forbidden in new
code; legacy Vuex modules are migrated on touch.
Every store uses the SETUP syntax:
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => user.value !== null);
async function login(email: string, password: string) {
user.value = await api.login(email, password);
}
function logout() { user.value = null; }
return { user, isLoggedIn, login, logout };
});
Reasons over the Options syntax:
- Type inference is automatic — no need to type state shape twice.
- State, getters, actions live next to each other, easy to reason about.
- Composables can be called inside the store's setup function.
Store IDs are short, lowercase, kebab-case if multi-word: 'user',
'cart', 'product-search'. Match the file name (`stores/user.ts` →
'user').
Consumers destructure with storeToRefs to keep refs reactive:
import { storeToRefs } from 'pinia';
const userStore = useUserStore();
const { user, isLoggedIn } = storeToRefs(userStore); // refs
const { login, logout } = userStore; // functions ok
NEVER `const { user } = userStore` — that loses reactivity for refs.
One store per FEATURE, not one mega-store. A 500-line store with 12
unrelated state slices is a sign you need to split:
stores/user.ts (auth, profile)
stores/cart.ts (cart items, totals)
stores/products.ts (product catalog cache)
stores/notifications.ts (toast queue)
Stores can call each other — `useCartStore()` inside `useUserStore`'s
logout to clear the cart. Pinia handles ordering.
Mutations are direct: `user.value = newUser`. There is no Vuex-style
mutation/action distinction. If you need an audit trail, use Pinia's
`$subscribe` or a plugin.
Async work happens inside actions (regular async functions). Components
await the action and render based on the resulting state.
Persisted state: pinia-plugin-persistedstate for localStorage / cookie
persistence. Configure per-store, not globally.
SSR (Nuxt 3): `useState` for ad-hoc cross-component state, Pinia for
structured store state. Pinia auto-integrates with Nuxt 3 via @pinia/nuxt
module — state is serialized in the SSR payload and hydrated on the
client.
Testing: `createPinia()` per test, set as active via `setActivePinia`.
Stores are plain Composition API functions; they unit-test trivially.
Plugins for cross-cutting concerns (logging, persistence) — never
copy-paste the same logic into every store.
Before — Vuex-style options store, destructure loses reactivity:
// stores/cart.ts
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as CartItem[], coupon: null as string | null }),
getters: { count: (s) => s.items.length },
actions: {
add(item: CartItem) { this.items.push(item); },
SET_COUPON(c: string) { this.coupon = c; },
},
});
<script setup>
const cart = useCartStore();
const { items, count } = cart; // both lose reactivity
</script>
Mutation-like names (SET_COUPON) are Vuex residue. Consumer destructure breaks the bindings.
After — setup store, storeToRefs, scoped, typed:
// stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface CartItem { id: number; productId: number; qty: number; price: number }
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);
const coupon = ref<string | null>(null);
const count = computed(() => items.value.length);
const subtotal = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0));
function add(item: CartItem) { items.value.push(item); }
function setCoupon(code: string | null) { coupon.value = code; }
function clear() { items.value = []; coupon.value = null; }
return { items, coupon, count, subtotal, add, setCoupon, clear };
});
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';
const cartStore = useCartStore();
const { items, count, subtotal } = storeToRefs(cartStore);
const { add, clear } = cartStore;
</script>
State, getters, actions co-located. Consumers preserve reactivity. No Vuex pretense.
Rule 5: Composables — Extract Reusable Logic Into composables/use*.ts, Return Refs, Cleanup With onScopeDispose
Composables are the Composition API's answer to mixins, render-props, and HOCs all at once. Cursor often inlines the same logic across three components instead of extracting (because that's the path of least resistance for the next-token predictor), or extracts it into a function that returns plain values instead of refs (losing reactivity at the consumer), or wires up an event listener inside onMounted and forgets the matching onBeforeUnmount. The rule: cross-component logic is a composable in composables/use*.ts, every composable returns refs / computeds, cleanup uses onScopeDispose / tryOnScopeDispose so it works inside both component and effect-scope contexts, and you reach for VueUse before hand-rolling browser APIs.
The rule:
Cross-component logic lives in `composables/use*.ts` (Nuxt 3 auto-imports
this directory). Naming: always `use<Thing>`, lowerCamelCase suffix.
composables/useDebouncedRef.ts
composables/useUserPreferences.ts
composables/useAsyncTask.ts
A composable is a plain function that uses Composition API primitives
(ref, computed, watch, lifecycle hooks). It returns an object of refs
and functions. Each call creates fresh state — composables are
factories, not singletons.
Return refs, NOT plain values:
// good
export function useCounter(initial = 0) {
const count = ref(initial);
function increment() { count.value++; }
return { count, increment };
}
// bad — caller can't watch count
export function useCounter(initial = 0) {
let count = initial;
return { count, increment: () => count++ };
}
If you need a singleton (one global instance across the app) — wrap in
an outer scope or use Pinia. Don't fake singleton-ness with
module-level refs unless you understand the SSR implications (cross-
request leakage). For SSR-safe shared state, use `useState` (Nuxt 3) or
a Pinia store.
Cleanup via onScopeDispose / tryOnScopeDispose, not just
onBeforeUnmount:
import { onScopeDispose } from 'vue';
export function useEventListener(target, event, handler) {
target.addEventListener(event, handler);
onScopeDispose(() => target.removeEventListener(event, handler));
}
`tryOnScopeDispose` from VueUse is safer when the composable might be
called outside a setup context — it no-ops instead of throwing.
`effectScope()` for composables that manage a group of effects you may
need to dispose together (e.g. a long-lived shared composable used by
the app shell):
const scope = effectScope();
scope.run(() => { /* refs, watchers */ });
// later
scope.stop();
Prefer VueUse over hand-rolling browser APIs. `useEventListener`,
`useIntersectionObserver`, `useLocalStorage`, `useResizeObserver`,
`useDebouncedRef`, `useThrottle`, `useDark`, `useClipboard`, `useFetch`
(VueUse's, distinct from Nuxt's) — all already cleanup-correct, all
SSR-aware, all TypeScript-typed. Reach for them first.
Composables can call other composables. Inversion of control: a
composable that does API work should accept its dependencies (a
fetcher) rather than importing the global one — easier to test.
Async in composables: return `{ data, error, pending, refresh }` shape
matching Nuxt's useAsyncData / useFetch. Consumers know what to expect.
Don't over-extract. A 4-line piece of logic used in one component is
not a composable — it's a function. Extract on the second use, not the
first.
Before — inline logic, plain return, missing cleanup:
<script setup>
import { ref, onMounted } from 'vue';
const width = ref(window.innerWidth);
onMounted(() => {
window.addEventListener('resize', () => { width.value = window.innerWidth; });
// never removed — listener leaks across HMR + unmount
});
</script>
Each component duplicates this. The listener leaks. Crashes during SSR (no window on the server).
After — VueUse composable, SSR-safe, cleanup automatic:
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core';
const { width, height } = useWindowSize();
</script>
<template>
<p>{{ width }} × {{ height }}</p>
</template>
Or for a reusable pattern not in VueUse:
// composables/useOnlineStatus.ts
import { ref } from 'vue';
import { useEventListener } from '@vueuse/core';
export function useOnlineStatus() {
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true);
useEventListener(window, 'online', () => { online.value = true; });
useEventListener(window, 'offline', () => { online.value = false; });
return { online };
}
SSR-safe initial value. Listeners cleaned up automatically by VueUse. Reusable from any component or composable.
Rule 6: Nuxt 3 Data Fetching & SSR — useFetch / useAsyncData With Explicit Keys, useState For Shared State
Nuxt 3's data-fetching helpers (useFetch, useAsyncData, useLazyFetch, useLazyAsyncData, $fetch, useState) exist because plain fetch in <script setup> causes the most common Nuxt bug: the request fires on the server during SSR, the result isn't included in the hydration payload, and then it fires again on the client. Cursor reaches for plain fetch constantly — that's what its training corpus is full of. It also gets the auto-generated cache keys wrong (Nuxt derives a key from the file/component, but identical calls in different places without explicit keys collide), or omits transform / pick and ships the entire API payload through hydration just to render three fields. The rule: every server-side data read uses useFetch or useAsyncData with an explicit key, payload-shaping via transform and pick, $fetch only inside event handlers, useState for SSR-safe shared state.
The rule:
Server-side data fetching uses useFetch / useAsyncData. Every call has
an explicit `key`:
const { data: posts } = await useFetch('/api/posts', { key: 'posts-list' });
const { data: post } = await useAsyncData(
`post:${slug.value}`,
() => $fetch(`/api/posts/${slug.value}`)
);
Without an explicit key, Nuxt auto-derives one from the file location.
Two identical calls in the same file collide. Always pass `key`.
useFetch:
- `useFetch(url, options)` — wraps $fetch with SSR awareness.
- Returns `{ data, pending, error, refresh, status }` — refs.
- Runs on server during SSR, hydrates result on client. No double-fetch.
- Watches reactive sources in `url` or `options.query` automatically;
refetches on change.
useAsyncData:
- `useAsyncData(key, asyncFn, options)` — for any async work, not just
HTTP.
- Use when fetching from a JS SDK / direct DB call (in server routes)
where useFetch doesn't apply.
Client-only fetches:
- `useFetch(url, { server: false })` for data needed only after
hydration (per-user dashboards where SSR doesn't help).
- `useLazyFetch(url, options)` returns immediately with `pending=true`,
UI shows skeleton, data populates when ready. Good for non-critical
sections.
Plain `fetch(url)` and bare `$fetch(url)` in `<script setup>` are bugs.
They run twice (SSR + hydration) and the SSR result is discarded.
Allowed only:
- Inside event handlers (`onClick`, `onSubmit`) — user-initiated calls.
- Inside server routes (`server/api/*.ts`).
- Inside Pinia actions / composables that are never called during
initial render.
Payload shaping:
- `transform: (data) => data.items` — strip wrapper objects.
- `pick: ['id', 'title', 'slug']` — keep only fields used by the UI.
- These run on the server BEFORE serialization; the wire payload
shrinks proportionally.
Cache keys for invalidation:
- `refresh()` re-runs the fetch.
- `clearNuxtData('posts-list')` for explicit cache busting from
elsewhere.
Server routes (server/api/*.ts):
- One handler per route file: `export default defineEventHandler(...)`.
- Validate input with Zod / valibot at the top.
- Return typed responses; Nuxt infers them on the client.
- For DB access, import from `~/server/utils/db.ts` — never expose
client-side.
useState for SSR-safe shared state:
- `const counter = useState('counter', () => 0);` — SSR-rendered,
hydrated, shared across components.
- Use for cross-component state that doesn't deserve a Pinia store.
- Module-level `const state = ref(0)` is an SSR LEAK across requests.
- Always provide an initializer function so SSR isolation works.
Error handling:
- `useFetch` errors populate `error.value`; render an error UI when it's
truthy.
- For 4xx/5xx that should produce an error page, throw
`createError({ statusCode: 404, statusMessage: 'Not found' })`.
- Wrap critical server work in try/catch within the asyncFn, then
surface a typed error.
`onMounted(() => fetchPosts())` is a Nuxt anti-pattern. It bypasses
SSR, causes layout shift, and breaks SEO. Move into `useFetch`
without `server: false` for SSR rendering.
Hydration mismatches:
- Don't render `Date.now()` / `Math.random()` directly. Use `useId`
for stable IDs; compute time-relative values inside `<ClientOnly>`.
- `useNuxtApp().payload.serverRendered` for explicit server checks.
Before — plain fetch in script setup, no SSR, double-fetch, no key:
<script setup>
import { ref, onMounted } from 'vue';
const posts = ref([]);
onMounted(async () => {
const r = await fetch('/api/posts');
posts.value = await r.json();
});
</script>
<template>
<ul><li v-for="p in posts" :key="p.id">{{ p.title }}</li></ul>
</template>
Empty list during SSR. Flashes empty on hydration. Refetches on every navigation. SEO crawlers see nothing.
After — useFetch with key, transform, error UI:
<script setup lang="ts">
interface Post { id: number; title: string; slug: string }
const { data: posts, error, pending, refresh } = await useFetch<Post[]>(
'/api/posts',
{
key: 'posts-list',
transform: (raw: { items: Post[] }) => raw.items,
pick: ['id', 'title', 'slug'] as never, // type assertion for pick
}
);
</script>
<template>
<p v-if="pending">Loading…</p>
<p v-else-if="error" role="alert">
Failed to load posts. <button @click="refresh">Retry</button>
</p>
<ul v-else>
<li v-for="p in posts ?? []" :key="p.id">
<NuxtLink :to="`/posts/${p.slug}`">{{ p.title }}</NuxtLink>
</li>
</ul>
</template>
Server-rendered first paint. No double-fetch. Wire payload shrunk via pick. Error/loading UIs explicit.
Rule 7: Vue Router 4 With Typed Routes — File-Based Or Unplugin-Vue-Router, definePageMeta In Nuxt
Vue Router 4 in raw form is string-typed: <RouterLink to="/users/42">, router.push({ name: 'user-detail', params: { id: '42' } }) where name and params are both string. Cursor will type-check the JSX wrapping and miss that the route name is a typo until the user clicks the link in production. unplugin-vue-router (and Nuxt 3's built-in equivalent) generates types from the file system so route names, params, and query parsing are compile-time checked. The rule: routes are file-based or registered via unplugin-vue-router for type safety, navigation uses the typed <RouterLink :to="{name: '...'}"> form, components are lazy-loaded via dynamic imports, route guards live in dedicated middleware files, definePageMeta configures layout/middleware/auth in Nuxt.
The rule:
Routes are FILE-BASED:
- Nuxt 3: pages/ directory (built-in).
- Vite + Vue: unplugin-vue-router with src/pages/.
- Vue CLI legacy: migrate to Vite.
The plugin generates typed route names and params. Import from
'vue-router/auto-routes' (unplugin) or use Nuxt's typed
`navigateTo(...)`.
Navigation uses object form with named routes, not string paths:
// good
<RouterLink :to="{ name: 'user-detail', params: { id: user.id }}">…</RouterLink>
router.push({ name: 'user-detail', params: { id: 42 }});
navigateTo({ name: 'user-detail', params: { id: 42 }}); // Nuxt
// bad — typos pass typecheck
<RouterLink :to="`/users/${user.id}`">…</RouterLink>
router.push(`/users/${id}`);
useRoute() / useRouter() in <script setup>; never `this.$route` /
`this.$router` (that's Options API).
Route params are typed once routes are typed:
const route = useRoute('user-detail');
const id = route.params.id; // string, narrowed by route name
Query params are validated with Zod at the top of the page component
that uses them — coerce strings to the types you actually want.
Lazy-load route components with dynamic imports (file-based routing
does this for you). For manual route definitions:
{ path: '/admin', component: () => import('./pages/Admin.vue') }
This keeps the initial bundle small.
Route guards: GLOBAL guards in `router.beforeEach` registered in the
router setup (or `middleware/` in Nuxt). PER-ROUTE guards via
`beforeEnter` in route definitions. NEVER `beforeRouteEnter` /
`beforeRouteLeave` inline in a component — that's Options API and
breaks TypeScript inference in <script setup>.
Nuxt middleware: `middleware/auth.ts` registered globally or via
`definePageMeta({ middleware: 'auth' })`:
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const user = useUserStore();
if (!user.isLoggedIn) return navigateTo('/login');
});
definePageMeta is the ONLY place to set per-page layout, middleware,
keepalive, transition, or pageTransition:
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin'],
pageTransition: { name: 'fade' },
});
Programmatic navigation:
await navigateTo({ name: 'user-detail', params: { id }});
In Nuxt, `navigateTo` is preferred over `useRouter().push` because it
also handles redirects in middleware.
External links: `navigateTo('https://...', { external: true })`. Without
`external: true`, Nuxt will treat external URLs as relative.
Scroll behavior: configured once in `app/router.options.ts` (Nuxt) or
the router setup. Never per-component.
Query-driven UI state (search, filters):
- Read via `route.query.q`, parsed/validated.
- Update via `router.replace({ query: { ...route.query, q: value }})`
— `replace` not `push` to avoid bloating history.
- Debounce the input handler that calls replace().
`<NuxtLink>` (Nuxt) and `<RouterLink>` (raw Vue Router) — never
`<a href="/internal">` for internal nav, that triggers a full reload
and skips prefetching.
Prefetching: `<NuxtLink>` prefetches by default in production. Disable
with `prefetch="false"` for very large rarely-visited pages.
Before — string paths, inline guard, no types, full reload links:
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
function goToUser(id) {
router.push(`/users/${id}`); // typo passes
}
// Options-API guard mixed into setup component
onBeforeRouteEnter((to, from, next) => {
if (!isAuthed()) next('/login');
else next();
});
</script>
<template>
<a href="/dashboard">Dashboard</a> <!-- full page reload -->
</template>
String paths fragile. Inline guard breaks types. <a> does a full reload, killing performance.
After — typed routes, middleware file, NuxtLink, named navigation:
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
layout: 'default',
});
const route = useRoute('users-id');
const id = computed(() => Number(route.params.id));
async function goToOrders() {
await navigateTo({ name: 'users-id-orders', params: { id: id.value }});
}
</script>
<template>
<NuxtLink :to="{ name: 'dashboard' }">Dashboard</NuxtLink>
<button @click="goToOrders">View orders</button>
</template>
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const user = useUserStore();
if (!user.isLoggedIn) return navigateTo('/login');
});
Route names checked at compile time. Middleware reusable. Navigation type-safe. Internal links use NuxtLink with prefetch.
Rule 8: Testing — Vitest For Units, @vue/test-utils + Vue Testing Library For Components, Playwright For E2E, MSW For Mocks
Vue's testing story consolidated around Vitest in 2023 — fast, ESM-native, Vite-integrated, Jest-compatible API. Component tests use @vue/test-utils for low-level mount control combined with Vue Testing Library for accessible assertions. End-to-end is Playwright (better than Cypress for SSR-driven Nuxt apps). API mocking is MSW so the same handlers cover unit tests (Node) and Playwright (browser). Cursor defaults to Jest + @vue/test-utils.shallowMount + class-name selectors — workable, but every refactor breaks the assertions. The rule: Vitest, full mount with role-based selectors, MSW for HTTP, Playwright for the user-facing flows, no shallow mount as the default.
The rule:
Unit / component tests: Vitest. Jest is legacy and migrated on touch.
- jsdom environment for component tests, node for pure-logic tests.
- vite.config.ts (or vitest.config.ts) configures the same Vue
plugin, aliases, and CSS handling as production.
- setupFiles include MSW handlers + Testing Library extends.
Component tests: prefer Vue Testing Library on top of @vue/test-utils.
- `mount(Component, { props, slots, global: {...} })` from @vue/test-
utils — not `shallowMount`. Shallow mounts hide integration bugs
and force you to mock children that didn't need mocking.
- User interactions via @testing-library/user-event (`await
user.click(...)`, `await user.type(...)`). NEVER fireEvent for
new tests.
- Selectors by ROLE / accessible name:
screen.getByRole('button', { name: /save/i })
screen.getByLabelText('Email')
screen.getByText(/welcome/i)
- Avoid class selectors and data-testid except as last resort.
`flushPromises` from @vue/test-utils for awaiting reactive flushes
between user actions and assertions — Vitest's `await nextTick()` works
for one tick, but a chain of computed → watch → render needs
flushPromises.
Snapshot tests: sparingly, for stable presentational components
(empty states, icons). Snapshots of complex stateful components churn
on every refactor and add no signal.
Composables: tested as pure functions. No mount needed.
import { useCounter } from '@/composables/useCounter';
test('increments', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
For composables that use lifecycle hooks (`onMounted`), wrap in a
test component:
import { mount } from '@vue/test-utils';
const TestComponent = defineComponent({
setup() { return useThing(); },
template: '<div />',
});
Pinia stores: `setActivePinia(createPinia())` in beforeEach. Then call
the store and assert directly. No mount required.
API mocks: MSW (msw) handlers shared between Vitest (node worker) and
Playwright (browser worker). Handlers live in `tests/mocks/handlers.ts`.
Server-side rendering tests: Nuxt provides `@nuxt/test-utils/e2e`. Use
`setup({ host, browser })` to spin up an isolated Nuxt app per test.
For unit-style tests, `mountSuspended` mounts Nuxt-aware components
(useFetch / useAsyncData / useState all work).
Playwright:
- playwright.config.ts with projects for chromium / firefox / webkit.
- baseURL pointed at http://localhost:3000.
- webServer starts `nuxt dev` (or built `node .output/server/index`).
- Role-based selectors from `page.getByRole`, `page.getByLabel`.
- `expect(locator).toBeVisible()` over `toHaveCount(1)` — captures
intent.
- `page.waitForResponse(/api\/posts/)` when testing streaming /
progressive enhancement.
Vue Router in tests: createRouter + createMemoryHistory; install via
`global.plugins`. Don't navigate via `router.push` and assert on
Document — use Playwright for routing flows.
Storybook 8 for visual catalogue + component-level interaction tests.
The Storybook test runner exports stories as Playwright tests; gives
you visual regression for free.
Coverage:
- composables/, stores/, utils/: >90%.
- components/: >70% combined (component + e2e).
- Every store action: happy + error path test minimum.
CI:
- Vitest on every PR (parallelized).
- Playwright on every PR with sharding by test file.
- Storybook test runner on every PR for changed stories.
Before — shallowMount, class selectors, fireEvent, no MSW:
import { shallowMount } from '@vue/test-utils';
import UserCard from '@/components/UserCard.vue';
test('renders name', () => {
const wrapper = shallowMount(UserCard, {
propsData: { user: { name: 'Ada' }}, // Vue 2 syntax
});
expect(wrapper.find('.name').text()).toBe('Ada');
wrapper.find('.edit-btn').trigger('click');
expect(wrapper.vm.editing).toBe(true);
});
Brittle CSS selector. Reaches into private vm.editing. Vue 2 propsData. Children stubbed and missing integration bugs.
After — Vitest + RTL + MSW + role-based assertions:
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) =>
HttpResponse.json({ id: Number(params.id), name: 'Ada Lovelace' })
),
];
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './tests/mocks/handlers';
import '@testing-library/jest-dom/vitest';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// components/UserCard.test.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import UserCard from './UserCard.vue';
test('user can edit and save the name', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(UserCard, {
props: { user: { id: 1, name: 'Ada' }, editable: true },
attrs: { onSave },
});
expect(screen.getByText('Ada')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /edit/i }));
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'Ada L.');
await user.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Ada L.' }));
});
// e2e/profile.spec.ts
import { test, expect } from '@playwright/test';
test('profile edit flow', async ({ page }) => {
await page.goto('/profile');
await page.getByRole('button', { name: /edit/i }).click();
await page.getByLabel('Name').fill('Ada Lovelace');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByText('Profile saved')).toBeVisible();
});
Role-based selectors survive markup refactors. MSW handlers are shared. Component test exercises real interaction via user-event. Playwright covers the full SSR flow.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Vue 3 + Nuxt 3 — Production Patterns
## Components
- Every component is <script setup lang="ts"> Composition API.
- No Options API: no data()/methods/computed/watch blocks.
- No mixins, no Vue.extend(), no extends. Use composables.
- Lifecycle via onMounted/onBeforeUnmount/onUpdated imported from 'vue'.
- useTemplateRef() (3.5+) over manual ref + DOM ref attribute.
- defineExpose only when a parent must call child methods.
- useId() for stable SSR-safe IDs; never Math.random().
- Generic components via <script setup lang="ts" generic="T">.
## Props, Emits, Models
- Always defineProps<{...}>() generics syntax; never runtime arrays.
- withDefaults for optional defaults.
- defineEmits<{ event: [args] }>() tuple form.
- Props are immutable inside the child — never mutate props.foo.
- Two-way bindings via defineModel<T>() (Vue 3.4+); never manual
modelValue/update:modelValue boilerplate.
- defineSlots<{...}>() for typed slot props.
## Reactivity
- ref by default for primitives, objects, arrays.
- reactive only for long-lived mutable objects passed around.
- toRefs / toRef at composable boundaries to keep destructured refs
reactive.
- computed for derivations (read-only by default; getter/setter form
for writable).
- watch for explicit reactions; watchEffect for "track whatever I read".
- { deep: true } is a code smell — fix the data shape.
- { immediate: true } over duplicating body in onMounted.
- shallowRef / shallowReactive for large frozen data.
- markRaw for class instances / non-reactive objects.
- Never Object.assign(state, ...) to "force reactivity" — fix
destructuring instead.
## Pinia
- defineStore('id', () => { ... }) setup syntax only.
- One store per feature scope; no mega-stores.
- storeToRefs() when destructuring state/getters.
- Plain destructure for actions (functions stay reactive without).
- Stores can call other stores; Pinia handles ordering.
- pinia-plugin-persistedstate for selective persistence.
- @pinia/nuxt module for SSR-safe store hydration.
- Vuex (any version) is forbidden in new code.
## Composables
- composables/use*.ts (Nuxt auto-imports).
- Return refs / computeds / functions; never plain values.
- Cleanup via onScopeDispose / tryOnScopeDispose, not just
onBeforeUnmount.
- effectScope() for shared composables that need group disposal.
- VueUse first; hand-roll only when not covered.
- Composables are factories (fresh state each call), not singletons.
- Singletons via Pinia or useState (Nuxt), never module-level refs
(SSR leak).
- Async composables return { data, error, pending, refresh } shape.
## Nuxt 3 Data & SSR
- useFetch / useAsyncData with EXPLICIT key on every call.
- transform / pick to shrink the SSR payload.
- $fetch only inside event handlers and server routes.
- Plain fetch() in <script setup> is a bug (double-fetch).
- useState('key', () => initial) for SSR-safe shared state.
- Module-level refs leak across SSR requests — never.
- server: false for client-only fetches; useLazyFetch for non-critical.
- Validate input in server/api/*.ts with Zod / valibot.
- createError({ statusCode, statusMessage }) for error pages.
- Don't render Date.now()/Math.random() outside <ClientOnly>.
## Routing
- File-based: pages/ (Nuxt) or unplugin-vue-router (Vite).
- Navigation: object form with named routes, never string paths.
- useRoute('route-name') for typed params.
- Lazy-load route components via dynamic imports.
- Global guards in router.beforeEach or middleware/*.ts.
- definePageMeta for per-page layout / middleware / transition.
- navigateTo over useRouter().push in Nuxt (handles middleware
redirects).
- <NuxtLink> / <RouterLink> for internal links, never <a href>.
- Query-driven UI state via router.replace + Zod-validated query.
## Testing
- Vitest for units / components.
- @vue/test-utils mount (NOT shallowMount) + Vue Testing Library.
- Role-based selectors; data-testid is a last resort.
- @testing-library/user-event over fireEvent.
- flushPromises for chained reactive updates.
- MSW handlers shared between Vitest (node) and Playwright (browser).
- Composables tested as pure functions; lifecycle ones via wrapper
component.
- Pinia stores tested with createPinia + setActivePinia per test.
- @nuxt/test-utils mountSuspended for Nuxt-aware components.
- Playwright for e2e with chromium/firefox/webkit projects.
- Storybook 8 + test runner for visual regression.
- Snapshots only for stable presentational components.
- Coverage: >90% composables/stores/utils, >70% components.
- CI: Vitest + Playwright + Storybook test runner on every PR.
End-to-End Example: A Nuxt 3 Profile Page With Form, Composable, Pinia Store, And SSR Data
Without rules: Options-API-style defineComponent, plain fetch in setup, prop mutation in form, Vuex-style action names, no types, no SSR awareness.
<script>
import { defineComponent, ref, onMounted } from 'vue';
import { useUserStore } from '@/stores/user';
export default defineComponent({
props: ['userId'],
setup(props) {
const user = ref(null);
const userStore = useUserStore();
onMounted(async () => {
const r = await fetch(`/api/users/${props.userId}`);
user.value = await r.json();
});
function save() {
fetch(`/api/users/${props.userId}`, {
method: 'PUT',
body: JSON.stringify(user.value),
});
userStore.SET_USER(user.value);
}
return { user, save };
},
});
</script>
<template>
<form @submit.prevent="save" v-if="user">
<input v-model="user.name" />
<input v-model="user.email" />
<button type="submit">Save</button>
</form>
</template>
No SSR, fires on the client only. Untyped props. Direct mutation of user.value from v-model mixed with the prop concern. Vuex-style mutation name. No loading/error UI. No validation.
With rules: typed page, useFetch with key, defineModel form child, Pinia setup store, composable for save flow, server route with Zod, role-tested.
<!-- pages/profile/[id].vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';
import { useUpdateUser } from '@/composables/useUpdateUser';
definePageMeta({ middleware: 'auth' });
const route = useRoute('profile-id');
const id = computed(() => Number(route.params.id));
interface User { id: number; name: string; email: string }
const { data: user, error, refresh } = await useFetch<User>(
() => `/api/users/${id.value}`,
{ key: `user:${id.value}`, pick: ['id', 'name', 'email'] as never }
);
const userStore = useUserStore();
const { setUser } = userStore;
const { saving, fieldErrors, save } = useUpdateUser({ onSuccess: setUser });
</script>
<template>
<p v-if="error" role="alert">
Failed to load profile. <button @click="refresh">Retry</button>
</p>
<ProfileForm
v-else-if="user"
:user="user"
:saving="saving"
:field-errors="fieldErrors"
@save="(draft) => save(id, draft)"
/>
</template>
<!-- components/ProfileForm.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue';
interface User { id: number; name: string; email: string }
const props = defineProps<{
user: User;
saving: boolean;
fieldErrors: Partial<Record<'name' | 'email', string>>;
}>();
const emit = defineEmits<{ save: [draft: Pick<User, 'name' | 'email'>] }>();
const draft = ref({ name: props.user.name, email: props.user.email });
watch(() => props.user, (u) => { draft.value = { name: u.name, email: u.email }; });
function onSubmit() { emit('save', draft.value); }
const nameId = useId();
const emailId = useId();
</script>
<template>
<form @submit.prevent="onSubmit" novalidate>
<label :for="nameId">Name</label>
<input :id="nameId" v-model="draft.name" :aria-invalid="!!fieldErrors.name" />
<p v-if="fieldErrors.name" role="alert">{{ fieldErrors.name }}</p>
<label :for="emailId">Email</label>
<input :id="emailId" v-model="draft.email" type="email"
:aria-invalid="!!fieldErrors.email" />
<p v-if="fieldErrors.email" role="alert">{{ fieldErrors.email }}</p>
<button type="submit" :disabled="saving">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</form>
</template>
// composables/useUpdateUser.ts
import { ref } from 'vue';
interface User { id: number; name: string; email: string }
type FieldErrors = Partial<Record<'name' | 'email', string>>;
export function useUpdateUser(opts: { onSuccess?: (u: User) => void } = {}) {
const saving = ref(false);
const fieldErrors = ref<FieldErrors>({});
async function save(id: number, draft: Pick<User, 'name' | 'email'>) {
saving.value = true;
fieldErrors.value = {};
try {
const updated = await $fetch<User>(`/api/users/${id}`, {
method: 'PUT', body: draft,
});
opts.onSuccess?.(updated);
} catch (err: any) {
if (err?.data?.fieldErrors) fieldErrors.value = err.data.fieldErrors;
else throw err;
} finally {
saving.value = false;
}
}
return { saving, fieldErrors, save };
}
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface User { id: number; name: string; email: string }
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => user.value !== null);
function setUser(u: User) { user.value = u; }
function clear() { user.value = null; }
return { user, isLoggedIn, setUser, clear };
});
// server/api/users/[id].put.ts
import { z } from 'zod';
const Schema = z.object({
name: z.string().min(1).max(80),
email: z.string().email(),
});
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'));
const body = await readBody(event);
const parsed = Schema.safeParse(body);
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation failed',
data: { fieldErrors: parsed.error.flatten().fieldErrors },
});
}
const updated = await db.user.update({ where: { id }, data: parsed.data });
return updated;
});
// middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const { isLoggedIn } = useUserStore();
if (!isLoggedIn) return navigateTo('/login');
});
Server-rendered profile, accessible form with useId-stable label associations, draft state isolated from props, composable encapsulates the save flow with typed field errors, Pinia store updated via a real action, server route validates with Zod and returns structured field errors that the UI surfaces — every layer typed, no double-fetches, no prop mutation.
Get the Full Pack
These eight rules cover the Vue 3 + Nuxt 3 patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — Composition-API-default, prop-immutable, reactivity-disciplined, Pinia-scoped, useFetch-cached, route-typed, role-tested Vue, without having to re-prompt.
If you want the expanded pack — these eight plus rules for Nuxt 3 server routes with defineEventHandler + Zod + rate limiting, advanced useFetch / useAsyncData patterns (request dedupe, watch sources, transform pipelines), Pinia + Drizzle/Prisma integration with optimistic updates, VueUse mastery (createSharedComposable, useStorage typed schemas, useEventBus patterns), Vitest + @vue/test-utils deep dives (mounting Suspense, async components, teleport), Storybook 8 with Vue 3 generics + autodocs, internationalization with @nuxtjs/i18n (lazy locales, route prefixing, SEO), @nuxt/image configuration (provider setup, responsive sources, IPX for self-hosting), accessibility with vue-axe + Storybook a11y, animation patterns with <Transition> / <TransitionGroup> + @vueuse/motion, the Nitro server presets I use (Vercel Edge, Cloudflare Workers, Deno Deploy), and the Tailwind + Vue 3 patterns that don't fight <script setup> — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Vue you would actually merge.
Top comments (0)