DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Vue.js and Nuxt.js: 13 Rules That Make AI Write Composable, SSR-Ready Components

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>
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 } })
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' }])
})
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

What Claude gets wrong without these rules

  • Generates Options API because half its training is Vue 2.
  • Fetches in onMounted and 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)