vue-nuxt-skills

9

vue-nuxt-skills plugin for Cursor

2 skills

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 |

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`