DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Vue 3: 6 Rules That Make AI Write Composable, Type-Safe Vue

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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)