Ask Claude Code to "add a user dashboard" to a Nuxt 3 app and the default output is a 2018 Vue tutorial: an Options API component, a Vuex store wired with mutations, mounted() doing a $fetch, v-if and v-for on the same element, and a prop being mutated three layers deep. None of it is broken — all of it is wrong for a Vue 3 + Nuxt 3 app shipped in 2026.
The fix is not a longer prompt. It is a CLAUDE.md at the repo root — the file Claude reads on every turn — encoding the patterns that make generated code look like your codebase instead of a Stack Overflow answer from 2019.
Get the full CLAUDE.md Rules Pack for 20+ frameworks — oliviacraftlat.gumroad.com/l/skdgt. The rules below are a free preview.
Here are 13 rules that survive review. Each one started as a recurring failure in real Vue/Nuxt PRs.
1. Composition API + <script setup> only — no Options API, no mixins
Half of Vue's training data is Vue 2. Without a rule, Claude reaches for export default { data() {}, methods: {} } because the surface area looks safer. It is not. Options API does not compose, types poorly, and forces every new feature into one growing object. <script setup> is the canonical modern path: better TypeScript inference, smaller compiled output, imports become the API.
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const { data: user } = await useFetch(`/api/users/${props.userId}`)
</script>
Mixins are banned outright — composables replace them, and they actually type-check.
2. Composables are useX, return refs, never render
A composable is a noun-as-function: useUser(), useCart(), usePagination(). It returns reactive state, never JSX or a <Spinner />. Composability dies the second a composable owns rendering.
// composables/useUser.ts
export function useUser(id: Ref<string>) {
const { data, pending, error } = useFetch(() => `/api/users/${id.value}`)
return { user: data, pending, error }
}
Always return refs (or computeds), never raw values destructured from reactive() — that silently breaks reactivity.
3. Reactivity: ref for primitives, reactive only for fixed shapes
The hidden trap: const state = reactive({ user }) and then const { user } = state — reactivity is gone. Default to ref everywhere. Use reactive only for stable object shapes that never get destructured. When in doubt, ref and .value.
4. Pinia setup-store style, one store per domain
Pinia options syntax is the Vuex 4 hangover. Setup stores compose with everything else in <script setup> and type without ceremony.
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
function add(item: CartItem) { items.value.push(item) }
return { items, total, add }
})
One store per domain (cart, auth, catalog) — never one giant app store. State is ref/reactive, getters are computed, actions are plain functions. No mutations. No modules.
5. Nuxt data fetching: useFetch/useAsyncData on the server, never onMounted
If the data exists at request time, fetch it during SSR. onMounted(() => fetch(...)) waterfalls a request after hydration, blanks the page on first paint, and tanks LCP.
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(
`post-${route.params.id}`,
() => $fetch(`/api/posts/${route.params.id}`)
)
</script>
useFetch for direct URL calls, useAsyncData when you need a custom function. Both deduplicate across server and client and hydrate the payload — no double request.
6. Server routes live in server/api/, validated, typed end-to-end
Every mutation is a Nitro route under server/api/. Validate input with Zod, authorize against the session, return a typed response.
// server/api/posts.post.ts
import { z } from 'zod'
const Body = z.object({ title: z.string().min(1).max(120) })
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
const body = Body.parse(await readBody(event))
return await db.post.create({ data: { ...body, userId: session.user.id } })
})
Never return a thrown exception across the wire. Use createError({ statusCode, statusMessage }) so the client gets a typed error shape.
7. <script setup lang="ts"> everywhere — strict on
tsconfig.json extends ./.nuxt/tsconfig.json and turns on "strict": true and "noUncheckedIndexedAccess": true. defineProps and defineEmits use the generic form (defineProps<{ id: string }>()), never the runtime object form when types are available. The only acceptable cast is as const.
8. Template hygiene: no v-if + v-for, keys are stable IDs
Two recurring AI mistakes:
<!-- ❌ v-if + v-for on the same element — v-for wins, v-if runs per item -->
<li v-for="u in users" v-if="u.active" :key="u.id">{{ u.name }}</li>
<!-- ✅ filter in a computed, key by stable ID -->
<li v-for="u in activeUsers" :key="u.id">{{ u.name }}</li>
Keys are never index. They are stable, unique IDs from the data.
9. Props down, events up — never mutate a prop
Props are read-only. The pattern AI defaults to (props.user.name = 'x') silently desyncs the parent. Use defineEmits and emit a typed event, or use defineModel() for two-way binding when the parent really owns the value.
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template><input v-model="model" /></template>
10. Auto-imports are real — don't import { ref } from 'vue'
Nuxt auto-imports ref, computed, watch, every composable in composables/, every component in components/, and every store. Manual imports of these are noise that creates merge conflicts. Configure ESLint with @nuxt/eslint so unused imports get flagged and Nuxt's globals don't trip no-undef.
11. Error and loading states: error.vue, <NuxtLoadingIndicator>, Suspense boundaries
A root error.vue catches every uncaught error in SSR or client navigation. <NuxtLoadingIndicator /> in app.vue gives free top-of-page progress. Wrap async route children in <Suspense> only when you need a custom fallback — Nuxt does this implicitly for pages.
12. Testing: Vitest + @vue/test-utils, query by role
Mount the real component with mount, query by accessible role or label. No shallow rendering. No querying by class name.
import { mount } from '@vue/test-utils'
import { it, expect } from 'vitest'
import LoginForm from './LoginForm.vue'
it('submits the form', async () => {
const wrapper = mount(LoginForm)
await wrapper.get('input[name=email]').setValue('a@b.co')
await wrapper.get('button[type=submit]').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual([{ email: 'a@b.co' }])
})
For server routes, integration-test against a real test DB with @nuxt/test-utils.
13. Performance: <NuxtImage>, <NuxtLink>, lazy components, payloadExtraction
Use <NuxtImage> over <img> (responsive, lazy, format-negotiated). <NuxtLink> over <a> for internal routes (prefetch, no full reload). Prefix heavy components with Lazy (<LazyProductGallery />) so they code-split. Keep experimental.payloadExtraction: true on for static routes — it ships JSON instead of re-running data fetchers in the client.
A starter CLAUDE.md snippet
# CLAUDE.md — Vue 3 + Nuxt 3
## Stack
- Vue 3.4+, Nuxt 3.12+, TypeScript strict, Pinia setup stores, Vitest
## Hard rules
- `<script setup lang="ts">` only. No Options API, no mixins.
- `ref` by default; `reactive` only for fixed shapes never destructured.
- Pinia setup-store syntax. One store per domain.
- Server data via `useFetch` / `useAsyncData`. Never `onMounted` for first-paint data.
- Mutations go through `server/api/*.ts`, validated with Zod.
- Props are read-only — emit events or use `defineModel()`.
- No manual imports of Nuxt/Vue auto-imported symbols.
- No `v-if` and `v-for` on the same element. Keys are stable IDs.
- `<NuxtImage>`, `<NuxtLink>`, `Lazy*` for heavy components.
What Claude gets wrong without these rules
- Generates Options API because half its training is Vue 2.
- Fetches in
onMountedand waterfalls every page. - Destructures
reactive()and silently kills reactivity. - Mutates props three components deep.
- Drops a giant Vuex-style store with mutations and modules.
- Uses
<img>and<a>so LCP and prefetch lose.
Drop the 13 rules above into CLAUDE.md and the next AI-generated PR looks like the codebase, not a tutorial. The diff stays small. Review stays short. Hydration stays clean.
Want this for 20+ stacks (Vue, Nuxt, React, Next.js, Rails, Django, Rust, Go, Kotlin, Swift, Flutter, and more) with 200+ rules ready to paste in? Grab the CLAUDE.md Rules Pack at oliviacraftlat.gumroad.com/l/skdgt.
— Olivia (@OliviaCraftLat)
Top comments (0)