Blog › ICP guides
Vue developer on retainer: Composition API architecture, Pinia state management, Nuxt 3 SSR, and Vue performance optimization on monthly retainer
August 12, 2026 · ~18 min read
A twelve-person SaaS company had a Vue 2 frontend that was getting slower with every new feature. The Options API components had deeply nested watchers — a watch on a user object triggered a watcher on a derived computed property, which mutated a Vuex store, which triggered another watcher in a separate component via a getter. The Vuex store had mutation chains that were impossible to trace: a single button click dispatched three actions in sequence, each committing mutations that updated overlapping slices of state, and no one on the team could confidently say which order the watchers fired. Every page navigation triggered full component re-renders that Visual Studio Code’s performance tab showed consuming 400 to 600 milliseconds of main-thread time.
A fractional Vue architect on monthly retainer led the migration. The audit started with Vue DevTools’ performance timeline: recording a filter interaction and expanding the component render flamegraph revealed that a ProductList component was re-rendering completely on every keystroke in the search field because a reactive() object holding 300 product records was being mutated in-place, triggering Vue’s deep reactivity tracking across the entire nested structure. A computed getter in the Vuex store held a non-serializable function reference — a sort comparator — that caused Vue DevTools serialization to fail and, more importantly, caused Vuex’s reactivity tracking to behave unpredictably across hot-module replacement cycles. Another computed property re-evaluated on every state change in an unrelated store slice because its dependency array was implicitly captured from a this.$store.state access that touched the root state object rather than a specific slice.
The architect incrementally migrated the application to Composition API with Pinia. The migration strategy: new components were written in <script setup> from the start; existing Options API components were migrated in order of complexity, with the simplest components (stateless presentational components) migrated first and the complex watcher-heavy components last. Each Vuex module was replaced by a Pinia store using defineStore setup syntax, which allowed direct use of ref, computed, and watch inside the store’s setup function rather than the separate state/getters/mutations/actions objects. The shallowReactive replacement for deeply nested product data reduced the reactive tracking overhead by 10x, bringing the filter interaction from 400ms to 38ms.
Vue developers, Vue architects, and Vue consultants on monthly retainer — fractional Vue engineers, Nuxt consultants, and Vue.js migration advisors — do their highest-value work in the Composition API architecture, Pinia store design, Nuxt 3 server-side rendering configuration, and reactivity system performance optimization that produces the fast, maintainable frontend the product director reports on to the CEO. This guide covers Vue 3 Composition API in depth, Pinia state management, Vue performance optimization, Nuxt 3 architecture, and Vue testing — and how to structure a Vue developer retainer that makes the hours behind each component-level function visible.
Vue 3 Composition API
Vue 3’s Composition API is the foundational change that the Vue architect builds all production-grade component architecture on. Understanding the Composition API’s reactivity primitives, their caching and dependency tracking semantics, and the differences between setup() and <script setup> is the prerequisite for all other Vue architecture work.
setup() vs <script setup>
The setup() function is the Options API-compatible entry point for the Composition API. It receives props and context as arguments and must return an object whose properties are exposed to the component template. <script setup> is compile-time syntactic sugar that eliminates the boilerplate: every top-level binding declared in <script setup> is automatically available in the template without an explicit return statement.
// Options API setup() — explicit return required:
export default {
props: { userId: String },
setup(props, { emit, expose }) {
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
// Must return everything the template needs:
return { count, doubled, increment }
}
}
// <script setup> — no return needed; all top-level bindings auto-exposed to template:
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{ change: [value: string] }>()
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
// count, doubled, increment are all available in template automatically
</script>
The key behavioral difference: in setup(), the expose context function controls what the parent can access via a template ref — if expose() is called with an object, only those properties are accessible; if expose() is never called, all returned properties are accessible. In <script setup>, components are closed by default — a parent holding a template ref to a <script setup> child sees nothing unless the child explicitly calls defineExpose:
// Child.vue — <script setup> component:
<script setup lang="ts">
const internalCount = ref(0)
const publicValue = ref('hello')
function reset() { internalCount.value = 0 }
// Only publicValue and reset are visible to parent template refs:
defineExpose({ publicValue, reset })
</script>
// Parent.vue:
<script setup lang="ts">
import Child from './Child.vue'
const childRef = useTemplateRef<InstanceType<typeof Child>>('child')
function onButtonClick() {
childRef.value?.reset() // accessible — exposed
childRef.value?.publicValue // accessible — exposed
// childRef.value?.internalCount // undefined — not exposed
}
</script>
<template>
<Child ref="child" />
<button @click="onButtonClick">Reset</button>
</template>
useTemplateRef() (Vue 3.5+) is the typed alternative to the string-based ref="child" + const childRef = ref(null) pattern. It returns a typed ShallowRef bound to the DOM element or component instance named by the key, eliminating the need for as InstanceType<typeof Child> assertions at every access site.
ref() vs reactive()
ref() wraps any value — primitives, arrays, objects — in a reactive container that exposes the wrapped value as .value. In templates, Vue automatically unwraps refs (no .value required). reactive() makes an entire object reactive by proxying it; properties of a reactive object do not need .value to be accessed.
// ref() — always access via .value in script; auto-unwrapped in template:
const count = ref(0)
const user = ref<User | null>(null)
const items = ref<string[]>([])
count.value++ // mutation in script
items.value.push('new-item') // mutating the array ref holds
user.value = { id: 'u-1', name: 'Ada' } // replacing the object ref holds
// reactive() — access properties directly; but destructuring loses reactivity:
const state = reactive({
count: 0,
user: null as User | null,
items: [] as string[],
})
state.count++ // direct property mutation
state.items.push('new-item')
state.user = { id: 'u-1', name: 'Ada' }
// DANGER: destructuring a reactive object loses reactivity:
const { count } = state // count is now a plain number — no longer reactive
count++ // does NOT update state.count or trigger re-renders
// Fix: use toRef() for a single property, or toRefs() for all properties:
const countRef = toRef(state, 'count') // reactive ref connected to state.count
const { count: countRef2, user: userRef } = toRefs(state) // all refs, all connected
The Vue architect’s rule of thumb: use ref() for primitives (numbers, strings, booleans) and for object references that may be replaced wholesale (a nullable user, a paginated list being replaced on each page fetch); use reactive() for stable objects with multiple related properties that are mutated in-place and where you want direct property access. When passing reactive state to composables, always pass toRef(state, 'prop') or toRefs(state) rather than the raw destructured value — this is the most common reactivity bug in Options API-to-Composition API migrations.
computed(): caching, lazy evaluation, and writable computed
computed() creates a reactive value that is derived from other reactive sources. The critical property: computed values are cached based on their reactive dependencies. A computed getter only re-runs when one of the reactive values it accessed during its last evaluation changes. Multiple template bindings to the same computed value trigger only one evaluation per dependency change, not one per binding.
// Read-only computed — getter only:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Re-evaluates ONLY when firstName or lastName changes.
// Accessing fullName.value 100 times between changes evaluates the getter once.
// Writable computed — getter + setter:
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (newValue: string) => {
const parts = newValue.split(' ')
firstName.value = parts[0] ?? ''
lastName.value = parts.slice(1).join(' ')
}
})
fullName.value = 'Charles Babbage' // triggers setter; firstName and lastName update
// Dependency tracking pitfall — avoid accessing unrelated reactive state:
const allUsers = ref<User[]>([])
const filterText = ref('')
const theme = ref('dark') // unrelated to filtering
// BAD: this computed re-evaluates when theme changes even though theme doesn't affect the result:
const filteredUsers = computed(() => {
console.log(theme.value) // accesses theme — adds it as a dependency
return allUsers.value.filter(u => u.name.includes(filterText.value))
})
// GOOD: only access reactive state that actually contributes to the computation:
const filteredUsers = computed(() =>
allUsers.value.filter(u => u.name.includes(filterText.value))
)
watch() vs watchEffect()
watch(source, handler, options) watches a specific reactive source (a ref, a reactive object, a getter function, or an array of these) and calls the handler only when the source changes. It receives the new value and the previous value. watchEffect(fn) immediately runs its function and automatically tracks every reactive dependency accessed during the run, re-running whenever any of those dependencies change.
// watch — explicit source, lazy by default (does not run on mount):
const userId = ref<string | null>(null)
watch(userId, async (newId, oldId) => {
if (!newId) return
userData.value = await fetchUser(newId)
}, { immediate: true }) // immediate: true runs on mount with initial value
// watch with getter — watches a derived value without a computed:
watch(
() => route.params.id as string,
async (newId) => { await loadPost(newId) },
{ immediate: true }
)
// deep watch — tracks nested mutations in a reactive object:
watch(formState, (newState) => {
saveFormDraft(newState)
}, { deep: true })
// once option (Vue 3.4+) — fires once then stops:
watch(userId, (id) => {
analyticsTracker.identify(id)
}, { once: true })
// watchEffect — implicit dependency tracking, runs immediately:
watchEffect(async () => {
// Accesses both userId and includeArchived — both become dependencies automatically.
// Re-runs when either changes. No explicit source list required.
if (!userId.value) return
userData.value = await fetchUser(userId.value, {
includeArchived: includeArchived.value
})
})
The Vue architect’s rule: use watch when you need the previous value, when you want lazy behavior (not running on mount), or when the dependency source is explicit and controlled. Use watchEffect when the side effect naturally needs everything it accesses — an API call that uses several reactive parameters — and you want the dependency tracking to be implicit and automatically updated as the function body evolves.
provide / inject with typed injection keys
provide and inject allow an ancestor component to supply values to any descendant in the component tree without prop drilling. In Vue 3, typed injection keys using InjectionKey<T> from vue give TypeScript full type inference at the inject call site:
// injectionKeys.ts — shared module imported by provider and consumer:
import type { InjectionKey } from 'vue'
interface UserService {
currentUser: Readonly<Ref<User | null>>
login(credentials: Credentials): Promise<void>
logout(): Promise<void>
}
export const USER_SERVICE_KEY: InjectionKey<UserService> = Symbol('UserService')
// Provider — App.vue or a layout component:
<script setup lang="ts">
import { USER_SERVICE_KEY } from './injectionKeys'
const userService = createUserService() // returns UserService-shaped object
provide(USER_SERVICE_KEY, userService)
</script>
// Consumer — any descendant component:
<script setup lang="ts">
import { USER_SERVICE_KEY } from './injectionKeys'
// TypeScript infers userService as UserService (the InjectionKey's type parameter).
// The non-null assertion is appropriate when injection is guaranteed by the app structure.
const userService = inject(USER_SERVICE_KEY)!
// Alternatively, provide a fallback for optional injection:
const userService = inject(USER_SERVICE_KEY, createNoopUserService())
</script>
The string-key alternative (inject('userService')) returns unknown and requires a type assertion at every usage. Typed InjectionKeys are the production-grade pattern: they move the type assertion to the key definition (a single location) and provide full autocompletion at all injection sites.
Pinia state management
Pinia is the officially recommended state management library for Vue 3, replacing Vuex. Its design removes the mutations layer (Vuex required state changes to go through explicit mutation functions; Pinia allows direct state assignment inside actions), provides TypeScript inference without decoration, and integrates with Vue DevTools for action timeline tracking and state snapshot diffing.
defineStore: setup syntax vs options syntax
Pinia’s defineStore supports two syntaxes. The options syntax mirrors Vuex’s structure and is the easier migration target for teams coming from Vuex. The setup syntax is the more powerful choice: it allows using any Composition API primitive directly inside the store, making computed refs, watchers, and composables available without a translation layer.
// Options syntax — familiar Vuex-like shape:
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
couponCode: null as string | null,
}),
getters: {
totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price, 0),
itemCount: (state) => state.items.length,
},
actions: {
addItem(item: CartItem) {
this.items.push(item)
},
async applyCoupon(code: string) {
const discount = await validateCoupon(code)
if (discount) { this.couponCode = code }
},
},
})
// Setup syntax — full Composition API inside the store:
export const useCartStore = defineStore('cart', () => {
// ref() for reactive state:
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
// computed() for derived state (equivalent to Vuex getters):
const totalPrice = computed(() => items.value.reduce((sum, i) => sum + i.price, 0))
const itemCount = computed(() => items.value.length)
// Plain functions for actions — direct state mutation, no commits:
function addItem(item: CartItem) {
items.value.push(item)
}
async function applyCoupon(code: string) {
const discount = await validateCoupon(code)
if (discount) { couponCode.value = code }
}
// Can use watch, watchEffect, composables directly here:
watch(items, (newItems) => {
localStorage.setItem('cart', JSON.stringify(newItems))
}, { deep: true })
// Everything returned is exposed as part of the store's public API:
return { items, couponCode, totalPrice, itemCount, addItem, applyCoupon }
})
storeToRefs() for reactive destructuring
Destructuring a Pinia store directly loses reactivity for state and getters, the same as destructuring a reactive() object. storeToRefs(store) converts all state properties and getters to Refs that remain connected to the store, while leaving actions as plain functions (actions don’t need to be reactive — they are stable function references):
const cartStore = useCartStore()
// WRONG: destructuring loses reactivity for state and getters:
const { items, totalPrice, addItem } = cartStore
// items and totalPrice are now plain values — template won't update when store changes
// CORRECT: storeToRefs for state and getters; destructure actions directly from store:
const { items, totalPrice } = storeToRefs(cartStore) // reactive refs
const { addItem, applyCoupon } = cartStore // actions — plain functions, fine
// Now in template: items.value and totalPrice.value update reactively.
// In <template>: {{ totalPrice }} works because refs are auto-unwrapped in templates.
store.$subscribe() for mutation tracking
store.$subscribe(callback) registers a callback that fires after every store state mutation. It receives a mutation object describing how the state changed and the full state snapshot after the change. This is the correct hook for persistence plugins, logging, and undo/redo implementations:
cartStore.$subscribe((mutation, state) => {
// mutation.type describes how the change happened:
// 'direct' — state.items.push(item) or direct property assignment
// 'patch object' — store.$patch({ items: newItems })
// 'patch function' — store.$patch((state) => { state.items = newItems })
console.log('Cart mutated via:', mutation.type)
console.log('Store ID:', mutation.storeId) // 'cart'
console.log('New state:', state)
// Persistence example — save state after every mutation:
localStorage.setItem('cart-state', JSON.stringify(state))
})
// store.$patch() for batch updates — more efficient than individual assignments:
// Multiple individual assignments trigger multiple reactivity cycles:
cartStore.items = newItems // trigger 1
cartStore.couponCode = null // trigger 2
// $patch batches into a single reactivity cycle:
cartStore.$patch({
items: newItems,
couponCode: null,
})
// $patch with a function — useful when the new state depends on existing state:
cartStore.$patch((state) => {
state.items = state.items.filter(item => !item.isExpired)
state.couponCode = null
})
// $reset() — resets state to its initial value (options stores only):
cartStore.$reset()
Cross-store dependencies
Pinia stores are singletons: calling useCartStore() from multiple places returns the same store instance. Cross-store dependencies are handled by calling one store’s composable inside another store’s setup function:
// authStore.ts:
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
return { user, isAuthenticated }
})
// orderStore.ts — depends on authStore:
export const useOrderStore = defineStore('order', () => {
// Calling useAuthStore() inside another store's setup is safe —
// Pinia resolves the singleton, no circular dependency at call time:
const authStore = useAuthStore()
const orders = ref<Order[]>([])
async function fetchUserOrders() {
if (!authStore.isAuthenticated) return
orders.value = await api.getOrders(authStore.user!.id)
}
return { orders, fetchUserOrders }
})
Server-side hydration and HMR
For Nuxt SSR applications, Pinia provides built-in serialization support. The server serializes the store state into the HTML payload; the client hydrates the store from that payload rather than re-fetching data on mount:
// In a Nuxt application, Pinia SSR hydration is automatic when using @pinia/nuxt.
// The nuxt module handles pinia.state serialization and hydration.
// For custom SSR setups, pinia.state.value holds all store states:
// Server:
const pinia = createPinia()
app.use(pinia)
// ... render app to HTML ...
const state = JSON.stringify(pinia.state.value) // serialize to string for HTML injection
// Client:
const pinia = createPinia()
// Deserialize server state before mounting:
pinia.state.value = JSON.parse(window.__PINIA_STATE__)
app.use(pinia)
// HMR support — add to every store file:
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot))
}
Vue 3 performance optimization
Vue 3’s reactivity system is significantly more efficient than Vue 2’s getter/setter-based approach, but it still has performance costs that a Vue architect identifies and addresses through targeted optimizations. The starting point is always the Vue DevTools performance timeline — not intuition.
v-memo for memoized subtrees
v-memo="[dep1, dep2]" memoizes a template subtree and skips re-rendering when all of the listed dependencies are unchanged (compared by value using ===). It is most effective for expensive list items where the item data rarely changes:
<!-- Without v-memo: every ProductCard re-renders when any product changes -->
<div v-for="product in products" :key="product.id">
<ProductCard :product="product" :selected="selectedId === product.id" />
</div>
<!-- With v-memo: ProductCard subtree is skipped when both deps are unchanged -->
<div
v-for="product in products"
:key="product.id"
v-memo="[product.id, product.updatedAt, selectedId === product.id]"
>
<ProductCard :product="product" :selected="selectedId === product.id" />
</div>
<!-- The subtree only re-renders when product.id, product.updatedAt, or the selection
state of THIS specific item changes. Other products updating doesn't trigger a re-render. -->
<!-- v-memo="[]" — empty array memoizes forever (like v-once but dynamically applied): -->
<StaticHeader v-memo="[]" />
<!-- Never re-renders after initial mount regardless of parent state changes -->
The caveat: v-memo’s dependency array must be a fixed-length literal. Dynamic dependency arrays that change length will produce incorrect memoization behavior. The Vue architect audits v-memo usage for correctness: the deps array must include every value that the subtree’s rendering depends on, or stale content will be displayed.
shallowRef() and shallowReactive()
shallowRef(obj) creates a ref where only the top-level .value assignment is reactive — Vue does not deep-track the object’s internal properties. shallowReactive(obj) creates a reactive proxy where only the top-level properties are reactive. Nested properties of both are non-reactive: mutations to them do not trigger re-renders unless the top-level ref or property is reassigned.
// shallowRef — top-level .value reassignment is reactive; nested mutations are not:
const products = shallowRef<Product[]>([])
// REACTIVE — reassigns .value; triggers re-render:
products.value = [...products.value, newProduct]
products.value = await fetchProducts()
// NOT REACTIVE — mutates nested array in-place; no re-render:
products.value.push(newProduct) // use triggerRef(products) to force if needed
// shallowReactive — top-level properties are reactive; nested objects are not:
const state = shallowReactive({
loading: false,
error: null as string | null,
data: [] as Product[], // data itself is reactive; data[0].name is not
})
state.loading = true // REACTIVE — top-level property change
state.data = newProducts // REACTIVE — top-level property change
state.data[0].name = 'New' // NOT REACTIVE — nested mutation, no re-render
// Performance benefit: for a list of 500 products, shallowRef vs ref reduces
// the number of reactive getters Vue creates from ~5,000 (assuming ~10 properties
// per product) to 1 — only the top-level .value is tracked.
markRaw() for non-reactive objects
markRaw(obj) permanently marks an object as non-reactive. Vue will never wrap it in a reactive proxy, regardless of how it ends up inside a reactive system. Use it for class instances that have their own internal state management, DOM nodes, third-party library objects, and any object where Vue’s Proxy-based tracking would interfere with the object’s behavior or create unnecessary overhead:
import { Chart } from 'chart.js'
import { markRaw, shallowRef } from 'vue'
// Without markRaw: Vue wraps the Chart instance in a Proxy, which:
// 1. Adds significant overhead (Chart.js has hundreds of properties)
// 2. Can break Chart.js's internal instanceof checks
// 3. Causes Vue DevTools serialization errors for non-serializable canvas state
const chartInstance = shallowRef<Chart | null>(null)
onMounted(() => {
const chart = new Chart(canvasRef.value!, chartConfig)
// markRaw prevents Vue from ever proxying this instance:
chartInstance.value = markRaw(chart)
})
// The same pattern for WebSocket connections, Web Workers, RxJS subjects,
// D3 selections, and any other stateful class instances:
const wsConnection = shallowRef(markRaw(new WebSocket(WS_URL)))
defineAsyncComponent() for code splitting
defineAsyncComponent(() => import('./Component.vue')) wraps a dynamic import and integrates it with Vue’s Suspense system. The component’s JavaScript bundle is fetched only when the component is first rendered, not at application startup:
import { defineAsyncComponent } from 'vue'
// Simple async component — the import() produces a separate webpack/Vite chunk:
const HeavyEditor = defineAsyncComponent(
() => import('./components/HeavyEditor.vue')
)
// With loading and error states, delay, and timeout:
const HeavyEditor = defineAsyncComponent({
loader: () => import('./components/HeavyEditor.vue'),
loadingComponent: LoadingSpinner, // shown while the chunk loads
errorComponent: ErrorDisplay, // shown if the load fails
delay: 200, // wait 200ms before showing loadingComponent (avoids flash)
timeout: 5000, // show errorComponent if load takes longer than 5 seconds
onError(error, retry, fail, attempts) {
if (attempts < 3) {
retry() // automatically retry up to 3 times
} else {
fail() // give up and show errorComponent
}
}
})
// Use in template normally — Suspense handles the async boundary:
<Suspense>
<template #default>
<HeavyEditor v-if="editorVisible" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
KeepAlive for cached component instances
<KeepAlive> wraps dynamic components and caches their instance — including their DOM nodes and reactive state — when they are removed from the tree. When the component re-enters the tree, Vue reuses the cached instance rather than creating a new one. The include, exclude, and max props control which components are cached and how many instances the cache holds simultaneously:
<!-- Cache up to 5 tab panel instances; LRU eviction when max is exceeded -->
<KeepAlive :max="5" include="TabPanel">
<component :is="activeTab" />
</KeepAlive>
<!-- In the cached component — use onActivated/onDeactivated instead of onMounted: -->
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Fires when component re-enters the tree from the KeepAlive cache.
// Use for: resuming polling, reconnecting event listeners, refreshing stale data.
startDataPolling()
})
onDeactivated(() => {
// Fires when component is removed to the KeepAlive cache.
// Use for: pausing polling, removing event listeners, saving scroll position.
stopDataPolling()
})
</script>
Virtual scrolling for large lists
v-for on a list of 10,000 items creates 10,000 DOM nodes, 10,000 Vue component instances, and 10,000 reactive dependency tracking contexts simultaneously. Page interaction — scrolling, filtering, sorting — must update thousands of DOM nodes. Virtual scrolling (also called windowing) renders only the items currently visible in the viewport plus a small buffer, recycling DOM nodes as the user scrolls:
<!-- vue-virtual-scroller (RecycleScroller) — items must have known height: -->
<template>
<RecycleScroller
class="scroller"
:items="largeProductList"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<ProductRow :product="item" />
</RecycleScroller>
</template>
<!-- @tanstack/vue-virtual — more flexible, supports dynamic heights: -->
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref<HTMLElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: largeList.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 80, // estimated row height in pixels
overscan: 5, // render 5 extra items outside viewport
})
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())
const totalHeight = computed(() => rowVirtualizer.value.getTotalSize())
</script>
<template>
<div ref="parentRef" style="overflow-y:auto;height:600px">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="vRow in virtualItems"
:key="vRow.index"
:style="{ position: 'absolute', top: vRow.start + 'px', width: '100%' }"
>
<ProductRow :product="largeList[vRow.index]" />
</div>
</div>
</div>
</template>
Nuxt 3
Nuxt 3 is the Vue-native full-stack framework built on the Nitro server engine. A Nuxt consultant on retainer designs the server/client architecture, configures rendering strategies per route, and manages the deployment preset configuration that determines how the application runs in production.
File-based routing and layouts
Nuxt 3 generates its Vue Router configuration automatically from the pages/ directory structure. The routing conventions support dynamic segments, optional segments, catch-all routes, and nested layouts:
pages/
index.vue → /
about.vue → /about
products/
index.vue → /products
[id].vue → /products/:id (dynamic segment)
[[category]].vue → /products/:category? (optional segment)
[...slug].vue → /products/* (catch-all)
users/
[userId]/
index.vue → /users/:userId
orders/
index.vue → /users/:userId/orders
[orderId].vue → /users/:userId/orders/:orderId
// Access dynamic params in a page component:
<script setup lang="ts">
const route = useRoute()
const { id } = route.params // typed as string | string[]
</script>
// Layouts — layouts/default.vue wraps all pages unless overridden:
// layouts/admin.vue — separate layout for admin pages
// pages/admin/users.vue — use definePageMeta to select a layout:
<script setup lang="ts">
definePageMeta({
layout: 'admin', // uses layouts/admin.vue
middleware: 'auth', // runs middleware/auth.ts before rendering
})
</script>
Server routes with Nitro and H3
Nuxt 3’s server/api/ and server/routes/ directories define API endpoints handled by the Nitro server engine. Route files map directly to URL paths; the HTTP method is indicated by a .get.ts, .post.ts, or .delete.ts suffix on the filename (or inferred from a default export):
// server/api/users/[id].get.ts → GET /api/users/:id
export default defineEventHandler(async (event) => {
const { id } = getRouterParams(event)
const user = await db.user.findUnique({ where: { id } })
if (!user) { throw createError({ statusCode: 404, message: 'User not found' }) }
return user // automatically serialized to JSON
})
// server/api/users/index.post.ts → POST /api/users
export default defineEventHandler(async (event) => {
const body = await readBody<CreateUserRequest>(event)
const query = getQuery(event) // URL query parameters
// Cookies:
const sessionId = getCookie(event, 'session_id')
setCookie(event, 'session_id', newSessionId, { httpOnly: true, secure: true })
const user = await db.user.create({ data: body })
setResponseStatus(event, 201)
return user
})
// server/middleware/auth.ts — runs before every server route:
export default defineEventHandler(async (event) => {
// Middleware runs first; throw to abort the request:
const token = getHeader(event, 'authorization')
if (!token) { throw createError({ statusCode: 401, message: 'Unauthorized' }) }
event.context.user = await verifyJWT(token)
})
useFetch() vs useAsyncData()
Both composables fetch data during SSR (populating the page before sending it to the client) and hydrate on the client side (using the server-fetched data rather than re-fetching). The key difference: useFetch is a convenience wrapper around useAsyncData that automatically derives the deduplication key from the URL and options:
// useFetch — URL-based deduplication key, shorthand for simple cases:
const { data: products, pending, error, refresh } = await useFetch('/api/products', {
query: { category: selectedCategory }, // reactive — refetches when category changes
method: 'GET',
lazy: false, // false (default): blocks navigation until data is available
server: true, // true (default): fetch runs on server during SSR
key: 'products-list', // explicit key for deduplication (auto-derived from URL if omitted)
})
// useAsyncData — full control over the async function; required for non-$fetch calls:
const { data: user, refresh: refreshUser } = await useAsyncData(
'current-user', // deduplication key — only one request per key per page load
() => $fetch<User>(`/api/users/${userId.value}`),
{
watch: [userId], // re-run when userId changes
transform: (user) => ({ ...user, displayName: `${user.firstName} ${user.lastName}` }),
pick: ['id', 'email', 'displayName'], // only include these fields in the reactive data
}
)
// refresh() re-runs the fetch without navigating:
await refresh() // awaitable — resolves when the new data has replaced the old data
// execute() — for lazy fetches that don't run automatically:
const { data, execute } = useAsyncData('lazy-data', () => $fetch('/api/heavy'), {
lazy: true // won't run on mount — call execute() to trigger
})
onMounted(() => { execute() })
SSR, SSG, ISR, and hybrid rendering with routeRules
Nuxt 3’s routeRules in nuxt.config.ts enables per-route rendering strategy configuration without separate framework setup. The same application can serve some routes as SSR, others as statically generated, and others as client-only — all configured in one object:
// nuxt.config.ts:
export default defineNuxtConfig({
routeRules: {
// Static generation — pre-rendered at build time, served from CDN edge:
'/': { prerender: true },
'/about': { prerender: true },
'/blog/**': { prerender: true },
// ISR — cached on CDN for 60 seconds; regenerated on next request after TTL:
'/products/**': { isr: 60 },
// SSR with edge caching — rendered per-request but cached at the CDN for 1 hour:
'/api/**': { headers: { 'cache-control': 's-maxage=3600' } },
// Client-only (SPA mode) — no SSR for authenticated dashboard pages:
'/dashboard/**': { ssr: false },
// Redirect:
'/old-path': { redirect: '/new-path' },
},
// Global SSR setting:
ssr: true, // false would make the entire app client-only
})
// Page-level override with definePageMeta:
// pages/admin/reports.vue:
definePageMeta({ ssr: false }) // this page is always client-only regardless of routeRules
useRuntimeConfig() and environment variables
Nuxt’s useRuntimeConfig() provides a typed interface to environment variables, with automatic separation between server-only secrets and client-accessible public values. Variables set in runtimeConfig.public are exposed to both client and server; variables set at the top level of runtimeConfig are server-only:
// nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
// Server-only (never sent to client):
apiSecret: '', // overridden by NUXT_API_SECRET env variable
dbConnectionString: '', // overridden by NUXT_DB_CONNECTION_STRING
// Public — accessible in both server and client:
public: {
apiBase: '/api', // overridden by NUXT_PUBLIC_API_BASE
appVersion: '2.1.0',
}
}
})
// .env file:
NUXT_API_SECRET=sk-prod-secret-key
NUXT_DB_CONNECTION_STRING=postgresql://user:pass@host/db
NUXT_PUBLIC_API_BASE=https://api.example.com
// Usage in server routes:
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
const secret = config.apiSecret // server-only, typed correctly
const base = config.public.apiBase
})
// Usage in Vue components (client and server):
<script setup lang="ts">
const config = useRuntimeConfig()
// config.apiSecret is undefined in client context — only public values accessible
const apiBase = config.public.apiBase
</script>
Nuxt plugins
Nuxt plugins run once when the Vue app is created — on the server for SSR, on the client after hydration. They are the correct location for global utilities, third-party library initialization, and app-wide event bus setup:
// plugins/analytics.client.ts — .client suffix: runs only in browser:
export default defineNuxtPlugin((nuxtApp) => {
// Initialize analytics library (client-only):
const analytics = initAnalytics({ key: useRuntimeConfig().public.analyticsKey })
// Provide globally via inject — accessible as $analytics in Options API:
return {
provide: {
analytics,
}
}
})
// plugins/api.ts — no suffix: runs on both server and client:
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
// Create a configured $fetch instance with interceptors:
const api = $fetch.create({
baseURL: config.public.apiBase,
onRequest({ options }) {
// Add auth token from cookie/store to every request:
const token = useCookie('auth-token')
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`,
}
}
},
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
}
})
return { provide: { api } }
})
// Plugin ordering: plugins are loaded alphabetically by filename.
// Prefix with numbers to control order: 01.auth.ts, 02.api.ts, 03.analytics.client.ts
Nitro deployment presets
Nitro produces a zero-config deployment artifact targeted at the deployment environment specified by the preset. The same Nuxt application code deploys to different platforms without code changes — only the preset changes:
// nuxt.config.ts — preset selection:
export default defineNuxtConfig({
nitro: {
preset: 'vercel-edge', // Vercel Edge Functions (fastest cold start)
// preset: 'netlify-edge', // Netlify Edge Functions
// preset: 'cloudflare-pages', // Cloudflare Pages with Workers
// preset: 'aws-lambda', // AWS Lambda (Node.js)
// preset: 'node-server', // standalone Node.js server
}
})
// The preset is also inferred automatically when deploying to Vercel or Netlify.
// NITRO_PRESET=cloudflare-pages npx nuxi build — override via env variable
// Cloudflare Workers specific: Nitro respects Cloudflare's Worker KV and Durable Objects
// via nitro's storage abstraction:
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages',
storage: {
cache: { driver: 'cloudflare-kv-binding', binding: 'CACHE' }
}
}
})
Vue testing
A Vue architect on retainer designs the testing strategy that validates component behavior, store logic, and composable reactivity in isolation. The testing stack for Vue 3 projects centers on Vitest and @vue/test-utils.
Component testing with @vue/test-utils
// ProductCard.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { mount, shallowMount } from '@vue/test-utils'
import { flushPromises, nextTick } from 'vue'
import ProductCard from './ProductCard.vue'
describe('ProductCard', () => {
it('renders product name and price', () => {
const wrapper = mount(ProductCard, {
props: {
product: { id: 'p-1', name: 'Widget Pro', price: 49.99, inStock: true }
}
})
expect(wrapper.find('[data-testid="product-name"]').text()).toBe('Widget Pro')
expect(wrapper.find('[data-testid="product-price"]').text()).toContain('49.99')
})
it('emits add-to-cart when button clicked', async () => {
const wrapper = mount(ProductCard, {
props: { product: { id: 'p-1', name: 'Widget', price: 9.99, inStock: true } }
})
await wrapper.find('button[data-testid="add-to-cart"]').trigger('click')
expect(wrapper.emitted('add-to-cart')).toHaveLength(1)
expect(wrapper.emitted('add-to-cart')![0]).toEqual(['p-1'])
})
it('disables button when out of stock', () => {
const wrapper = mount(ProductCard, {
props: { product: { id: 'p-1', name: 'Widget', price: 9.99, inStock: false } }
})
expect(wrapper.find('button[data-testid="add-to-cart"]').attributes('disabled'))
.toBeDefined()
})
it('shows async description after load', async () => {
const wrapper = mount(AsyncProductCard, {
props: { productId: 'p-1' }
})
// Component is still loading — description not yet rendered:
expect(wrapper.find('[data-testid="description"]').exists()).toBe(false)
// Flush all pending promises (mocked API calls resolve here):
await flushPromises()
// Now the async setup has completed — description is rendered:
expect(wrapper.find('[data-testid="description"]').text()).toBe('A great widget.')
})
})
Testing Pinia stores
// cartStore.test.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { useCartStore } from './cartStore'
import { mount } from '@vue/test-utils'
import CartWidget from './CartWidget.vue'
describe('useCartStore', () => {
beforeEach(() => {
// Create a fresh pinia for each test — prevents state leaking between tests:
setActivePinia(createPinia())
})
it('adds items and calculates total', () => {
const store = useCartStore()
store.addItem({ id: 'i-1', name: 'Widget', price: 10.00 })
store.addItem({ id: 'i-2', name: 'Gadget', price: 25.50 })
expect(store.items).toHaveLength(2)
expect(store.totalPrice).toBe(35.50)
})
it('persists to localStorage on mutation', () => {
const setSpy = vi.spyOn(Storage.prototype, 'setItem')
const store = useCartStore()
store.addItem({ id: 'i-1', name: 'Widget', price: 10.00 })
expect(setSpy).toHaveBeenCalledWith('cart', expect.any(String))
})
})
// Testing a component that uses a Pinia store — createTestingPinia replaces actions with spies:
describe('CartWidget with mocked store', () => {
it('calls addItem action when add button clicked', async () => {
const wrapper = mount(CartWidget, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // wraps all actions in vi.fn() spies
initialState: {
cart: { items: [], couponCode: null },
},
}),
],
},
props: { product: { id: 'p-1', name: 'Widget', price: 9.99 } },
})
const store = useCartStore()
await wrapper.find('[data-testid="add-button"]').trigger('click')
expect(store.addItem).toHaveBeenCalledWith({ id: 'p-1', name: 'Widget', price: 9.99 })
})
})
Testing with a mocked router
// Components that use useRoute() or router-link need a router instance in tests:
import { createRouter, createMemoryHistory } from 'vue-router'
import { mount } from '@vue/test-utils'
import NavBar from './NavBar.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{ path: '/products', name: 'products',component: { template: '<div>Products</div>' } },
]
})
describe('NavBar', () => {
it('highlights active route', async () => {
await router.push('/products')
await router.isReady()
const wrapper = mount(NavBar, {
global: { plugins: [router] }
})
const activeLink = wrapper.find('a.router-link-active')
expect(activeLink.text()).toBe('Products')
})
})
HourTab for Vue developer retainers
Vue developer retainer work produces visible frontend outcomes: page render time reduced from 400ms to 38ms on filter interactions, Vuex store mutation chains replaced by traceable Pinia action sequences, Vue 2 codebase migrated to Vue 3 Composition API with type-safe composables, Nuxt SSR implemented with server-side data fetching that eliminates the client-only loading spinner on the most-visited pages. The hours behind each outcome are invisible without a work log that connects the advisory session to the specific improvement.
The Vue DevTools component render timeline analysis that preceded the shallowReactive optimization took 2 hours to run correctly: recording the flamegraph, identifying the specific component re-rendering unnecessarily, tracing the reactive dependency chain back to the root reactive object, and designing the replacement with shallowRef and top-level reassignment. Those 2 hours of profiling preceded 3 hours of implementation and produced the 90% render time reduction — but the invoice line for the profiling session looks identical to any other 2-hour advisory block without a work log entry that captures what the DevTools timeline revealed.
The Vuex-to-Pinia migration design that preceded the store correctness improvement took 8 hours of architectural work before a single line of store code changed: auditing the existing Vuex module structure, identifying the getter that held a non-serializable function reference, tracing all mutation callers to understand which components depended on which state slice, and designing the Pinia store topology (how many stores, which state belongs to each, how cross-store dependencies would work). The 8 hours of design preceded 14 hours of implementation and produced a Pinia store structure that the entire team can maintain without the previous expert-only tribal knowledge of which Vuex mutations must be called in sequence.
The defineAsyncComponent split that preceded the bundle size reduction took 4 hours to execute correctly: running webpack-bundle-analyzer (or Vite’s rollup-plugin-visualizer) to identify the largest synchronous imports, identifying which components were loaded at startup but only rendered after a user interaction, designing the async component wrapper with the correct loadingComponent, errorComponent, and delay configuration, and verifying with Lighthouse that the Largest Contentful Paint score improved after the split. Those 4 hours are invisible in the bundle size reduction metric reported to the product manager.
HourTab gives Vue architects and Vue consultants a retainer dashboard their engineering directors can bookmark without creating an account: the month’s committed hours, the hours consumed, and the work log entries that connect each block to the specific Vue platform function performed. When the VP of Engineering can see that 10 of the month’s 30 retainer hours went to Vue DevTools performance investigation and shallowReactive optimization, 8 hours went to Pinia store design for the migration from Vuex, and 6 hours went to Nuxt routeRules configuration for the hybrid rendering rollout, the retainer renewal conversation is grounded in the actual distribution of Vue architecture advisory work rather than an abstract sense of whether the Vue investment produced value.
Monthly retainer amounts for Vue developer advisory and architecture consulting typically range from $4,000 to $8,500 per month for component architecture advisory retainers (focused on Composition API migration, composable design, and Pinia store structure), increasing to $9,000 to $20,000 per month for full-stack Vue and Nuxt architecture consulting engagements that cover Vue 2-to-3 migration leadership, Nuxt SSR configuration, Nitro deployment preset selection, and ongoing performance optimization cadence.
The retainer model fits Vue architecture consulting because Vue codebases are living systems: every new developer adding components can reintroduce Options API patterns, mixin dependencies, and deeply reactive objects that undo previous Composition API migration work; every Nuxt minor version release brings updated Nitro engine behavior and new routeRules options that the Nuxt consultant evaluates for adoption; the Pinia store topology requires ongoing governance as new features add state that may belong in an existing store or require a new one; and the Vue DevTools performance baseline drifts as new components and state are added. A monthly hour commitment provides the Vue architect’s sustained availability across the full frontend platform maintenance and evolution calendar.
For Vue consultants documenting retainer work, sharing a live hours dashboard replaces the weekly status email: the client sees the current month’s hour consumption and the work log entries that narrate what each block of Vue advisory hours accomplished.
Frequently asked questions
What does a Vue developer on retainer typically do?
A Vue developer or Vue architect on monthly retainer provides ongoing frontend architecture advisory and development across four principal service areas. Composition API architecture and migration: auditing Options API components for deeply nested watchers and mixin dependency tangles, refactoring to <script setup> syntax, designing composable functions that encapsulate reactive logic and are testable in isolation, and applying defineExpose to control what <script setup> components expose to template refs. Pinia state management design: replacing Vuex stores with typed Pinia stores using the defineStore setup syntax, designing cross-store dependencies, implementing store.$subscribe for mutation tracking and persistence plugin integration, and configuring server-side hydration with pinia.state.value serialization for Nuxt SSR applications. Vue 3 performance optimization: auditing the Vue DevTools component render timeline to identify unnecessary re-renders, replacing deeply reactive objects with shallowRef and shallowReactive where nested mutation tracking is not required, applying v-memo to expensive list subtrees, marking third-party class instances with markRaw to prevent Vue from recursively tracking their properties, and implementing defineAsyncComponent for route-level and component-level code splitting. Nuxt 3 architecture: designing the server/api/ route structure using defineEventHandler and H3 utilities, configuring routeRules for hybrid rendering, setting up useRuntimeConfig with proper public and private environment variable separation, and deploying Nitro server output to Vercel, Netlify, or Cloudflare Workers via deployment presets.
What Vue.js work is most commonly underlogged in a retainer?
The most systematically underlogged categories in Vue.js retainers are Composition API migration (converting Options API components to <script setup>, extracting reusable composables, replacing mixin trees with composable composition — produces no user-visible change but dramatically improves testability and reduces future bug surface; typically 6 to 16 hours per major feature module invisible in the component diff); Vuex-to-Pinia migration design (auditing existing Vuex store structure, designing the equivalent Pinia store topology, migrating getters to computed refs and mutations to direct state assignments, implementing storeToRefs for reactive destructuring — typically 10 to 25 hours for a medium-sized Vuex store invisible in the store file count reduction); Vue DevTools performance investigation (running the component render timeline to identify the root cause of unnecessary re-renders, tracing reactive dependency chains to find computed properties that re-evaluate on unrelated state changes — typically 4 to 12 hours invisible in the frame time improvement); and shallowReactive and markRaw optimization (auditing reactive objects for unnecessary deep tracking, replacing reactive() with shallowReactive() for large objects with stable structure, wrapping third-party class instances with markRaw — typically 3 to 8 hours per performance engagement invisible in the reduced JavaScript CPU time). Detailed work log entries that capture the DevTools timeline findings and the reactive system changes that addressed them connect the invisible Vue platform investment to its concrete outcomes.
What should a Vue developer retainer agreement include?
Vue developer retainer agreements should specify: scope boundary between feature development, Composition API migration, performance advisory, and Nuxt SSR architecture (Composition API migration and DevTools performance analysis produce no deployable artifact — define these explicitly as in-scope functions with their own hour allocation); repository access level required (read access for component audit and DevTools profiling; write access for pull request authorship; CI pipeline access for Vitest and build configuration); Vue version scope (whether the retainer covers Vue 2 LTS maintenance, Vue 2-to-3 migration, or Vue 3 greenfield development — migration engagements require separate scoping from ongoing advisory); Pinia store governance scope (who owns the store topology design, the defineStore naming conventions, the storeToRefs usage standards, and the SSR serialization strategy for Nuxt applications); Nuxt configuration scope (whether the retainer covers server/api/ route authoring, routeRules hybrid rendering configuration, Nitro deployment preset selection, and useRuntimeConfig environment variable mapping); IP ownership for composables, Pinia store modules, and Nuxt server route handlers authored during the engagement; and a shared work log documenting each Composition API migration session, performance optimization engagement, Pinia store design sprint, and Nuxt SSR configuration milestone. Monthly retainer amounts for Vue developer advisory and architecture consulting typically range from $4,000 to $8,500 per month for component architecture advisory retainers, increasing to $9,000 to $20,000 per month for full-stack Vue and Nuxt architecture consulting engagements.
What are typical retainer rates for Vue developers and Vue architects?
Entry-level Vue developers with 1 to 3 years of experience, Vue 3 proficiency, and Composition API skill typically bill $70 to $120 per hour, with monthly retainers running 10 to 20 hours for component development and code review. Mid-level Vue engineers with 3 to 8 years of experience, expertise in Pinia state management, Nuxt 3, Vue performance optimization (including shallowReactive, v-memo, and defineAsyncComponent), and composable design patterns typically bill $115 to $200 per hour, with monthly retainers running 15 to 30 hours. Senior Vue architects with 8 to 14 years of experience, expertise in Vue reactivity internals, custom plugin authorship, Vue 2-to-3 migration leadership, Nuxt SSR architecture, and Nitro deployment configuration typically bill $175 to $330 per hour, with monthly retainers running 20 to 40 hours. Vue consulting firms and specialized frontend architecture consultancies typically bill $145 to $255 per hour. Monthly retainer amounts range from $4,000 to $8,500 per month for component architecture advisory retainers, increasing to $9,000 to $20,000 per month for full-stack Vue and Nuxt architecture consulting engagements covering Vue 2-to-3 migration, Pinia store design, Nuxt SSR configuration, and performance optimization.
How should Vue developer retainer hours be logged?
Work log entries should capture the advisory category (Composition API migration, Pinia store design, Vue DevTools performance investigation, shallowReactive optimization, Nuxt SSR configuration, defineAsyncComponent code splitting, code review), the specific component or store module being worked on, the task performed, and the finding or deliverable. Example: “Vue Performance Investigation — ProductListPage.vue and useProductFilters.ts. Task: diagnose slow re-renders on filter interaction. Work: ran Vue DevTools component render timeline during filter state change — ProductListPage re-rendering 340ms; traced dependency graph and found useProductFilters composable held a reactive() object with 180 deeply nested product records, triggering deep reactive tracking on every assignment — 2 hours; replaced reactive(products) with shallowRef(products) and updated mutations to reassign products.value = [...newList] — 3 hours; applied v-memo="[product.id, product.updatedAt]" to ProductCard in v-for loop — 2 hours; wrapped third-party ImageOptimizer class instances with markRaw() to prevent Vue from deeply tracking internal canvas state — 1 hour. Total: 8 hours. Render time improvement: 340ms to 41ms on filter change. DOM nodes tracked by Vue reactivity system: reduced by 94%. User-visible new features: zero.” Entries that document the specific DevTools timeline findings and the reactive system changes that addressed them connect the 8 hours of performance work to the concrete rendering improvement, making the Vue retainer investment legible to the engineering director reviewing the work log.