Vue Expert
Turns Claude into a senior Vue 3.5+/Nuxt 4 engineer who writes idiomatic <script setup lang="ts"> code, designs reactive state that survives SSR, and never ships Options API or Vue 2 idioms.
When to Use This Skill
- Build or modify Vue single-file components (
.vue) with Composition API and TypeScript
- Design composables (
useX) with correct reactivity, cleanup, and SSR safety
- Create or refactor Pinia stores (setup-store style) and wire them into components
- Add Nuxt 4 pages, layouts, middleware, or Nitro server routes (
server/api/)
- Debug reactivity bugs: lost reactivity from destructuring, stale watchers,
shallowRef misuse
- Fix SSR/hydration mismatches and data-fetching duplication in Nuxt
- Write component and composable tests with Vitest + Vue Testing Library
Core Workflow
- Analyze - Inspect the project before writing anything: check
package.json for Vue/Nuxt/Pinia versions, whether it is a Nuxt app (nuxt.config.ts) or plain Vite SPA, auto-import config, existing composable/store conventions, and the test runner setup (vitest.config.ts, existing *.spec.ts). Match existing directory conventions (app/ vs src/ in Nuxt 4).
- Implement - Write
<script setup lang="ts"> only. Use defineProps/defineEmits with type-only declarations, defineModel() for two-way binding, ref/computed for state, composables for shared logic, Pinia setup stores for app state. In Nuxt, fetch data with useFetch/useAsyncData (never bare $fetch in setup) and put API logic in Nitro routes under server/. Load the matching reference file from the Reference Guide before writing unfamiliar patterns.
- Verify types and lint - Run
pnpm vue-tsc --noEmit (or pnpm nuxi typecheck in Nuxt) and pnpm eslint .; fix all reported issues and re-run until clean before proceeding.
- Test - Write or update Vitest + Vue Testing Library tests for the change (render, interact via
@testing-library/user-event, assert on the DOM). Run pnpm vitest run; fix all failures and re-run until clean before proceeding.
- Prove it works - Run the dev server (
pnpm dev) and exercise the changed flow, or for Nuxt SSR changes run pnpm build && node .output/server/index.mjs and confirm no hydration warnings in the console. If anything fails, fix it and re-verify (steps 3-4) until clean.
Reference Guide
Load detailed guidance only when the task needs it:
| Topic |
Reference |
Load When |
| Script setup, props/emits/defineModel, ref vs reactive vs shallowRef, composables design |
references/composition-api.md |
Writing or reviewing any .vue component or useX composable |
| Pinia setup stores, storeToRefs, SSR state, testing stores |
references/state-pinia.md |
Task touches app-level state, a stores/ directory, or defineStore |
| Nuxt 4 structure, useFetch/useAsyncData, Nitro server routes, middleware, runtime config |
references/nuxt-patterns.md |
Project has nuxt.config.ts or task mentions Nuxt, server routes, or data fetching |
| Reactivity edge cases: destructuring, watch vs watchEffect, hydration mismatches |
references/reactivity-pitfalls.md |
Debugging "not updating"/"fires twice"/hydration warnings, or writing watchers |
| Vitest + Vue Testing Library setup, component/composable/Nuxt testing |
references/testing.md |
Writing or fixing any test for Vue/Nuxt code |
Key Patterns
Typed props with defaults and defineModel (Vue 3.5+) - no withDefaults needed; props can be destructured reactively:
<script setup lang="ts">
const { label, size = 'md' } = defineProps<{ label: string; size?: 'sm' | 'md' | 'lg' }>()
const modelValue = defineModel<string>({ required: true })
const emit = defineEmits<{ submit: [value: string] }>()
</script>
<template>
<input v-model="modelValue" :placeholder="label" @keyup.enter="emit('submit', modelValue)" />
</template>
Composable returning refs, with cleanup - return plain refs (not a reactive bag), accept MaybeRefOrGetter, clean up with onScopeDispose:
import { ref, toValue, watchEffect, onScopeDispose, type MaybeRefOrGetter } from 'vue'
export function useEventSource(url: MaybeRefOrGetter<string>) {
const data = ref<string | null>(null)
const status = ref<'connecting' | 'open' | 'closed'>('connecting')
let es: EventSource | undefined
watchEffect(() => {
es?.close()
es = new EventSource(toValue(url))
es.onmessage = (e) => (data.value = e.data)
es.onopen = () => (status.value = 'open')
})
onScopeDispose(() => es?.close())
return { data, status }
}
Pinia setup store - ref = state, computed = getters, functions = actions; always return everything used outside:
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
async function add(productId: string) {
const item = await $fetch<CartItem>(`/api/cart/${productId}`, { method: 'POST' })
items.value.push(item)
}
return { items, total, add }
})
Nuxt data fetching + typed server route - types flow from the Nitro handler to the client automatically:
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const product = await db.product.findUnique({ where: { id } })
if (!product) throw createError({ statusCode: 404, statusMessage: 'Not found' })
return product
})
<script setup lang="ts">
const route = useRoute()
const { data: product, status, error } = await useFetch(`/api/products/${route.params.id}`)
</script>
Common Mistakes
- Destructuring
reactive() or a store kills reactivity. const { count } = reactive(...) or const { items } = useCartStore() yields dead plain values. Use toRefs/storeToRefs for state - but destructure actions directly (they are plain functions). Props destructured from defineProps ARE reactive in 3.5+, but only inside the same <script setup>; pass a getter (() => size) when handing them to watch or a composable.
ref vs reactive vs shallowRef chosen wrong. Default to ref for everything, including objects (.value is uniform and survives reassignment). reactive cannot be reassigned or destructured - reserve it for a fixed-shape local object you never replace. Use shallowRef for large immutable payloads (API responses, editor/map instances) and trigger updates by replacing .value wholesale; mutating deep properties of a shallowRef silently does nothing.
watch given a .value or plain property watches nothing. watch(count.value, ...) and watch(props.id, ...) pass a snapshot. Watch the ref itself (watch(count, ...)) or a getter (watch(() => props.id, ...)). Prefer watch with explicit sources over watchEffect for async work - watchEffect only tracks dependencies read before the first await.
- Fetching in Nuxt with bare
$fetch in setup double-fetches. It runs on the server and again on the client with no payload transfer. Use useFetch/useAsyncData in components; use $fetch only inside event handlers and server code.
- Options API,
Vue.observable, mixins, this.$emit, filters - Vue 2/Options idioms that dominate training data. Do not emit them in new code; only touch them in explicit migration tasks (map data to refs, computed options to computed(), mixins to composables).
- Hydration mismatch from browser-only or random values in render.
Date.now(), Math.random(), window.*, or locale-dependent formatting in templates renders differently on server and client. Gate with <ClientOnly>, useId() for stable IDs, or compute in onMounted.
- Testing implementation details via
wrapper.vm or shallow mounting. With Vue Testing Library, query by role/text as a user would, use @testing-library/user-event, and await the interaction; assert emitted events via emitted() only when the DOM cannot show the outcome.
1---2name: vue-expert3description: Use when working with .vue single-file components, nuxt.config.ts, vite.config.ts with @vitejs/plugin-vue, Pinia stores, or tasks mentioning Vue, Vue 3, Nuxt, Composition API, script setup, ref/reactive, composables, useFetch, or SSR hydration. Builds Vue 3.5+/Nuxt 4 features, designs composables and Pinia setup stores, debugs reactivity and hydration bugs, and writes Vitest + Vue Testing Library tests. Invoke for component implementation, state management, Nuxt server routes, reactivity debugging, SSR fixes, and component testing.4license: MIT5---67# Vue Expert89Turns Claude into a senior Vue 3.5+/Nuxt 4 engineer who writes idiomatic `<script setup lang="ts">` code, designs reactive state that survives SSR, and never ships Options API or Vue 2 idioms.1011## When to Use This Skill1213- Build or modify Vue single-file components (`.vue`) with Composition API and TypeScript14- Design composables (`useX`) with correct reactivity, cleanup, and SSR safety15- Create or refactor Pinia stores (setup-store style) and wire them into components16- Add Nuxt 4 pages, layouts, middleware, or Nitro server routes (`server/api/`)17- Debug reactivity bugs: lost reactivity from destructuring, stale watchers, `shallowRef` misuse18- Fix SSR/hydration mismatches and data-fetching duplication in Nuxt19- Write component and composable tests with Vitest + Vue Testing Library2021## Core Workflow22231. **Analyze** - Inspect the project before writing anything: check `package.json` for Vue/Nuxt/Pinia versions, whether it is a Nuxt app (`nuxt.config.ts`) or plain Vite SPA, auto-import config, existing composable/store conventions, and the test runner setup (`vitest.config.ts`, existing `*.spec.ts`). Match existing directory conventions (`app/` vs `src/` in Nuxt 4).242. **Implement** - Write `<script setup lang="ts">` only. Use `defineProps`/`defineEmits` with type-only declarations, `defineModel()` for two-way binding, `ref`/`computed` for state, composables for shared logic, Pinia setup stores for app state. In Nuxt, fetch data with `useFetch`/`useAsyncData` (never bare `$fetch` in setup) and put API logic in Nitro routes under `server/`. Load the matching reference file from the Reference Guide before writing unfamiliar patterns.253. **Verify types and lint** - Run `pnpm vue-tsc --noEmit` (or `pnpm nuxi typecheck` in Nuxt) and `pnpm eslint .`; fix all reported issues and re-run until clean before proceeding.264. **Test** - Write or update Vitest + Vue Testing Library tests for the change (render, interact via `@testing-library/user-event`, assert on the DOM). Run `pnpm vitest run`; fix all failures and re-run until clean before proceeding.275. **Prove it works** - Run the dev server (`pnpm dev`) and exercise the changed flow, or for Nuxt SSR changes run `pnpm build && node .output/server/index.mjs` and confirm no hydration warnings in the console. If anything fails, fix it and re-verify (steps 3-4) until clean.2829## Reference Guide3031Load detailed guidance only when the task needs it:3233| Topic | Reference | Load When |34|-------|-----------|-----------|35| Script setup, props/emits/defineModel, ref vs reactive vs shallowRef, composables design | `references/composition-api.md` | Writing or reviewing any `.vue` component or `useX` composable |36| Pinia setup stores, storeToRefs, SSR state, testing stores | `references/state-pinia.md` | Task touches app-level state, a `stores/` directory, or `defineStore` |37| Nuxt 4 structure, useFetch/useAsyncData, Nitro server routes, middleware, runtime config | `references/nuxt-patterns.md` | Project has `nuxt.config.ts` or task mentions Nuxt, server routes, or data fetching |38| Reactivity edge cases: destructuring, watch vs watchEffect, hydration mismatches | `references/reactivity-pitfalls.md` | Debugging "not updating"/"fires twice"/hydration warnings, or writing watchers |39| Vitest + Vue Testing Library setup, component/composable/Nuxt testing | `references/testing.md` | Writing or fixing any test for Vue/Nuxt code |4041## Key Patterns4243**Typed props with defaults and defineModel (Vue 3.5+)** - no `withDefaults` needed; props can be destructured reactively:4445```vue46<script setup lang="ts">47const { label, size = 'md' } = defineProps<{ label: string; size?: 'sm' | 'md' | 'lg' }>()48const modelValue = defineModel<string>({ required: true })49const emit = defineEmits<{ submit: [value: string] }>()50</script>5152<template>53 <input v-model="modelValue" :placeholder="label" @keyup.enter="emit('submit', modelValue)" />54</template>55```5657**Composable returning refs, with cleanup** - return plain refs (not a `reactive` bag), accept `MaybeRefOrGetter`, clean up with `onScopeDispose`:5859```ts60import { ref, toValue, watchEffect, onScopeDispose, type MaybeRefOrGetter } from 'vue'6162export function useEventSource(url: MaybeRefOrGetter<string>) {63 const data = ref<string | null>(null)64 const status = ref<'connecting' | 'open' | 'closed'>('connecting')65 let es: EventSource | undefined6667 watchEffect(() => {68 es?.close()69 es = new EventSource(toValue(url))70 es.onmessage = (e) => (data.value = e.data)71 es.onopen = () => (status.value = 'open')72 })73 onScopeDispose(() => es?.close())7475 return { data, status }76}77```7879**Pinia setup store** - `ref` = state, `computed` = getters, functions = actions; always return everything used outside:8081```ts82export const useCartStore = defineStore('cart', () => {83 const items = ref<CartItem[]>([])84 const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))85 async function add(productId: string) {86 const item = await $fetch<CartItem>(`/api/cart/${productId}`, { method: 'POST' })87 items.value.push(item)88 }89 return { items, total, add }90})91```9293**Nuxt data fetching + typed server route** - types flow from the Nitro handler to the client automatically:9495```ts96// server/api/products/[id].get.ts97export default defineEventHandler(async (event) => {98 const id = getRouterParam(event, 'id')99 const product = await db.product.findUnique({ where: { id } })100 if (!product) throw createError({ statusCode: 404, statusMessage: 'Not found' })101 return product102})103```104105```vue106<script setup lang="ts">107const route = useRoute()108const { data: product, status, error } = await useFetch(`/api/products/${route.params.id}`)109</script>110```111112## Common Mistakes113114- **Destructuring `reactive()` or a store kills reactivity.** `const { count } = reactive(...)` or `const { items } = useCartStore()` yields dead plain values. Use `toRefs`/`storeToRefs` for state - but destructure actions directly (they are plain functions). Props destructured from `defineProps` ARE reactive in 3.5+, but only inside the same `<script setup>`; pass a getter (`() => size`) when handing them to `watch` or a composable.115- **`ref` vs `reactive` vs `shallowRef` chosen wrong.** Default to `ref` for everything, including objects (`.value` is uniform and survives reassignment). `reactive` cannot be reassigned or destructured - reserve it for a fixed-shape local object you never replace. Use `shallowRef` for large immutable payloads (API responses, editor/map instances) and trigger updates by replacing `.value` wholesale; mutating deep properties of a `shallowRef` silently does nothing.116- **`watch` given a `.value` or plain property watches nothing.** `watch(count.value, ...)` and `watch(props.id, ...)` pass a snapshot. Watch the ref itself (`watch(count, ...)`) or a getter (`watch(() => props.id, ...)`). Prefer `watch` with explicit sources over `watchEffect` for async work - `watchEffect` only tracks dependencies read before the first `await`.117- **Fetching in Nuxt with bare `$fetch` in setup double-fetches.** It runs on the server and again on the client with no payload transfer. Use `useFetch`/`useAsyncData` in components; use `$fetch` only inside event handlers and server code.118- **Options API, `Vue.observable`, mixins, `this.$emit`, filters** - Vue 2/Options idioms that dominate training data. Do not emit them in new code; only touch them in explicit migration tasks (map `data` to `ref`s, `computed` options to `computed()`, mixins to composables).119- **Hydration mismatch from browser-only or random values in render.** `Date.now()`, `Math.random()`, `window.*`, or locale-dependent formatting in templates renders differently on server and client. Gate with `<ClientOnly>`, `useId()` for stable IDs, or compute in `onMounted`.120- **Testing implementation details via `wrapper.vm` or shallow mounting.** With Vue Testing Library, query by role/text as a user would, use `@testing-library/user-event`, and `await` the interaction; assert emitted events via `emitted()` only when the DOM cannot show the outcome.