dotmd
// Config Record

>.cursor/rules — Vue 3 + TypeScript (Nuxt 3)

Cursor MDC project rules for Nuxt 3 and Vue 3 with Composition API, Nitro routes, and typed data-fetching conventions.

author:
dotmd Team
license:CC0
published:Feb 23, 2026
// Installation

>Add this file to your project repository:

  • Cursor
    .cursor/rules/
// File Content
.cursor/rules (MDC)
1---
2description: Vue 3 + TypeScript project using Nuxt 3
3globs:
4alwaysApply: true
5---
6
7# Nuxt 3 + Vue 3 + TypeScript
8
9## Component Architecture
10
11Every Vue component uses `<script setup lang="ts">`. No Options API. No `defineComponent()` unless wrapping a library.
12
13```vue
14<script setup lang="ts">
15interface Props {
16 title: string
17 count?: number
18}
19
20const props = withDefaults(defineProps<Props>(), {
21 count: 0,
22})
23
24const emit = defineEmits<{
25 update: [value: string]
26 close: []
27}>()
28</script>
29
30<template>
31 <div>
32 <h1>{{ title }}</h1>
33 </div>
34</template>
35```
36
37Type props with TypeScript interfaces, not runtime validation. Use `withDefaults` for default values. Type emits with the tuple syntax.
38
39Use `defineModel` for two-way binding:
40
41```vue
42<script setup lang="ts">
43const modelValue = defineModel<string>({ required: true })
44const count = defineModel<number>('count', { default: 0 })
45</script>
46```
47
48## Auto-Imports
49
50Nuxt auto-imports Vue APIs, composables, and utils. Never manually import these:
51
52- **Vue:** `ref`, `computed`, `watch`, `watchEffect`, `onMounted`, `nextTick`, `toRef`, `toRefs`
53- **Nuxt:** `useFetch`, `useAsyncData`, `useState`, `useRuntimeConfig`, `useRoute`, `useRouter`, `navigateTo`, `useHead`, `useSeoMeta`, `definePageMeta`, `useNuxtApp`, `useCookie`, `useRequestHeaders`
54- **Your composables:** anything exported from `composables/` is auto-imported
55- **Your utils:** anything exported from `utils/` is auto-imported
56
57If you see `import { ref } from 'vue'` or `import { useFetch } from '#imports'` — remove it. Nuxt handles this.
58
59## File Conventions
60
61Follow Nuxt's directory structure strictly:
62
63| Directory | Purpose |
64|---|---|
65| `pages/` | File-based routing. `pages/users/[id].vue` → `/users/:id` |
66| `components/` | Auto-imported components. `components/ui/Button.vue` → `<UiButton>` |
67| `composables/` | Auto-imported composables. Must export `use*` functions |
68| `utils/` | Auto-imported utility functions |
69| `layouts/` | Page layouts. `default.vue` applies unless overridden |
70| `middleware/` | Route middleware. Named (`auth.ts`) or inline |
71| `server/api/` | API routes handled by Nitro. `server/api/users.get.ts` → `GET /api/users` |
72| `server/routes/` | Non-API server routes |
73| `server/middleware/` | Server-side middleware (runs on every request) |
74| `server/utils/` | Auto-imported server utilities |
75| `plugins/` | Nuxt plugins. `.server.ts` / `.client.ts` suffixes for environment-specific |
76| `public/` | Static assets served at root |
77| `assets/` | Build-processed assets (CSS, images for bundler) |
78
79## Data Fetching
80
81Use `useFetch` for simple API calls and `useAsyncData` when you need more control. Both handle SSR hydration automatically.
82
83```vue
84<script setup lang="ts">
85// Simple fetch — uses the route path as the cache key
86const { data: users, status } = await useFetch('/api/users')
87
88// With params and transform
89const { data: user } = await useFetch(`/api/users/${id}`, {
90 query: { include: 'posts' },
91 transform: (response) => response.data,
92})
93
94// useAsyncData for custom logic
95const { data: dashboard } = await useAsyncData('dashboard', () => {
96 return Promise.all([
97 $fetch('/api/stats'),
98 $fetch('/api/activity'),
99 ])
100})
101</script>
102```
103
104Rules:
105- Use `useFetch` over raw `$fetch` in components — it deduplicates requests and handles SSR
106- Use `$fetch` inside server routes, event handlers, and non-component code
107- Use `status` (`idle`, `pending`, `success`, `error`) instead of separate boolean flags
108- Set a `key` option when the URL is dynamic and you need to refetch: `useFetch('/api/users', { key: \`user-${id}\` })`
109- Use `lazy: true` for non-critical data that shouldn't block navigation
110
111## Server Routes (Nitro)
112
113Server routes are the backend. File name encodes the HTTP method.
114
115```ts
116// server/api/users.get.ts
117export default defineEventHandler(async (event) => {
118 const query = getQuery(event)
119 const users = await db.user.findMany({ take: Number(query.limit) || 20 })
120 return users
121})
122
123// server/api/users.post.ts
124export default defineEventHandler(async (event) => {
125 const body = await readValidatedBody(event, userSchema.parse)
126 const user = await db.user.create({ data: body })
127 return user
128})
129
130// server/api/users/[id].get.ts
131export default defineEventHandler(async (event) => {
132 const id = getRouterParam(event, 'id')
133 const user = await db.user.findUnique({ where: { id } })
134 if (!user) {
135 throw createError({ statusCode: 404, statusMessage: 'User not found' })
136 }
137 return user
138})
139```
140
141Validate request bodies with `readValidatedBody` and a Zod schema. Validate query params with `getValidatedQuery`. Throw errors with `createError`.
142
143## State Management
144
145Use `useState` for simple shared state that needs SSR support:
146
147```ts
148// composables/useCounter.ts
149export function useCounter() {
150 const count = useState<number>('counter', () => 0)
151 const increment = () => count.value++
152 return { count, increment }
153}
154```
155
156Use Pinia with setup stores for complex state:
157
158```ts
159// stores/auth.ts
160export const useAuthStore = defineStore('auth', () => {
161 const user = ref<User | null>(null)
162 const isLoggedIn = computed(() => !!user.value)
163
164 async function login(credentials: LoginCredentials) {
165 user.value = await $fetch('/api/auth/login', {
166 method: 'POST',
167 body: credentials,
168 })
169 }
170
171 function logout() {
172 user.value = null
173 navigateTo('/login')
174 }
175
176 return { user, isLoggedIn, login, logout }
177})
178```
179
180Install with `@pinia/nuxt` module. Stores are auto-imported from `stores/` when using the module.
181
182Use `useState` for SSR-safe reactive state that's simple. Use Pinia when you need getters, actions, devtools, or the state is complex. Don't use plain `ref()` at module scope for shared state — it leaks across requests on the server.
183
184## Middleware
185
186Route middleware for auth and navigation guards:
187
188```ts
189// middleware/auth.ts
190export default defineNuxtRouteMiddleware((to, from) => {
191 const { isLoggedIn } = useAuthStore()
192 if (!isLoggedIn) {
193 return navigateTo('/login')
194 }
195})
196```
197
198Apply in pages with `definePageMeta`:
199
200```vue
201<script setup lang="ts">
202definePageMeta({
203 middleware: 'auth',
204 layout: 'dashboard',
205})
206</script>
207```
208
209For global middleware, name the file with a `.global.ts` suffix.
210
211## TypeScript
212
213Use strict TypeScript. Define types for all API responses, props, and emits.
214
215```ts
216// types/index.ts
217export interface User {
218 id: string
219 email: string
220 name: string
221 createdAt: string
222}
223
224export interface ApiResponse<T> {
225 data: T
226 meta?: { total: number; page: number }
227}
228```
229
230The `types/` directory is not auto-imported — use explicit imports for type definitions. This is intentional; types are contracts and should be traceable.
231
232For Nitro event typing, use `EventHandlerRequest`:
233
234```ts
235// server/api/users.post.ts
236export default defineEventHandler<{ body: CreateUserInput }>(async (event) => {
237 const body = await readBody(event)
238 // body is typed as CreateUserInput
239})
240```
241
242## Runtime Config
243
244Use `runtimeConfig` in `nuxt.config.ts` for environment variables:
245
246```ts
247// nuxt.config.ts
248export default defineNuxtConfig({
249 runtimeConfig: {
250 dbUrl: '', // Server-only, set via NUXT_DB_URL
251 apiSecret: '', // Server-only, set via NUXT_API_SECRET
252 public: {
253 apiBase: '', // Client + server, set via NUXT_PUBLIC_API_BASE
254 },
255 },
256})
257```
258
259Access in code:
260
261```ts
262// In components/composables
263const config = useRuntimeConfig()
264config.public.apiBase // ✅ available everywhere
265
266// In server routes
267const config = useRuntimeConfig()
268config.dbUrl // ✅ server-only values available here
269```
270
271Never hardcode secrets. Never access private runtime config on the client.
272
273## Styling
274
275Use one of:
276- **UnoCSS** (`@unocss/nuxt`) — atomic CSS, Tailwind-compatible presets
277- **Tailwind CSS** (`@nuxtjs/tailwind`) — utility-first CSS
278- **Scoped styles** — `<style scoped>` for component-specific CSS
279
280Prefer utility classes in templates. Use `<style scoped>` when you need complex selectors or animations. Never use `<style>` without `scoped` in components — it leaks globally.
281
282For design systems, use **Nuxt UI** (`@nuxt/ui`) which provides accessible components built on Reka UI with Tailwind.
283
284## SEO and Head Management
285
286Use `useSeoMeta` for type-safe SEO:
287
288```vue
289<script setup lang="ts">
290useSeoMeta({
291 title: 'My Page',
292 description: 'Page description for search engines',
293 ogTitle: 'My Page',
294 ogDescription: 'Page description for social sharing',
295 ogImage: '/og-image.png',
296})
297</script>
298```
299
300Use `useHead` for non-SEO head tags (scripts, links, structured data).
301
302## Error Handling
303
304Use `<NuxtErrorBoundary>` for component-level error recovery. Create `error.vue` at the project root for full-page errors.
305
306In server routes, always use `createError` — never throw plain Error objects:
307
308```ts
309throw createError({
310 statusCode: 422,
311 statusMessage: 'Validation failed',
312 data: { errors: validationErrors },
313})
314```
315
316In components, handle fetch errors through the `error` return from `useFetch`:
317
318```vue
319<script setup lang="ts">
320const { data, error } = await useFetch('/api/resource')
321</script>
322
323<template>
324 <div v-if="error">Something went wrong: {{ error.message }}</div>
325 <div v-else-if="data">{{ data }}</div>
326</template>
327```
328
329## Testing
330
331Use **Vitest** for unit tests and **@nuxt/test-utils** for Nuxt-aware testing:
332
333```ts
334// tests/components/UserCard.nuxt.spec.ts
335import { mountSuspended } from '@nuxt/test-utils/runtime'
336import UserCard from '~/components/UserCard.vue'
337
338describe('UserCard', () => {
339 it('renders user name', async () => {
340 const wrapper = await mountSuspended(UserCard, {
341 props: { user: { id: '1', name: 'Alice', email: 'alice@example.com' } },
342 })
343 expect(wrapper.text()).toContain('Alice')
344 })
345})
346```
347
348Use `mountSuspended` instead of `mount` from `@vue/test-utils` — it handles Nuxt's async setup and auto-imports.
349
350For server route tests, use `$fetch` from `@nuxt/test-utils/e2e` or test handlers directly.
351
352## Key Conventions
353
3541. One component per file. Name matches usage: `UserCard.vue` → `<UserCard>`
3552. Composables start with `use`: `useAuth`, `useFilters`, `useFormValidation`
3563. Prefer `<template>` logic (`v-if`, `v-for`) over render functions
3574. Use `<Suspense>` awareness — top-level `await` in `<script setup>` is fine, Nuxt handles it
3585. Use `definePageMeta` for page-level config, not component options
3596. Keep server routes thin — extract business logic into `server/utils/`
3607. Use `$fetch` in server routes to call other server routes (stays server-side, no HTTP overhead)
3618. Prefer `navigateTo()` over `router.push()` — it works in both client and server contexts
362