vue-nuxt-skills
vue-nuxt-skills plugin for Cursor
cursor.directory·↓ 4
Skill
nuxt3
# Nuxt 3 Skill ## Description Use this skill when the task involves **building or modifying a Nuxt 3 application** — including pages, layouts, server API routes, middleware, plugins, SEO, data fet
# Nuxt 3 Skill
## Description
Use this skill when the task involves **building or modifying a Nuxt 3 application** — including pages, layouts, server API routes, middleware, plugins, SEO, data fetching, SSR/SSG configuration, or Nuxt modules.
Trigger phrases: "create a page", "add an API route", "set up server route", "configure Nuxt", "add SEO meta", "create middleware", "set up auth", "add a layout", "configure rendering", "write a Nuxt plugin", "fetch data in Nuxt", "create a Nitro route", "add a Nuxt module".
---
## How to Use This Skill
When working on a Nuxt 3 task, follow this workflow:
1. **Identify the rendering requirement first** — SSR, SSG, ISR, or CSR?
2. **Determine where data lives** — server route, external API, or static?
3. **Choose the right data fetching primitive** — `useFetch`, `useAsyncData`, or `$fetch`
4. **Implement server route if needed** — validate input, handle errors, return typed data
5. **Build the page/component** — use auto-imports, no manual imports for Nuxt utils
6. **Add SEO meta** — always use `useSeoMeta()`
7. **Apply middleware** — auth, redirects, analytics
---
## Project Structure Reference
```
.
├── app.vue # Global app wrapper (optional)
├── nuxt.config.ts # Central config
├── error.vue # Custom error page
│
├── pages/
│ ├── index.vue # → /
│ ├── about.vue # → /about
│ ├── blog/
│ │ ├── index.vue # → /blog
│ │ └── [slug].vue # → /blog/:slug
│ └── users/
│ ├── index.vue # → /users
│ ├── [id]/
│ │ ├── index.vue # → /users/:id
│ │ └── settings.vue # → /users/:id/settings
│ └── [...slug].vue # → /users/* (catch-all)
│
├── components/
│ ├── ui/ # Base: Button, Input, Modal
│ ├── App/ # App-level: AppHeader, AppFooter
│ └── [Feature]/ # Feature-specific components
│
├── composables/ # Auto-imported useXxx.ts files
├── utils/ # Auto-imported utility functions
│
├── layouts/
│ ├── default.vue # Default layout
│ └── dashboard.vue # Dashboard layout
│
├── middleware/
│ ├── auth.ts # Named: requiresAuth pages
│ └── redirect.global.ts # Global: runs on every navigation
│
├── plugins/
│ ├── analytics.client.ts # Client-only plugin
│ └── error-handler.ts # Universal plugin
│
├── server/
│ ├── api/ # → /api/* routes
│ │ ├── users/
│ │ │ ├── index.get.ts # GET /api/users
│ │ │ ├── index.post.ts # POST /api/users
│ │ │ └── [id].get.ts # GET /api/users/:id
│ │ └── health.get.ts # GET /api/health
│ ├── routes/ # Custom non-/api/* routes
│ ├── middleware/ # Server middleware (runs every request)
│ └── utils/ # Server-only shared utilities (auto-imported)
│
├── stores/ # Pinia stores
└── types/ # Shared TypeScript types
```
---
## `nuxt.config.ts` Skill
### Full production-ready config template
```ts
export default defineNuxtConfig({
devtools: { enabled: true },
// --- Modules ---
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxt/image',
'@vueuse/nuxt',
'@nuxtjs/i18n',
'nuxt-security',
'@nuxt/content', // If using CMS/markdown
],
// --- Runtime Config ---
runtimeConfig: {
// 🔒 Server-only (never sent to browser)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
// 🌐 Public (exposed to browser — safe values only)
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
appName: process.env.NUXT_PUBLIC_APP_NAME ?? 'My App',
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
},
},
// --- Rendering Strategy Per Route ---
routeRules: {
'/': { prerender: true }, // Static home
'/about': { prerender: true }, // Static page
'/blog/**': { isr: 3600 }, // ISR — 1hr revalidation
'/docs/**': { prerender: true }, // Full static
'/dashboard/**': { ssr: false }, // SPA — client-only
'/admin/**': { ssr: false }, // SPA — client-only
'/api/**': { cors: true, cache: false }, // API routes
},
// --- TypeScript ---
typescript: {
strict: true,
typeCheck: true,
},
// --- App Head Defaults ---
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
},
},
})
```
---
## Data Fetching Skill
### Decision tree — which primitive to use?
```
Is this inside a Vue component or page?
├─ YES → Is it needed for SSR (visible on first load)?
│ ├─ YES → useFetch() or useAsyncData()
│ └─ NO → $fetch() inside onMounted() or with lazy: true
└─ NO (inside event handler / store action / server route)
└─ $fetch()
```
### `useFetch` — standard SSR data fetching
```ts
// Basic
const { data, status, error, refresh } = await useFetch<User[]>('/api/users')
// With options
const { data: user } = await useFetch<User>(`/api/users/${route.params.id}`, {
// Cache key — must be unique per fetch
key: `user-${route.params.id}`,
// Re-fetch when this reactive value changes
watch: [() => route.params.id],
// Transform response
transform: (res) => res.data,
// Don't block navigation — load in background
lazy: true,
// Pass auth header
headers: { Authorization: `Bearer ${token.value}` },
})
// Always handle status
if (status.value === 'error') {
// handle error.value
}
```
### `useAsyncData` — multi-source or custom logic
```ts
const { data } = await useAsyncData('dashboard', async () => {
const [stats, recentOrders, topProducts] = await Promise.all([
$fetch<Stats>('/api/dashboard/stats'),
$fetch<Order[]>('/api/orders/recent'),
$fetch<Product[]>('/api/products/top'),
])
return { stats, recentOrders, topProducts }
}, {
watch: [selectedDateRange],
})
```
### `$fetch` — mutations and event handlers
```ts
// In component
async function createPost() {
const post = await $fetch<Post>('/api/posts', {
method: 'POST',
body: {
title: form.title,
content: form.content,
},
})
await navigateTo({ name: 'PostDetail', params: { id: post.id } })
}
```
---
## Server Routes Skill
### File naming convention
| Filename | HTTP Method | URL |
|---|---|---|
| `users/index.get.ts` | GET | `/api/users` |
| `users/index.post.ts` | POST | `/api/users` |
| `users/[id].get.ts` | GET | `/api/users/:id` |
| `users/[id].patch.ts` | PATCH | `/api/users/:id` |
| `users/[id].delete.ts` | DELETE | `/api/users/:id` |
### Full server route template
```ts
// server/api/posts/index.post.ts
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).optional().default([]),
published: z.boolean().default(false),
})
export default defineEventHandler(async (event) => {
// 1. Authenticate
const user = await requireAuth(event) // from server/utils/auth.ts
// 2. Read and validate body
const rawBody = await readBody(event)
const result = CreatePostSchema.safeParse(rawBody)
if (!result.success) {
throw createError({
statusCode: 422,
message: 'Validation failed',
data: result.error.flatten(),
})
}
const body = result.data
// 3. Business logic
const post = await db.post.create({
data: {
...body,
authorId: user.id,
},
})
// 4. Return — Nitro auto-serializes
return post
})
```
### Reading route params, query, and body
```ts
export default defineEventHandler(async (event) => {
// Route param from [id].get.ts
const id = getRouterParam(event, 'id')
// Query string ?page=1&limit=20
const query = getQuery(event)
const page = Number(query.page ?? 1)
const limit = Number(query.limit ?? 20)
// Request body (POST/PATCH)
const body = await readBody(event)
// Headers
const authHeader = getHeader(event, 'authorization')
// Cookies
const sessionToken = getCookie(event, 'session')
})
```
### Server utility (shared across routes)
```ts
// server/utils/auth.ts — auto-imported in server routes
import type { H3Event } from 'h3'
export async function requireAuth(event: H3Event) {
const token = getCookie(event, 'auth-token')
?? getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({ statusCode: 401, message: 'Authentication required' })
}
try {
const config = useRuntimeConfig()
const payload = verifyJwt(token, config.jwtSecret)
return payload
} catch {
throw createError({ statusCode: 401, message: 'Invalid or expired token' })
}
}
```
---
## Pages & Layouts Skill
### Page template with all features
```vue
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
// 1. Page meta (static)
definePageMeta({
name: 'BlogPost',
layout: 'blog',
middleware: ['auth'],
})
// 2. Route
const route = useRoute()
const slug = computed(() => route.params.slug as string)
// 3. Data fetching
const { data: post, status } = await useFetch<Post>(`/api/posts/${slug.value}`, {
key: `post-${slug.value}`,
})
// 4. Handle not found
if (!post.value) {
throw createError({ statusCode: 404, message: 'Post not found' })
}
// 5. SEO
useSeoMeta({
title: post.value.title,
description: post.value.excerpt,
ogTitle: post.value.title,
ogDescription: post.value.excerpt,
ogImage: post.value.coverImage,
ogType: 'article',
twitterCard: 'summary_large_image',
})
</script>
<template>
<div v-if="status === 'pending'">
<PostSkeleton />
</div>
<article v-else-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.renderedContent" />
</article>
</template>
```
### Layout template
```vue
<!-- layouts/dashboard.vue -->
<script setup lang="ts">
const authStore = useAuthStore()
const { user } = storeToRefs(authStore)
</script>
<template>
<div class="dashboard-layout">
<AppSidebar />
<main class="dashboard-main">
<AppTopbar :user="user" />
<div class="dashboard-content">
<slot /> <!-- Pages render here -->
</div>
</main>
</div>
</template>
```
---
## Middleware Skill
### Named middleware (opt-in per page)
```ts
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, user } = storeToRefs(useAuthStore())
if (!isAuthenticated.value) {
return navigateTo({
name: 'Login',
query: { redirect: to.fullPath },
})
}
// Role check
if (to.meta.requiredRole && user.value?.role !== to.meta.requiredRole) {
return abortNavigation(
createError({ statusCode: 403, message: 'Forbidden' }),
)
}
})
```
### Global middleware (runs on every navigation)
```ts
// middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
// Runs automatically on every route change
if (import.meta.client) {
trackPageView(to.fullPath)
}
})
```
---
## Plugin Skill
```ts
// plugins/toast.client.ts — client-only
import Toast from 'vue-toastification'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(Toast, {
position: 'top-right',
timeout: 3000,
})
// Provide typed helper
nuxtApp.provide('toast', {
success: (msg: string) => useToast().success(msg),
error: (msg: string) => useToast().error(msg),
info: (msg: string) => useToast().info(msg),
})
})
// Augment types
declare module '#app' {
interface NuxtApp {
$toast: {
success(msg: string): void
error(msg: string): void
info(msg: string): void
}
}
}
```
---
## SEO Skill
### Global defaults in `app.vue`
```ts
// app.vue
useSeoMeta({
titleTemplate: '%s | My Brand',
description: 'Default site description for social sharing',
ogSiteName: 'My Brand',
ogImage: 'https://mysite.com/og-default.png',
twitterCard: 'summary_large_image',
twitterSite: '@mybrand',
})
```
### Dynamic SEO per page
```ts
// Computed SEO from fetched data
watchEffect(() => {
if (product.value) {
useSeoMeta({
title: product.value.name,
description: product.value.shortDescription,
ogImage: product.value.images[0]?.url,
ogType: 'product',
})
}
})
```
### Structured data (JSON-LD)
```ts
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.value.title,
author: { '@type': 'Person', name: post.value.author.name },
datePublished: post.value.publishedAt,
}),
},
],
})
```
---
## Rendering Strategy Reference
| Use Case | Strategy | Config |
|---|---|---|
| Marketing pages | Full static | `prerender: true` |
| Blog / docs | ISR (hourly) | `isr: 3600` |
| Product pages | ISR (15 min) | `isr: 900` |
| Dashboard / app | Client-only SPA | `ssr: false` |
| Default (dynamic) | SSR per request | (default, no config needed) |
| API routes | No cache | `cache: false` |
---
## Common Anti-Patterns to Avoid
| ❌ Wrong | ✅ Correct |
|---|---|
| `import { useFetch } from '#app'` | Just use `useFetch` — it's auto-imported |
| `import { ref } from 'vue'` | Auto-imported — remove the import |
| `window.localStorage` in `<script setup>` | Wrap in `onMounted` or use `.client.ts` plugin |
| `process.env.SECRET` in component | Use `useRuntimeConfig().public.xxx` (public only) |
| `fetch('/api/users')` in `<script setup>` | Use `useFetch('/api/users')` for SSR |
| `onMounted(() => { fetch data })` for SSR | Use `useFetch` or `useAsyncData` instead |
| `<a href="/about">` for internal links | Use `<NuxtLink to="/about">` |
| `router.push()` before `navigateTo()` | Use `navigateTo()` — Nuxt-aware navigation |Skill
vue3
# Vue 3 Skill ## Description Use this skill when the task involves **creating, editing, or reviewing Vue 3 components, composables, Pinia stores, or Vue Router configuration**. This skill provides
# Vue 3 Skill
## Description
Use this skill when the task involves **creating, editing, or reviewing Vue 3 components, composables, Pinia stores, or Vue Router configuration**. This skill provides deep expertise in Vue 3 Composition API patterns, component architecture, state management, and performance optimization.
Trigger phrases: "create a component", "write a composable", "set up Pinia", "build a form", "add Vue Router", "make it reactive", "refactor to Composition API", "build a modal", "create a reusable component", "add a store", "should I use Pinia or TanStack Query", "set up TanStack Query", "axios vs tanstack", "when to use Pinia", "state management in Vue", "cache API data", "data fetching setup".
---
## How to Use This Skill
When working on Vue 3 tasks, follow this workflow:
1. **Understand the component's responsibility** — one component = one job
2. **Identify what state is needed** — local `ref`/`reactive`, composable, Pinia (client state), or TanStack Query (server state) — see State Management Decision Guide below
3. **Identify what the parent needs to know** — design the `emit` interface first
4. **Write `<script setup>` before the template** — logic drives structure
5. **Write the template to match the logic** — not the other way around
6. **Add scoped styles last** — no global leakage
---
## Component Skeleton
Every Vue 3 component must follow this exact structure:
```vue
<script setup lang="ts">
// 1. defineOptions (name for DevTools)
defineOptions({ name: 'ComponentName' })
// 2. Props interface + defineProps
interface Props {
title: string
count?: number
isDisabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
isDisabled: false,
})
// 3. Emits
const emit = defineEmits<{
submit: [value: string]
cancel: []
update: [field: string, value: unknown]
}>()
// 4. Injected dependencies (useRoute, useRouter, stores)
const router = useRouter()
const authStore = useAuthStore()
// 5. Local reactive state
const isOpen = ref(false)
const inputValue = ref('')
// 6. Computed values
const isValid = computed(() => inputValue.value.trim().length > 0)
const displayTitle = computed(() => props.title.toUpperCase())
// 7. Composables
const { user, isLoading } = useUser(props.userId)
// 8. Methods / handlers
function handleSubmit() {
if (!isValid.value) return
emit('submit', inputValue.value)
}
// 9. Lifecycle hooks
onMounted(() => {
// DOM is available here
})
onUnmounted(() => {
// cleanup here
})
</script>
<template>
<div class="component-root">
<!-- template content -->
</div>
</template>
<style scoped>
.component-root {
/* scoped styles only */
}
</style>
```
---
## Composable Skill
### When to extract a composable
- Logic is used in 2+ components → extract immediately
- Logic has its own loading/error state → extract always
- Logic involves async operations → extract always
- Logic involves event listeners or timers → extract and clean up
### Composable template
```ts
// composables/useResourceName.ts
import type { MaybeRef } from 'vue'
interface ResourceNameOptions {
immediate?: boolean
onError?: (err: Error) => void
}
export function useResourceName(
id: MaybeRef<string>,
options: ResourceNameOptions = {},
) {
const { immediate = true, onError } = options
// State
const data = ref<ResourceType | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// Core async function
async function fetch() {
if (!toValue(id)) return
isLoading.value = true
error.value = null
try {
data.value = await apiClient.get<ResourceType>(`/resource/${toValue(id)}`)
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err))
error.value = e
onError?.(e)
} finally {
isLoading.value = false
}
}
async function update(payload: Partial<ResourceType>) {
try {
data.value = await apiClient.patch(`/resource/${toValue(id)}`, payload)
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
}
}
// Reactive refetch when id changes
watch(() => toValue(id), fetch, { immediate })
return {
data: readonly(data),
isLoading: readonly(isLoading),
error: readonly(error),
fetch,
update,
}
}
```
### Key composable rules
- Return `readonly()` wrapped refs to prevent accidental mutation from outside
- Accept `MaybeRef<T>` for params so callers can pass either a `ref` or a raw value
- Use `toValue()` (Vue 3.3+) inside the composable to unwrap either
- Always clean up event listeners, timers, subscriptions inside `onUnmounted` or `watchEffect` cleanup
---
## Pinia Store Skill
### Store template (always use Setup Store style)
```ts
// stores/useExampleStore.ts
import { defineStore } from 'pinia'
interface ExampleState {
items: Item[]
selectedId: string | null
filter: string
}
export const useExampleStore = defineStore('example', () => {
// --- State ---
const items = ref<Item[]>([])
const selectedId = ref<string | null>(null)
const filter = ref('')
// --- Getters (computed) ---
const selectedItem = computed(() =>
items.value.find(i => i.id === selectedId.value) ?? null,
)
const filteredItems = computed(() =>
filter.value
? items.value.filter(i =>
i.name.toLowerCase().includes(filter.value.toLowerCase()),
)
: items.value,
)
// --- Actions ---
async function loadItems() {
try {
items.value = await $fetch<Item[]>('/api/items')
} catch (err) {
console.error('[ExampleStore] loadItems failed:', err)
}
}
async function createItem(payload: CreateItemPayload) {
const newItem = await $fetch<Item>('/api/items', {
method: 'POST',
body: payload,
})
items.value.push(newItem)
return newItem
}
function selectItem(id: string) {
selectedId.value = id
}
function setFilter(value: string) {
filter.value = value
}
// --- Reset ---
function $reset() {
items.value = []
selectedId.value = null
filter.value = ''
}
return {
// State (readonly to enforce actions)
items: readonly(items),
selectedId: readonly(selectedId),
filter: readonly(filter),
// Getters
selectedItem,
filteredItems,
// Actions
loadItems,
createItem,
selectItem,
setFilter,
$reset,
}
})
```
### Using the store in a component
```ts
// In component
const store = useExampleStore()
// ✅ Use storeToRefs for reactive state
const { items, selectedItem, filteredItems } = storeToRefs(store)
// ✅ Destructure actions directly (not reactive, they're functions)
const { loadItems, createItem, selectItem } = store
onMounted(() => loadItems())
```
---
## State Management Decision Guide
This is one of the most common points of confusion in modern Vue development. The short answer: **Pinia and TanStack Query are not competitors — they solve different problems and are often used together in the same project.**
### The Core Mental Model: Who Owns the Data?
```
Is this data from a server / API / database?
└─ YES → TanStack Query owns it
Examples: user profiles, product lists, orders, search results
Is this data purely inside your app (no server round-trip needed)?
└─ YES → Pinia owns it
Examples: sidebar open/closed, dark mode, current step in a wizard, auth token
```
### When to Use TanStack Query
Use TanStack Query for any **server state** — data that lives on an external server and needs to be synchronized with your UI.
**What it handles automatically so you don't have to:**
- `isLoading`, `isError`, `data` states — no manual `ref` boilerplate
- **Caching** — navigate away and back, data is served from cache instantly
- **Deduplication** — two components requesting the same data = one network request
- **Background refetching** — stale data is refreshed when the tab regains focus
- **Pagination & infinite scroll** — built-in primitives
- **Memory management** — garbage-collects data no longer used by any component
```ts
// ❌ Old way — raw Axios in onMounted (every dev writes this boilerplate)
const users = ref<User[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
onMounted(async () => {
isLoading.value = true
try {
const res = await axios.get('/api/users')
users.value = res.data
} catch (err) {
error.value = err as Error
} finally {
isLoading.value = false
}
})
// 👆 No caching. Refetches on every mount. Duplicates if two components need same data.
// ✅ TanStack Query way — all of the above, plus caching + dedup + background sync
import { useQuery } from '@tanstack/vue-query'
const { data: users, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: () => axios.get<User[]>('/api/users').then(res => res.data),
staleTime: 1000 * 60 * 5, // treat as fresh for 5 minutes
})
```
### When to Use Pinia
Use Pinia for **client state** — data that exists purely inside your frontend and doesn't need a server round-trip.
| Good Pinia use cases | Why not TanStack Query |
|---|---|
| Dark mode / theme preference | No server — it's a UI toggle |
| Sidebar collapsed / expanded | Local UI state, no async |
| Current authenticated user's display info | Populated once from auth, then referenced everywhere |
| Multi-step form data across route changes | Temporary local data, not persisted server-side |
| Selected filters that affect multiple views | Shared UI state between components |
| Shopping cart (before checkout) | Local until user submits |
```ts
// stores/useUIStore.ts — perfect Pinia use case
export const useUIStore = defineStore('ui', () => {
const isDarkMode = ref(false)
const isSidebarOpen = ref(true)
const activeLocale = ref<'en' | 'hi' | 'ur'>('en')
function toggleDarkMode() { isDarkMode.value = !isDarkMode.value }
function toggleSidebar() { isSidebarOpen.value = !isSidebarOpen.value }
function setLocale(locale: typeof activeLocale.value) { activeLocale.value = locale }
return { isDarkMode, isSidebarOpen, activeLocale, toggleDarkMode, toggleSidebar, setLocale }
})
```
### Axios vs TanStack Query — They're Not Alternatives
This is a key misconception. Think of them as different layers:
```
┌─────────────────────────────────────────┐
│ TanStack Query │ ← "WHEN": caching, retries, loading state,
│ manages the lifecycle of data │ deduplication, background sync
├─────────────────────────────────────────┤
│ Axios │ ← "HOW": makes the actual HTTP call,
│ makes the actual HTTP request │ handles headers, interceptors, auth tokens
└─────────────────────────────────────────┘
```
**The professional setup is to use Axios *inside* TanStack Query's `queryFn`:**
```ts
// lib/axios.ts — global Axios instance with auth interceptor
import axios from 'axios'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
})
// Auth interceptor — attach token to every request automatically
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access-token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Response interceptor — handle 401 globally
apiClient.interceptors.response.use(
res => res,
async (error) => {
if (error.response?.status === 401) {
await useAuthStore().logout()
}
return Promise.reject(error)
},
)
```
```ts
// composables/useUsers.ts — TanStack Query + Axios together
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
// Query (GET)
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const { data } = await apiClient.get<User[]>('/users')
return data
},
staleTime: 1000 * 60 * 2, // 2 minutes
})
}
// Mutation (POST / PATCH / DELETE) + cache invalidation
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: CreateUserPayload) =>
apiClient.post<User>('/users', payload).then(res => res.data),
// After creating a user, invalidate the users list so it refetches
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
```
```ts
// In a component — clean, no boilerplate
const { data: users, isLoading, isError } = useUsers()
const { mutate: createUser, isPending } = useCreateUser()
function handleSubmit(form: CreateUserPayload) {
createUser(form)
}
```
### Decision Summary
| Scenario | Tool |
|---|---|
| Fetch a list from `/api/users` | TanStack Query |
| Fetch a single record by ID | TanStack Query |
| Paginated table / infinite scroll | TanStack Query |
| POST / PATCH / DELETE with cache update | TanStack Query `useMutation` |
| Dark mode toggle | Pinia |
| Auth user display info (navbar, avatar) | Pinia |
| Multi-step wizard form state | Pinia |
| Shared filters across multiple views | Pinia |
| Global HTTP headers / auth tokens | Axios interceptors |
| Simple one-off script / utility | Raw `fetch` or Axios directly |
### ⚠️ Key Rule: Don't Bridge the Two
Never copy data from TanStack Query into a Pinia store. This creates two sources of truth that will get out of sync.
```ts
// ❌ Anti-pattern — bridging TanStack Query into Pinia
const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
watch(users, (val) => { userStore.setUsers(val) }) // Now you have two copies
// ✅ Correct — components consume TanStack Query directly
const { data: users } = useUsers() // that's it
```
### Setup: Installing TanStack Query in Vue 3
```ts
// main.ts
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute default freshness
retry: 2, // retry failed requests twice
refetchOnWindowFocus: true, // refresh when tab regains focus
},
},
})
app.use(VueQueryPlugin, { queryClient })
```
---
## Vue Router Skill
### Typed route definitions
```ts
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
component: () => import('@/pages/HomePage.vue'),
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('@/pages/UserDetailPage.vue'),
// Route-level guard
beforeEnter: (to) => {
if (!to.params.id) return { name: 'Home' }
},
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'DashboardHome',
component: () => import('@/pages/dashboard/DashboardHome.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/NotFoundPage.vue'),
},
],
})
// Global auth guard
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
export default router
```
### Navigating in components
```ts
const router = useRouter()
const route = useRoute()
// Typed param access
const userId = computed(() => route.params.id as string)
// Navigate with named route
await router.push({ name: 'UserDetail', params: { id: '123' } })
// Navigate with query
await router.push({ name: 'Search', query: { q: searchTerm.value } })
// Go back
router.back()
```
---
## Reactivity Patterns Cheat Sheet
| Pattern | When to use |
|---|---|
| `ref<T>(value)` | Primitives, arrays, objects you'll reassign |
| `reactive({})` | Objects you won't destructure or reassign |
| `computed(() => ...)` | Derived values — cached until deps change |
| `watch(source, cb)` | Side effects when reactive data changes |
| `watchEffect(cb)` | Side effects that auto-track dependencies |
| `shallowRef()` | Large objects — only top-level reactivity needed |
| `toRefs(reactive)` | Destructure reactive without losing reactivity |
| `toValue(maybeRef)` | Unwrap ref or raw value inside composables |
| `readonly(ref)` | Expose state from composable/store without allowing mutation |
| `markRaw(obj)` | Non-reactive objects (chart instances, maps, sockets) |
---
## Common Component Patterns
### Async component with loading state
```vue
<script setup lang="ts">
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMessage,
delay: 200,
timeout: 5000,
})
</script>
```
### v-model on custom component
```vue
<!-- Parent -->
<MyInput v-model="email" />
<!-- MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
```
### Provide / Inject (typed)
```ts
// In parent
const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
provide(ThemeKey, ref('light'))
// In child
const theme = inject(ThemeKey) // typed as Ref<'light' | 'dark'> | undefined
```
### Teleport for modals
```vue
<Teleport to="body">
<div v-if="isModalOpen" class="modal-overlay">
<div class="modal">
<slot />
</div>
</div>
</Teleport>
```
---
## Performance Checklist
Before completing any component, verify:
- [ ] `v-for` always has a stable `:key` (not index)
- [ ] `v-if` and `v-for` are never on the same element
- [ ] Heavy computations are in `computed()`, not template expressions
- [ ] Large non-reactive objects are wrapped in `markRaw()`
- [ ] Heavy components use `defineAsyncComponent()`
- [ ] Long lists (100+ items) use virtual scrolling
- [ ] Event listeners registered in `onMounted` are removed in `onUnmounted`