vue-expert
Vue 3 Composition API, reactivity system, Pinia state management, Nuxt 3, Vue Router, component design patterns, and migration from Options API.
SKILL.mdagent/skills/vue-expert/SKILL.md
GitHubVue Expert
Deep technical reference for senior Vue developers building production applications with Vue 3. Covers the Composition API in depth, Vue's reactivity system internals, component design patterns, Pinia state management, Vue Router advanced usage, Nuxt 3 full-stack patterns, testing strategies, and migration paths from Vue 2 and the Options API. Every section includes working code, rationale, and anti-patterns to avoid.
Table of Contents
- Composition API
- Reactivity System
- Component Patterns
- Pinia State Management
- Vue Router
- Nuxt 3
- Testing
- Migration and Performance
Composition API
setup() and Script Setup
<script setup> is the recommended syntax. It compiles the entire <script> block into the component's setup() function, reducing boilerplate and improving runtime performance through compile-time optimizations.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { User } from '@/types'
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{ (e: 'update', user: User): void }>()
const user = ref<User | null>(null)
const displayName = computed(() => user.value?.name ?? 'Anonymous')
onMounted(async () => {
user.value = await fetchUser(props.userId)
})
</script>
When you need to declare component options like inheritAttrs or a custom name, use a separate plain <script> block alongside <script setup>.
<script lang="ts">
export default { name: 'UserProfile', inheritAttrs: false }
</script>
<script setup lang="ts">
// setup logic here
</script>
ref vs reactive
ref() wraps any value (primitives, objects, arrays) in a reactive container accessed via .value. reactive() creates a deeply reactive proxy of an object but cannot hold primitives and loses reactivity if destructured.
import { ref, reactive } from 'vue'
// ref: works with any type, requires .value in script
const count = ref(0)
count.value++
const user = ref<User | null>(null)
user.value = { name: 'Alice', age: 30 }
// reactive: object-only, no .value needed, but cannot reassign root
const state = reactive({ items: [] as string[], loading: false })
state.loading = true
state.items.push('new item')
// DANGER: reassigning a reactive object breaks reactivity
// state = { items: [], loading: false } // reactivity lost
Prefer ref() as the default. It works uniformly with primitives and objects, is explicit about reactivity (.value), and avoids the destructuring trap of reactive().
computed
Computed refs are cached and only re-evaluate when their reactive dependencies change.
import { ref, computed } from 'vue'
const items = ref<Product[]>([])
const search = ref('')
// Read-only computed
const filtered = computed(() =>
items.value.filter(item =>
item.name.toLowerCase().includes(search.value.toLowerCase())
)
)
// Writable computed
const selectedIds = ref<Set<string>>(new Set())
const allSelected = computed({
get: () => selectedIds.value.size === items.value.length,
set: (val: boolean) => {
selectedIds.value = val
? new Set(items.value.map(i => i.id))
: new Set()
},
})
watch and watchEffect
watch tracks specific sources and provides old/new values. watchEffect automatically tracks every reactive dependency accessed during execution.
import { ref, watch, watchEffect } from 'vue'
const userId = ref('u-1')
const userData = ref<User | null>(null)
// watch: explicit source, lazy by default
watch(userId, async (newId, oldId) => {
console.log(`Changed from ${oldId} to ${newId}`)
userData.value = await fetchUser(newId)
}, { immediate: true })
// watch multiple sources
watch([userId, () => route.query.tab], ([id, tab]) => {
loadContent(id, tab as string)
})
// watchEffect: auto-tracks dependencies, runs immediately
const stop = watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const data = await fetch(`/api/v1/users/${userId.value}`, {
signal: controller.signal,
})
userData.value = await data.json()
})
// Stop the watcher manually when needed
stop()
Composables
Composables are functions that encapsulate and reuse stateful logic using Composition API. They are the Vue equivalent of React's custom hooks.
// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<string | null>
loading: Ref<boolean>
}
export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<string | null>(null)
const loading = ref(false)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const urlValue = typeof url === 'string' ? url : url.value
const response = await fetch(urlValue, { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e.message
}
} finally {
loading.value = false
}
})
return { data, error, loading }
}
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const { data: users, loading, error } = useFetch<User[]>('/api/v1/users')
</script>
Lifecycle Hooks
All Options API lifecycle hooks have Composition API equivalents prefixed with on.
| Options API | Composition API | When |
|---|---|---|
beforeCreate / created |
setup() itself |
Reactive data initialized |
beforeMount |
onBeforeMount |
Before first DOM render |
mounted |
onMounted |
DOM is available |
beforeUpdate |
onBeforeUpdate |
Before reactive DOM update |
updated |
onUpdated |
After reactive DOM update |
beforeUnmount |
onBeforeUnmount |
Before teardown |
unmounted |
onUnmounted |
After teardown |
errorCaptured |
onErrorCaptured |
Error from descendant |
activated |
onActivated |
<KeepAlive> re-activated |
deactivated |
onDeactivated |
<KeepAlive> deactivated |
Anti-patterns
- Using
reactive()for everything and then destructuring it -- reactivity is silently lost. Useref()or wrap destructured values withtoRefs(). - Accessing
.valueinside templates -- Vue auto-unwraps refs in templates, so{{ count }}is correct, not{{ count.value }}. - Putting side effects inside
computed()-- computed should be pure. UsewatchorwatchEffectfor side effects. - Creating composables that rely on component context but are called outside
setup()-- lifecycle hooks and inject only work within setup.
Reactivity System
How Reactivity Works
Vue 3 uses ES Proxy objects (replacing Vue 2's Object.defineProperty) to intercept get/set operations. When a reactive property is read inside a running effect (render function, computed, watcher), Vue tracks the dependency. When the property changes, Vue triggers all effects that depend on it.
ref(0) --> Proxy { value: 0 }
|
get .value --> track(effect)
set .value --> trigger(effects)
This proxy-based system has key advantages over Vue 2: it detects property additions/deletions, array index mutations, and Map/Set operations without special helper methods.
shallowRef and shallowReactive
Use shallow variants when you have large objects where deep reactivity tracking is wasteful. Only the top-level .value assignment (for shallowRef) or top-level property changes (for shallowReactive) trigger updates.
import { shallowRef, triggerRef } from 'vue'
const largeList = shallowRef<DataRow[]>([])
// This does NOT trigger reactivity (mutating inner array)
largeList.value.push({ id: '1', value: 42 })
// Option 1: reassign .value to trigger
largeList.value = [...largeList.value, { id: '2', value: 99 }]
// Option 2: mutate then manually trigger
largeList.value.push({ id: '3', value: 77 })
triggerRef(largeList)
toRef and toRefs
Convert reactive object properties into individual refs that maintain the reactive connection.
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({ name: 'Alice', age: 30 })
// Single property
const nameRef = toRef(state, 'name')
nameRef.value = 'Bob' // state.name is now 'Bob'
// All properties -- essential for destructuring without losing reactivity
const { name, age } = toRefs(state)
name.value = 'Charlie' // state.name is now 'Charlie'
The most common use is destructuring props in composables:
export function useUserSearch(props: { query: string; limit: number }) {
const { query, limit } = toRefs(props)
// query and limit are now reactive refs
}
Reactivity Transform (Deprecated)
The Reactivity Transform ($ref, $computed, $toRef) was an experimental compile-time feature that removed the need for .value. It was deprecated in Vue 3.3 and removed in Vue 3.4. Do not use it in new code. If migrating, replace $ref(0) with ref(0) and add .value access.
markRaw and toRaw
Opt objects out of reactivity entirely when they should never be observed (class instances, third-party library objects, immutable data).
import { markRaw, reactive, toRaw } from 'vue'
import { Chart } from 'chart.js'
const state = reactive({
chartInstance: markRaw(new Chart(/* ... */)),
data: [1, 2, 3],
})
// toRaw: get the original object behind a reactive proxy
const raw = toRaw(state) // plain object, no proxy
Reactivity Gotchas
| Gotcha | Explanation | Fix |
|---|---|---|
Destructuring reactive() |
Extracts plain values, loses reactivity | Use toRefs() or use ref() instead |
Replacing a reactive root |
state = newObj breaks the proxy |
Mutate properties: Object.assign(state, newObj) |
Adding properties to reactive |
Works in Vue 3 (Proxy), but not Vue 2 | No fix needed in Vue 3 |
| Non-primitive ref in template | Auto-unwrapped, no .value needed |
Just use {{ myRef }} |
| Async gap in watchEffect | Dependencies accessed after await are not tracked |
Access all reactive deps before the first await |
Anti-patterns
- Wrapping everything in
reactive()whenref()is simpler and safer for primitives. - Calling
triggerRef()frequently instead of designing state updates as immutable replacements. - Forgetting that
watchEffectonly tracks dependencies accessed synchronously during execution -- anything read after anawaitis invisible to the tracker. - Using
markRawon data that should be reactive -- typically a sign of premature optimization.
Component Patterns
Props and Emits with TypeScript
Vue 3.3+ supports type-only props/emits declarations with defaults.
<script setup lang="ts">
interface Props {
title: string
count?: number
items: string[]
variant?: 'primary' | 'secondary'
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'primary',
})
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: string): void
}>()
// Vue 3.3+ shorthand emit syntax
const emit2 = defineEmits<{
update: [value: string]
delete: [id: string]
}>()
</script>
v-model on Components
Vue 3 supports multiple v-model bindings on a single component. Each maps to a modelValue-style prop and update: event.
<!-- Parent -->
<UserForm v-model:name="userName" v-model:email="userEmail" />
<!-- UserForm.vue -->
<script setup lang="ts">
const name = defineModel<string>('name', { required: true })
const email = defineModel<string>('email', { required: true })
</script>
<template>
<input :value="name" @input="name = ($event.target as HTMLInputElement).value" />
<input :value="email" @input="email = ($event.target as HTMLInputElement).value" />
</template>
defineModel() (Vue 3.4+) creates a ref that automatically syncs with the parent's v-model. This replaces the manual defineProps + defineEmits pattern.
Slots: Named, Scoped, and Dynamic
<!-- BaseLayout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" :year="currentYear" /></footer>
</template>
<!-- Parent usage -->
<BaseLayout>
<template #header>
<h1>Dashboard</h1>
</template>
<p>Main content goes in the default slot</p>
<template #footer="{ year }">
<span>© {{ year }}</span>
</template>
</BaseLayout>
Dynamic slot names allow programmatic slot selection:
<template v-for="section in sections" :key="section.id">
<slot :name="section.slotName" :data="section.data" />
</template>
Renderless Components with Scoped Slots
Renderless components encapsulate logic without prescribing UI. They expose state and methods through scoped slots.
<!-- MouseTracker.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<slot :x="x" :y="y" />
</template>
<!-- Usage -->
<MouseTracker v-slot="{ x, y }">
<p>Mouse is at {{ x }}, {{ y }}</p>
</MouseTracker>
provide / inject
Dependency injection for deeply nested components. Avoids prop drilling.
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
export const ApiClientKey: InjectionKey<ApiClient> = Symbol('api-client')
<!-- Provider (ancestor) -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey } from '@/types/injection-keys'
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
</script>
<!-- Consumer (any descendant) -->
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from '@/types/injection-keys'
const theme = inject(ThemeKey)
// type: Ref<'light' | 'dark'> | undefined
// With default value (removes undefined from type)
const theme2 = inject(ThemeKey, ref('light'))
</script>
Async Components and Suspense
Lazy-load components with loading and error states.
import { defineAsyncComponent } from 'vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // ms before showing loading component
timeout: 10000, // ms before showing error component
})
<Suspense> (experimental) provides a declarative way to handle async dependencies in a component subtree:
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
Components with async setup() are automatically Suspense-compatible:
<script setup lang="ts">
const data = await fetchDashboardData() // top-level await makes this async
</script>
Anti-patterns
- Deeply nesting
provide/injectwithout typed injection keys -- leads tounknowntypes and missing dependency errors at runtime. - Using
Suspensein production without an error boundary -- if the async component fails, the entire subtree disappears. - Defining emit names as strings without TypeScript definitions -- prevents type checking of emitted payloads.
- Creating "god components" that mix layout, logic, and data fetching -- extract composables for logic and use slots for flexible layouts.
Pinia State Management
Defining Stores
Pinia supports two syntaxes: Options stores and Setup stores. Setup stores align with the Composition API and offer more flexibility.
// stores/useAuthStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Setup store (recommended)
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
const displayName = computed(() => user.value?.name ?? 'Guest')
async function login(credentials: LoginPayload) {
const response = await api.post('/api/v1/auth/login', credentials)
token.value = response.data.token
user.value = response.data.user
}
function logout() {
token.value = null
user.value = null
}
return { user, token, isAuthenticated, displayName, login, logout }
})
// Options store syntax (familiar for Vuex users)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, lastUpdated: null as Date | null }),
getters: {
doubleCount: (state) => state.count * 2,
isPositive(): boolean { return this.count > 0 },
},
actions: {
increment() {
this.count++
this.lastUpdated = new Date()
},
async fetchCount() {
const data = await api.get('/api/v1/counter')
this.count = data.count
},
},
})
Using Stores in Components
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
// Destructure reactive state with storeToRefs (preserves reactivity)
const { user, isAuthenticated } = storeToRefs(authStore)
// Actions can be destructured directly (they are plain functions)
const { login, logout } = authStore
</script>
Plugins
Pinia plugins extend every store with additional behavior.
// plugins/piniaLogger.ts
import type { PiniaPlugin } from 'pinia'
export const piniaLogger: PiniaPlugin = ({ store }) => {
store.$onAction(({ name, args, after, onError }) => {
console.log(`[${store.$id}] Action: ${name}`, args)
after((result) => {
console.log(`[${store.$id}] Action ${name} completed`, result)
})
onError((error) => {
console.error(`[${store.$id}] Action ${name} failed`, error)
})
})
}
// main.ts
const pinia = createPinia()
pinia.use(piniaLogger)
Composing Stores
Stores can use other stores. Import and call the store function inside actions or getters.
export const useCartStore = defineStore('cart', () => {
const authStore = useAuthStore()
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
async function checkout() {
if (!authStore.isAuthenticated) {
throw new Error('Must be logged in to checkout')
}
await api.post('/api/v1/orders', {
items: items.value,
userId: authStore.user!.id,
})
items.value = []
}
return { items, total, checkout }
})
SSR Hydration
When using Pinia with SSR (Nuxt 3), the server serializes store state into the HTML payload. The client hydrates it automatically. For non-serializable state, use skipHydrate:
import { skipHydrate } from 'pinia'
export const useWebSocketStore = defineStore('ws', () => {
const socket = ref(skipHydrate(null as WebSocket | null))
const messages = ref<Message[]>([])
return { socket, messages }
})
Anti-patterns
- Destructuring state directly from a store (
const { count } = store) -- loses reactivity. UsestoreToRefs()for state and getters. - Putting everything in a single store -- split by domain (auth, cart, ui, notifications) for maintainability and code splitting.
- Mutating state outside actions in production -- enable strict mode (
pinia.use(strictMode)or Nuxt devtools) to catch direct mutations during development. - Using Pinia for server/cache state that TanStack Query or
useFetchhandles better -- Pinia is for client-side application state.
Vue Router
Route Definitions with TypeScript
// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('@/pages/Home.vue') },
{ path: 'about', name: 'about', component: () => import('@/pages/About.vue') },
],
},
{
path: '/users/:id',
name: 'user-profile',
component: () => import('@/pages/UserProfile.vue'),
props: true, // passes :id as a prop
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
children: [
{ path: '', name: 'admin-dashboard', component: () => import('@/pages/admin/Dashboard.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/pages/admin/Users.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFound.vue') },
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
export default router
Navigation Guards
Guards run in order: global beforeEach -> per-route beforeEnter -> in-component beforeRouteEnter -> global afterEach.
// Global guard: authentication check
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.roles) {
const hasRole = (to.meta.roles as string[]).some(
role => authStore.user?.roles.includes(role)
)
if (!hasRole) return { name: 'forbidden' }
}
})
// Per-route guard
{
path: '/checkout',
component: () => import('@/pages/Checkout.vue'),
beforeEnter: (to) => {
const cart = useCartStore()
if (cart.items.length === 0) return { name: 'shop' }
},
}
Composable-Based Navigation
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Reactive route params
const userId = computed(() => route.params.id as string)
// Programmatic navigation
async function handleSave() {
await saveUser()
router.push({ name: 'user-profile', params: { id: userId.value } })
}
// Replace current history entry
router.replace({ query: { ...route.query, page: '2' } })
</script>
Dynamic Routes
Add or remove routes at runtime for plugin systems or feature flags.
// Add route dynamically
const removeRoute = router.addRoute({
path: '/experiments/:slug',
name: 'experiment',
component: () => import('@/pages/Experiment.vue'),
})
// Remove it later
removeRoute()
// Or remove by name
router.removeRoute('experiment')
Typed Route Meta
Augment the RouteMeta interface to get type safety on route.meta.
// router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
transition?: string
}
}
Anti-patterns
- Using string paths everywhere instead of named routes -- breaks when paths change.
- Fetching data in
beforeEachglobal guards -- slows every navigation. Use per-route guards or in-component data fetching. - Not lazy-loading route components -- ships the entire app in one bundle.
- Ignoring the
scrollBehavioroption -- users expect scroll position to restore on back navigation.
Nuxt 3
Auto-Imports
Nuxt 3 auto-imports Vue APIs, composables, and utilities. No manual import statements needed for ref, computed, useState, useFetch, navigateTo, or any composable in the composables/ directory.
<!-- No imports needed -->
<script setup lang="ts">
const count = ref(0)
const doubled = computed(() => count.value * 2)
const { data: users } = await useFetch('/api/v1/users')
</script>
Custom composables placed in composables/ are auto-imported by filename:
composables/
useAuth.ts --> useAuth() available everywhere
useFormatDate.ts --> useFormatDate() available everywhere
File-Based Routing
Nuxt generates routes from the pages/ directory structure.
pages/
index.vue --> /
about.vue --> /about
users/
index.vue --> /users
[id].vue --> /users/:id
posts/
[...slug].vue --> /posts/* (catch-all)
admin/
[[dashboard]].vue --> /admin, /admin/dashboard (optional param)
Server Routes (API Routes)
Full-stack API endpoints live in server/api/ and server/routes/.
// server/api/users/[id].get.ts
import { defineEventHandler, getRouterParam, createError } from 'h3'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await db.user.findUnique({ where: { id } })
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
return user
})
// server/api/users/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ name: string; email: string }>(event)
const user = await db.user.create({ data: body })
return user
})
useFetch and useAsyncData
useFetch is a convenience wrapper around useAsyncData + $fetch. It deduplicates requests during SSR and auto-serializes data for hydration.
<script setup lang="ts">
// Basic usage -- runs on server during SSR, hydrates on client
const { data: users, pending, error, refresh } = await useFetch<User[]>('/api/v1/users')
// With query params and watch
const page = ref(1)
const { data: posts } = await useFetch('/api/v1/posts', {
query: { page, limit: 20 },
watch: [page], // re-fetches when page changes
})
// useAsyncData for custom async logic
const { data: stats } = await useAsyncData('dashboard-stats', () => {
return Promise.all([
$fetch('/api/v1/stats/revenue'),
$fetch('/api/v1/stats/users'),
])
})
// Client-only fetch (skip SSR)
const { data: notifications } = await useFetch('/api/v1/notifications', {
server: false,
})
</script>
Middleware
Nuxt middleware runs before navigating to a route. Place files in middleware/.
// middleware/auth.ts (named middleware -- applied per-page)
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
return navigateTo('/login', { redirectCode: 301 })
}
})
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
layout: 'admin',
})
</script>
Global middleware runs on every route -- suffix the filename with .global:
// middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
trackPageView(to.fullPath)
})
Modules and Runtime Config
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt', '@nuxt/image', '@vueuse/nuxt'],
runtimeConfig: {
// Server-only (not exposed to client)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
// Exposed to client
public: {
apiBase: process.env.API_BASE || '/api/v1',
appName: 'My App',
},
},
})
<script setup lang="ts">
const config = useRuntimeConfig()
// In client code: only config.public.* is available
const apiBase = config.public.apiBase
</script>
Deployment
Nuxt 3 uses Nitro as its server engine, supporting multiple deployment presets.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'node-server', // Node.js (default)
// preset: 'vercel', // Vercel
// preset: 'cloudflare', // Cloudflare Workers
// preset: 'netlify', // Netlify
// preset: 'static', // Static site generation
},
})
Anti-patterns
- Using
$fetchdirectly in components instead ofuseFetch-- causes duplicate requests (server + client) during SSR. - Putting secrets in
runtimeConfig.public-- anything underpublicis shipped to the client. Use root-levelruntimeConfigfor secrets. - Creating API routes that bypass Nuxt's server middleware -- use
server/middleware/for cross-cutting concerns like auth validation. - Ignoring the
keyoption onuseFetchwhen the same endpoint is called with different params -- causes cache collisions.
Testing
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
Component Testing with Vue Test Utils
// components/__tests__/TodoList.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import TodoList from '../TodoList.vue'
describe('TodoList', () => {
it('renders todo items', () => {
const wrapper = mount(TodoList, {
props: {
items: [
{ id: '1', text: 'Write tests', done: false },
{ id: '2', text: 'Ship feature', done: true },
],
},
})
const items = wrapper.findAll('[data-testid="todo-item"]')
expect(items).toHaveLength(2)
expect(items[0].text()).toContain('Write tests')
})
it('emits remove event when delete is clicked', async () => {
const wrapper = mount(TodoList, {
props: {
items: [{ id: '1', text: 'Test item', done: false }],
},
})
await wrapper.find('[data-testid="delete-btn"]').trigger('click')
expect(wrapper.emitted('remove')).toBeTruthy()
expect(wrapper.emitted('remove')![0]).toEqual(['1'])
})
it('applies completed class to done items', () => {
const wrapper = mount(TodoList, {
props: {
items: [{ id: '1', text: 'Done task', done: true }],
},
})
expect(wrapper.find('[data-testid="todo-item"]').classes()).toContain('completed')
})
})
Testing with Plugins and Global Dependencies
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { createI18n } from 'vue-i18n'
import UserProfile from '../UserProfile.vue'
function mountWithPlugins(component: any, options = {}) {
return mount(component, {
global: {
plugins: [
createTestingPinia({
initialState: { auth: { user: { name: 'Test User' } } },
createSpy: vi.fn,
}),
createI18n({ locale: 'en', messages: { en: { greeting: 'Hello' } } }),
],
stubs: {
RouterLink: true,
teleport: true,
},
},
...options,
})
}
it('displays user name from store', () => {
const wrapper = mountWithPlugins(UserProfile)
expect(wrapper.text()).toContain('Test User')
})
Testing Composables
// composables/__tests__/useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('increments and decrements', () => {
const { count, increment, decrement } = useCounter(10)
expect(count.value).toBe(10)
increment()
expect(count.value).toBe(11)
decrement()
expect(count.value).toBe(10)
})
})
For composables that require a component context (using provide/inject, lifecycle hooks, or getCurrentInstance), wrap them in a test component:
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useAuth } from '../useAuth'
function withSetup<T>(composable: () => T) {
let result!: T
const TestComponent = defineComponent({
setup() {
result = composable()
return () => h('div')
},
})
const wrapper = mount(TestComponent)
return { result, wrapper }
}
it('provides authentication state', () => {
const { result } = withSetup(() => useAuth())
expect(result.isAuthenticated.value).toBe(false)
})
Snapshot Testing
Use sparingly for stable, presentational components.
it('matches snapshot', () => {
const wrapper = mount(AppBadge, {
props: { label: 'New', variant: 'success' },
})
expect(wrapper.html()).toMatchSnapshot()
})
Anti-patterns
- Testing implementation details (internal ref values, private methods) instead of rendered output and emitted events.
- Mounting the entire app tree for unit tests -- use
shallowMountor stub heavy children. - Not using
@pinia/testing-- testing with a real Pinia store makes tests depend on global state. - Skipping
await nextTick()orawait flushPromises()after state changes -- assertions run before the DOM updates. - Over-relying on snapshot tests -- they break on every markup change and rarely catch logic bugs.
Migration and Performance
Options API to Composition API Migration
Migrate incrementally. Both APIs work in the same component during transition.
| Options API | Composition API Equivalent |
|---|---|
data() |
ref() / reactive() |
computed: {} |
computed() |
methods: {} |
Plain functions |
watch: {} |
watch() / watchEffect() |
mounted() etc. |
onMounted() etc. |
this.$emit() |
emit() from defineEmits |
mixins |
Composables |
provide / inject |
provide() / inject() |
Step-by-step migration for a single component:
<!-- BEFORE: Options API -->
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: { userId: { type: String, required: true } },
data() {
return { user: null as User | null, loading: false }
},
computed: {
displayName(): string {
return this.user?.name ?? 'Unknown'
},
},
watch: {
userId: {
immediate: true,
async handler(newId: string) {
this.loading = true
this.user = await fetchUser(newId)
this.loading = false
},
},
},
})
</script>
<!-- AFTER: Composition API -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const props = defineProps<{ userId: string }>()
const user = ref<User | null>(null)
const loading = ref(false)
const displayName = computed(() => user.value?.name ?? 'Unknown')
watch(() => props.userId, async (newId) => {
loading.value = true
user.value = await fetchUser(newId)
loading.value = false
}, { immediate: true })
</script>
Mixin to Composable Migration
// BEFORE: mixin (Options API)
export const paginationMixin = {
data() {
return { page: 1, pageSize: 20, total: 0 }
},
computed: {
totalPages() { return Math.ceil(this.total / this.pageSize) },
},
methods: {
nextPage() { if (this.page < this.totalPages) this.page++ },
prevPage() { if (this.page > 1) this.page-- },
},
}
// AFTER: composable (Composition API)
export function usePagination(pageSize = 20) {
const page = ref(1)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
function nextPage() { if (page.value < totalPages.value) page.value++ }
function prevPage() { if (page.value > 1) page.value-- }
return { page, total, totalPages, pageSize, nextPage, prevPage }
}
Vue 2 to Vue 3 Migration
Key breaking changes to address:
| Vue 2 | Vue 3 | Action |
|---|---|---|
new Vue() |
createApp() |
Replace global Vue instance |
Vue.component() |
app.component() |
Scope to app instance |
Vue.directive() |
app.directive() |
Scope to app instance |
Vue.filter() |
Removed | Use computed or methods |
$on, $off, $once |
Removed | Use external event bus (mitt) |
Vue.set(), Vue.delete() |
Not needed | Proxy handles additions/deletions |
.sync modifier |
v-model:propName |
Update template syntax |
.native modifier |
Removed | All events are native by default |
$children |
Removed | Use refs or provide/inject |
$listeners |
Merged into $attrs |
Update attribute forwarding |
Use the official migration build (@vue/compat) for gradual migration:
// vue.config.js or vite.config.ts
export default {
resolve: {
alias: {
vue: '@vue/compat',
},
},
}
Performance Optimization
Virtual Scrolling for Large Lists
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
allItems, // ref with thousands of items
{ itemHeight: 48, overscan: 10 }
)
</script>
<template>
<div v-bind="containerProps" style="height: 600px; overflow-y: auto">
<div v-bind="wrapperProps">
<div v-for="{ data, index } in list" :key="index" style="height: 48px">
{{ data.name }}
</div>
</div>
</div>
</template>
v-once, v-memo, and Computed Caching
<template>
<!-- v-once: render once, never update -->
<footer v-once>
<p>© {{ year }} Company Inc.</p>
</footer>
<!-- v-memo: skip re-render unless dependencies change -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<ExpensiveComponent :item="item" />
</div>
</template>
KeepAlive for Route Caching
<template>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['DashboardPage', 'SettingsPage']" :max="5">
<component :is="Component" />
</KeepAlive>
</RouterView>
</template>
Bundle Size Analysis
# Vite
npx vite-bundle-visualizer
# Nuxt
npx nuxi analyze
Tree Shaking
Vue 3 is fully tree-shakable. Unused features (Transition, KeepAlive, Teleport, Suspense) are eliminated from production builds when not imported. Third-party libraries should be evaluated for tree-shaking support.
// Named imports enable tree shaking
import { ref, computed, watch } from 'vue'
// Avoid importing entire libraries
// BAD: import _ from 'lodash'
// GOOD: import debounce from 'lodash-es/debounce'
Anti-patterns
- Migrating an entire codebase at once -- convert one component at a time, starting with leaf components.
- Using
@vue/compatpermanently -- it adds bundle size and hides deprecation warnings. Treat it as a temporary bridge. - Adding
v-memoeverywhere -- it has overhead and only helps when re-renders are measurably expensive. - Ignoring
defineAsyncComponentfor heavy components -- eager loading everything defeats code splitting. - Not profiling before optimizing -- use Vue DevTools performance tab to identify actual bottlenecks.