Cursor Rules for Vue 3: 6 Rules That Make AI Write Composable, Type-Safe Vue
Cursor generates Vue components fast. The problem? It generates Vue 2 patterns in Vue 3 projects — Options API instead of Composition API, untyped props, this.$emit calls, massive monolithic components, and reactive state that silently loses reactivity when destructured.
You can fix this with targeted rules in your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every Vue 3 project, with before/after examples showing exactly what changes.
Rule 1: Composition API Only — No Options API
Always use the Composition API with <script setup lang="ts">.
Never use the Options API (data(), methods, computed, watch options).
Use ref() and reactive() for state, computed() for derived values,
and watch()/watchEffect() for side effects.
Cursor's training data is full of Vue 2 Options API code. Without this rule, it mixes paradigms constantly.
Without this rule, Cursor generates Options API:
<!-- ❌ Bad: Options API in a Vue 3 project -->
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
search: '',
items: [] as any[],
loading: false,
}
},
computed: {
filteredItems() {
return this.items.filter(i => i.name.includes(this.search))
}
},
methods: {
async fetchItems() {
this.loading = true
const res = await fetch('/api/items')
this.items = await res.json()
this.loading = false
}
},
mounted() {
this.fetchItems()
}
})
</script>
Verbose, no type safety on this, and mixing two mental models in the same project.
With this rule, Cursor generates <script setup>:
<!-- ✅ Good: Composition API with script setup -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface Item {
id: string
name: string
}
const search = ref('')
const items = ref<Item[]>([])
const loading = ref(false)
const filteredItems = computed(() =>
items.value.filter(i => i.name.includes(search.value))
)
async function fetchItems() {
loading.value = true
const res = await fetch('/api/items')
items.value = await res.json()
loading.value = false
}
onMounted(fetchItems)
</script>
Half the code. Full type inference. No this ambiguity.
Rule 2: Type Props and Emits — No Runtime-Only Declarations
Always use TypeScript-based prop and emit declarations with
defineProps<T>() and defineEmits<T>(). Never use the runtime
array syntax (['title', 'count']) or object syntax without types.
Define interfaces for complex prop shapes.
AI models default to the simplest prop syntax — arrays with zero type information.
Without this rule:
<!-- ❌ Bad: no types, no defaults, no safety -->
<script setup>
const props = defineProps(['title', 'items', 'onSelect'])
const emit = defineEmits(['update', 'delete'])
function handleClick(item) {
emit('update', item)
}
</script>
Props are any. Emits have no payload types. Typos in event names won't be caught.
With this rule:
<!-- ✅ Good: fully typed props and emits -->
<script setup lang="ts">
interface Item {
id: string
label: string
}
const props = withDefaults(
defineProps<{
title: string
items: Item[]
maxVisible?: number
}>(),
{ maxVisible: 10 }
)
const emit = defineEmits<{
update: [item: Item]
delete: [id: string]
}>()
function handleClick(item: Item) {
emit('update', item)
}
</script>
Every prop has a type. Emits define their payload shape. The compiler catches emit('upate', item) instantly.
Rule 3: Extract Composables — Don't Stuff Everything in Components
Extract reusable logic into composables (use*.ts files).
A composable should encapsulate reactive state + logic for a
single concern. Components should orchestrate composables,
not contain raw business logic. Keep components focused on
template rendering and user interaction.
Cursor dumps all logic into single components. Without this rule, you get 400-line components with fetch calls, state management, and business logic tangled together.
Without this rule:
<!-- ❌ Bad: component does everything -->
<script setup lang="ts">
import { ref, watch } from 'vue'
const query = ref('')
const results = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const page = ref(1)
const hasMore = ref(true)
const debounceTimer = ref<ReturnType<typeof setTimeout>>()
watch(query, (val) => {
clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
page.value = 1
search(val)
}, 300)
})
async function search(term: string) {
loading.value = true
error.value = null
try {
const res = await fetch(`/api/search?q=${term}&page=${page.value}`)
if (!res.ok) throw new Error('Search failed')
const data = await res.json()
results.value = page.value === 1 ? data.items : [...results.value, ...data.items]
hasMore.value = data.hasMore
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
function loadMore() {
page.value++
search(query.value)
}
</script>
30+ lines of logic that has nothing to do with rendering. Impossible to reuse, hard to test.
With this rule:
// ✅ Good: composable handles the concern
// composables/useSearch.ts
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
export function useSearch<T>(endpoint: string) {
const query = ref('')
const results = ref<T[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const page = ref(1)
const hasMore = ref(true)
async function search() {
loading.value = true
error.value = null
try {
const res = await fetch(`${endpoint}?q=${query.value}&page=${page.value}`)
if (!res.ok) throw new Error('Search failed')
const data = await res.json()
results.value = page.value === 1 ? data.items : [...results.value, ...data.items]
hasMore.value = data.hasMore
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
const debouncedSearch = useDebounceFn(() => {
page.value = 1
search()
}, 300)
watch(query, () => debouncedSearch())
function loadMore() {
page.value++
search()
}
return { query, results, loading, error, hasMore, loadMore }
}
<!-- ✅ Good: component just wires things up -->
<script setup lang="ts">
import { useSearch } from '@/composables/useSearch'
const { query, results, loading, error, hasMore, loadMore } = useSearch<Product>('/api/search')
</script>
The component is 3 lines. The composable is testable, reusable, and self-contained.
Rule 4: Don't Break Reactivity — Destructure Correctly
Never destructure reactive() objects directly — it breaks reactivity.
Use toRefs() or storeToRefs() when destructuring. For ref() values,
always access .value in script. Prefer ref() over reactive() for
primitive values. Use shallowRef() for large objects that replace
rather than mutate.
This is the #1 source of silent bugs in Vue 3. AI models destructure reactive objects constantly because it looks cleaner — and silently breaks the template.
Without this rule:
<!-- ❌ Bad: reactivity is silently broken -->
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'initial',
})
// ❌ These are plain values now — changes won't update the template
const { count, name } = state
function increment() {
count++ // Does nothing in the template
}
</script>
<template>
<p>{{ count }}</p> <!-- Stuck at 0 forever -->
<button @click="increment">+1</button>
</template>
No error. No warning. The template just never updates.
With this rule:
<!-- ✅ Good: reactivity preserved with toRefs -->
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'initial',
})
const { count, name } = toRefs(state)
function increment() {
count.value++
}
</script>
<template>
<p>{{ count }}</p> <!-- Updates correctly -->
<button @click="increment">+1</button>
</template>
toRefs() creates refs that stay connected to the reactive source. The template updates as expected.
Rule 5: Use v-model with defineModel — No Manual Prop/Emit Boilerplate
Use defineModel() for two-way binding instead of manual prop +
emit patterns. For custom v-model modifiers, use the modifiers
option. For multiple v-model bindings, use named models.
Never manually emit update:modelValue when defineModel handles it.
Cursor generates verbose prop + emit patterns for v-model even though Vue 3.4+ has defineModel().
Without this rule:
<!-- ❌ Bad: manual v-model boilerplate -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
title: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:title': [value: string]
}>()
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
function onTitleChange(val: string) {
emit('update:title', val)
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
<input :value="title" @input="onTitleChange($event.target.value)" />
</template>
12 lines of boilerplate to do what Vue provides built-in.
With this rule:
<!-- ✅ Good: defineModel handles everything -->
<script setup lang="ts">
const modelValue = defineModel<string>({ required: true })
const title = defineModel<string>('title', { required: true })
</script>
<template>
<input v-model="modelValue" />
<input v-model="title" />
</template>
Two lines replace twelve. Type-safe. Reactive. Built-in.
Rule 6: Type Template Refs and Provide/Inject
Always type template refs with the correct component or element type.
Use ref<InstanceType<typeof Component> | null>(null) for component refs.
Use ref<HTMLInputElement | null>(null) for DOM element refs.
Always type provide/inject with InjectionKey<T> for type safety.
Never use string keys for provide/inject.
AI models leave template refs untyped and use string keys for provide/inject, losing all type safety across component boundaries.
Without this rule:
<!-- ❌ Bad: untyped refs and stringly-typed injection -->
<script setup>
import { ref, provide, inject } from 'vue'
const inputRef = ref(null)
const formRef = ref(null)
provide('theme', { primary: '#000', secondary: '#fff' })
// In child component:
const theme = inject('theme') // type: unknown
</script>
<template>
<input ref="inputRef" />
<MyForm ref="formRef" />
</template>
inputRef is Ref<null>. theme is unknown. No autocomplete, no type checking.
With this rule:
<!-- ✅ Good: fully typed refs and injection -->
<script setup lang="ts">
import { ref, provide } from 'vue'
import type { InjectionKey } from 'vue'
import MyForm from './MyForm.vue'
interface Theme {
primary: string
secondary: string
}
export const themeKey: InjectionKey<Theme> = Symbol('theme')
const inputRef = ref<HTMLInputElement | null>(null)
const formRef = ref<InstanceType<typeof MyForm> | null>(null)
provide(themeKey, { primary: '#000', secondary: '#fff' })
// In child component:
// const theme = inject(themeKey) // type: Theme | undefined
</script>
<template>
<input ref="inputRef" />
<MyForm ref="formRef" />
</template>
inputRef knows it's an HTMLInputElement. formRef exposes the component's public API. inject(themeKey) returns Theme | undefined — full autocomplete, compile-time checks.
Copy-Paste Ready: All 6 Rules
Drop this into your .cursorrules or .cursor/rules/vue.mdc:
# Vue 3 Code Rules
## Composition API
- Always use <script setup lang="ts">
- Never use Options API (data, methods, computed, watch options)
- Use ref() for primitives, reactive() for objects
- Use computed() for derived values, watch() for side effects
## Props and Emits
- Use defineProps<T>() with TypeScript generics, never array syntax
- Use defineEmits<T>() with typed event signatures
- Use withDefaults() for default prop values
## Composables
- Extract reusable logic into composables (use*.ts)
- One concern per composable
- Components should orchestrate composables, not contain business logic
## Reactivity
- Never destructure reactive() objects without toRefs()
- Always access .value for ref() in script
- Use shallowRef() for large objects that are replaced, not mutated
- Use storeToRefs() when destructuring Pinia stores
## v-model
- Use defineModel() for two-way binding (Vue 3.4+)
- Never manually emit update:modelValue when defineModel works
- Use named models for multiple v-model bindings
## Type Safety
- Type template refs: ref<HTMLElement | null>(null)
- Type component refs: ref<InstanceType<typeof Comp> | null>(null)
- Use InjectionKey<T> for provide/inject, never string keys
Want 50+ Production-Tested Rules?
These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering Vue, TypeScript, React, Next.js, and more — organized by language and priority so Cursor applies them consistently.
Stop fighting bad AI output. Give Cursor the rules it needs to write Vue the right way.
Top comments (0)