Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save suntong/6c160d32d51b03f94cbe8d1905507f5a to your computer and use it in GitHub Desktop.

Select an option

Save suntong/6c160d32d51b03f94cbe8d1905507f5a to your computer and use it in GitHub Desktop.

Vue 3 Master Reference for Backend Developers (2025 Edition) #

Table of Contents


1. MENTAL MODEL SHIFT #

1.1 Vue’s reactivity vs backend state management #

Backend mental model

  • State is usually:
    • Short‑lived (per request/job), or
    • Long‑lived but centralized (DB, cache, in‑process singletons/actors).
  • You pull state, compute, return a response.
  • No automatic “UI update” – HTML is rendered once per response.

Vue mental model

  • Your component tree is always “alive” in the browser.
  • Vue tracks which DOM fragments depend on which reactive values.
  • When reactive state changes, Vue:
    • Schedules an update in the next tick.
    • Re-runs the render function for affected components.
    • Patches only the minimal DOM that changed.

Reactivity mechanics (simplified)

  • Vue wraps your state in reactive containers:
    • ref() for single values.
    • reactive() for objects/arrays.
    • computed() for derived values.
  • When a render function or computed() reads reactive state, Vue records that dependency.
  • When that state changes, Vue:
    • Marks dependents as “dirty”.
    • Re-runs them in a batched, efficient way.

This is closer to:

  • DB change → invalidate cache entries → recompute only dependent cached values

than to:

  • “Render HTML template once per request”.

Key shift: you don’t imperatively re-render. You declare:

  • what the state is, and
  • how the UI depends on it,

and Vue handles propagation.


1.2 Component thinking vs backend service architecture #

Backend services

  • You design modules/microservices around domains:
    • UserService, OrderService, AuthService.
  • They expose methods; they don’t render UI.
  • Typically stateless per request.

Vue components

  • Each component is:
    • A small UI unit (markup + styling + behavior).
    • With inputs (props), outputs (emits), and internal state.
  • Form a tree:
    • AppLayoutPageWidgetButton
  • Data flow:
    • Down: props (like function parameters / DTOs).
    • Up: events (emit) (like callbacks or messages on a bus).

Where do “services” live?

  • Logic that is not inherently visual (API calls, business rules) shouldn’t stay inside components.
  • Factor that logic into composables (see §2.6):
    • useAuth(), useUser(), useCart().
  • Composables ≈ application/domain services in backend terms.

So:

  • Components = small, nested controller+view units.
  • Composables = services.
  • Stores (Pinia) = in-memory domain aggregates / singletons.

1.3 Template syntax as compiled render functions #

Templates are not HTML with magic attributes. They’re a DSL that compiles to render functions.

Example:

<template>
  <button @click="count++">{{ count }}</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

Roughly compiles to:

import { createElementVNode, toDisplayString, openBlock, createElementBlock } from 'vue'

export function render(_ctx: any) {
  return (openBlock(), createElementBlock(
    "button",
    { onClick: () => _ctx.count++ },
    toDisplayString(_ctx.count)
  ))
}

Implications:

  • Templates are declarative, but under the hood they’re just JS functions.
  • Reading reactive values in templates automatically registers dependencies.
  • The compiler:
    • Separates static vs dynamic parts.
    • Minimizes DOM work similarly to a carefully hand-written view layer.

2. SYNTAX & CORE CONCEPTS #

2.1 Composition API vs Options API #

Options API (older style)

import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  computed: {
    double() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    console.log('Mounted')
  }
})
  • Logic is grouped by option type (data, methods, computed, watch, etc.).
  • Easy onboarding; harder to maintain for large features (related logic is spread across options).

Composition API (modern, recommended)

With <script setup>:

<template>
  <button @click="increment">
    {{ count }} (double: {{ double }})
  </button>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const double = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('Mounted')
})
</script>
  • Logic is grouped by feature, not by option type.
  • Easy to extract into composables (useCounter).
  • Superior TypeScript experience.
  • Recommended default for new projects.

When to use which

  • New codebase: Composition API + <script setup> almost everywhere.
  • Legacy / small throwaway components: Options API is fine.
  • Migration path: Start with Options for existing components; migrate heavy logic into composables, then to <script setup> over time.

2.2 ref() vs reactive() vs computed() (TypeScript) #

ref<T>() – single reactive value #

  • Wraps a primitive or object in { value: T }.
  • In templates, .value is auto-unwrapped.
import { ref } from 'vue'

const count = ref<number>(0)
count.value++ // OK

const name = ref<string | null>(null)
name.value = 'Alice'

Template:

<template>
  <p>{{ count }}</p> <!-- .value auto-unwrapped -->
</template>

reactive<T>() – reactive objects/collections #

  • Wraps an object/array in a Proxy.
  • No .value; you mutate like a normal object.
import { reactive } from 'vue'

interface User {
  id: number
  name: string
  roles: string[]
}

const user = reactive<User>({
  id: 1,
  name: 'Alice',
  roles: ['admin']
})

user.name = 'Bob'           // reactive
user.roles.push('editor')   // reactive

Caveats:

  • Deep reactivity: nested objects are reactive too.
  • For reuse, prefer smaller refs + computed over sprawling reactive bags.

ref() vs reactive() for objects #

Both patterns work:

// 1. ref of object
const user = ref<User | null>(null)
user.value = { id: 1, name: 'Alice', roles: [] }

// 2. reactive object
const user2 = reactive<User>({
  id: 0,
  name: '',
  roles: []
})

Guidelines:

  • ref:
    • Great for primitives or optional objects (e.g. User | null).
    • More explicit for replacing the whole reference.
  • reactive:
    • Ergonomic for complex, nested objects (forms, config objects).
  • For arrays: choose either ref<T[]>() or reactive<T[]>([]) and be consistent.

computed<T>() – derived reactive values #

  • Like a cached getter that recomputes when dependencies change.
import { ref, computed } from 'vue'

const firstName = ref('Alice')
const lastName = ref('Smith')

const fullName = computed<string>(() => {
  return `${firstName.value} ${lastName.value}`
})

console.log(fullName.value)

Guidelines:

  • Use computed for pure, synchronous derivations.
  • Don’t put side effects (API calls, logging) inside computed. For that use watch/watchEffect.

2.3 Lifecycle hooks mapped to backend equivalents #

Key Composition API hooks:

  • onBeforeMount – before original mount.
  • onMounted – after first render is in DOM.
  • onBeforeUpdate – before a reactive update patches DOM.
  • onUpdated – after DOM patch.
  • onBeforeUnmount – before component is removed.
  • onUnmounted – after component is removed.
  • onErrorCaptured – when a child throws an error.
  • onActivated / onDeactivated – for <keep-alive> cached components.

Backend analogies

  • onMounted:
    • Like “controller initialized, first response done”.
    • Use it for initial data fetch, subscriptions, DOM integrations.
  • onUnmounted:
    • Like disposing a handler / background worker.
    • Use it to cleanup: unsubscribe, remove listeners, stop timers.
  • onUpdated:
    • Like “view re-rendered”; use sparingly (only for DOM libraries that need it).

Example:

import { ref, onMounted, onUnmounted } from 'vue'

function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  const onResize = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => {
    window.addEventListener('resize', onResize)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', onResize)
  })

  return { width, height }
}

2.4 Template directives: v-if, v-for, v-model #

v-if / v-else-if / v-else #

  • Conditional rendering: element may or may not exist in DOM.
<div v-if="user">
  Hello, {{ user.name }}
</div>
<div v-else>
  Not logged in
</div>
  • v-show is an alternative that toggles display: none but keeps the element in DOM.

v-for #

  • Loops over arrays/objects.
<li v-for="user in users" :key="user.id">
  {{ user.name }}
</li>

<li v-for="(value, key, index) in someObject" :key="key">
  {{ index }} - {{ key }}: {{ value }}
</li>

Always provide a stable :key for efficient diffing.

v-model #

On native inputs:

<input v-model="name" />

<!-- modifiers -->
<input v-model.trim="name" />
<input v-model.number="age" />
<input v-model.lazy="searchTerm" /> <!-- updates on change, not on every input -->

On components (default modelValue):

Child:

<!-- MyInput.vue -->
<template>
  <input :value="modelValue" @input="onInput" />
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function onInput(event: Event) {
  emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

Parent:

<MyInput v-model="name" />

Custom model name:

Child:

const props = defineProps<{
  title: string
}>()

const emit = defineEmits<{
  'update:title': [value: string]
}>()

Parent:

<MyComponent v-model:title="pageTitle" />

2.5 Slots & scoped slots #

Think of slots as template partials/layout regions where the parent injects markup.

Basic slot:

<!-- Card.vue -->
<template>
  <div class="card">
    <slot /> <!-- default slot -->
  </div>
</template>

Use:

<Card>
  <h2>Title</h2>
  <p>Body content</p>
</Card>

Named slots:

<!-- Layout.vue -->
<template>
  <header>
    <slot name="header" />
  </header>
  <main>
    <slot />
  </main>
  <footer>
    <slot name="footer" />
  </footer>
</template>

Use:

<Layout>
  <template #header>
    <h1>My App</h1>
  </template>

  <template #default>
    <RouterView />
  </template>

  <template #footer>
    © 2025
  </template>
</Layout>

Scoped slots: child passes data up to parent-provided content.

Child:

<!-- UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot name="item" :user="user">
        <!-- fallback if parent doesn’t provide slot -->
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  users: User[]
}>()
</script>

Parent:

<UserList :users="users">
  <template #item="{ user }">
    <strong>{{ user.name }}</strong>
  </template>
</UserList>

Conceptually similar to passing a callback into a renderer that receives context and returns markup.


2.6 Composables (like backend services/utilities) #

Composables = functions named useXxx that encapsulate state + logic and can be reused across components.

Example useCounter:

// useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return {
    count,
    double,
    increment
  }
}

Use in component:

<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

const { count, double, increment } = useCounter(5)
</script>

<template>
  <button @click="increment">
    {{ count }} (double: {{ double }})
  </button>
</template>

For API/domain logic:

// useUser.ts
import { ref } from 'vue'
import { getUser } from '@/api/user'

export function useUser(userId: number) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      user.value = await getUser(userId)
    } catch (err) {
      error.value = err as Error
    } finally {
      loading.value = false
    }
  }

  // optionally auto-fetch
  fetchUser()

  return { user, loading, error, fetchUser }
}

This is closest to a service + query object from backend architectures.


3. MODERN PATTERNS & BEST PRACTICES #

3.1 Component composition patterns (avoiding prop drilling) #

Prop drilling = passing the same props through multiple layers just so a deeply nested child can use them.

Avoid with:

  1. Composables + shared dependencies

    • Deep components call the same composable instead of relying on props.
    // useCurrentUser.ts
    import { computed } from 'vue'
    import { useUserStore } from '@/stores/userStore'
    
    export function useCurrentUser() {
      const userStore = useUserStore()
      return computed(() => userStore.currentUser)
    }
  2. Provide / inject (see §3.6)

    • Hierarchical context: theme, layout config, multistep wizard state.
  3. Slots

    • Pass children content instead of encoding every variation in props.
    • Example: <Table> that exposes #cell / #header slots instead of endless prop permutations.
  4. State colocation

    • Keep state near the components that use it.
    • Only lift state when multiple siblings or routes truly need to coordinate.

3.2 State management: local vs Pinia #

Local component state

  • Use when:
    • Only the component (and maybe its immediate children) need the data.
    • Examples: modal open state, dropdown selection, unsaved form draft.

Pinia (global stores)

  • Official Vue 3 store solution (successor to Vuex).
  • Good when:
    • Distant components share state.
    • You need a single source of truth for domains:
      • Auth user, permissions.
      • Shopping cart.
      • Feature flags / configuration.
  • Integrates with DevTools, supports time-travel, persistence, plugins.

Example Pinia store:

// stores/userStore.ts
import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
}

interface UserState {
  currentUser: User | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    currentUser: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.currentUser
  },
  actions: {
    setUser(user: User | null) {
      this.currentUser = user
    }
  }
})

Usage:

import { useUserStore } from '@/stores/userStore'

const userStore = useUserStore()

userStore.setUser({ id: 1, name: 'Alice' })
console.log(userStore.isAuthenticated) // true

Pinia vs TanStack Query

  • Pinia: client-state (UI flags, in-progress edits, client-only models).
  • TanStack Query: server-state (data whose source of truth is the backend and is cached client-side).

3.3 TypeScript integration: props, emits, generics #

Using <script setup lang="ts">:

Typed props

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  user: User
  editable?: boolean
}>()
</script>

Typed emits

const emit = defineEmits<{
  (e: 'save', user: User): void
  (e: 'cancel'): void
}>()

function onSave() {
  emit('save', props.user)
}

function onCancel() {
  emit('cancel')
}

TypeScript enforces event names and payload shapes.

Generic components (simplified)

<!-- GenericList.vue -->
<script setup lang="ts">
type WithId = { id: string | number }

const props = defineProps<{
  items: WithId[]
}>()
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item">
        {{ item }}
      </slot>
    </li>
  </ul>
</template>

Type-safe Pinia

export interface CartItem {
  id: number
  title: string
  qty: number
}

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[]
  }),
  actions: {
    addItem(item: CartItem) {
      this.items.push(item)
    }
  }
})

items and addItem are fully typed.


3.4 Performance: watch vs watchEffect, memoization, virtual lists #

computed vs watch vs watchEffect #

  • computed:

    • Derived, pure values.
    • Cached; recomputes only when deps change.
  • watch(source, callback, options?):

    • For side effects based on specific reactive sources.
    • Access to newValue, oldValue.
    import { ref, watch } from 'vue'
    
    const query = ref('')
    const results = ref<string[]>([])
    
    watch(
      query,
      async (newQuery) => {
        if (newQuery.length < 3) {
          results.value = []
          return
        }
        results.value = await searchApi(newQuery)
      }
    )
  • watchEffect(effect, options?):

    • Like a “reactive effect”: automatically tracks any reactive values used inside.
    • Good for quick side effects spanning multiple sources.
    import { watchEffect } from 'vue'
    import { useUserStore } from '@/stores/userStore'
    
    const userStore = useUserStore()
    
    watchEffect(() => {
      console.log('Current user id:', userStore.currentUser?.id)
    })

Guidelines:

  • Prefer computed for derivations used in rendering or logic.
  • Use watch when you need precise triggers or previous values.
  • Use watchEffect for small, implicit, multi-source side effects.

Memoization #

  • computed is already memoized.
  • For heavy but pure transformations of reactive data, wrap them in computed.
  • For heavy computations over non-reactive data, use classic JS memoization (Maps, LRU caches, etc.).

Virtual lists #

Rendering thousands of nodes is slow. Use virtual scrolling:

  • Libraries like vue-virtual-scroller or similar.

Pattern:

<VirtualList
  :items="items"
  :item-size="48"
  v-slot="{ item }"
>
  <YourItemComponent :item="item" />
</VirtualList>

Only visible items are rendered to the DOM.


3.5 Error boundaries and logging #

Vue 3 options:

  1. Component-level error boundary (onErrorCaptured)

    <!-- ErrorBoundary.vue -->
    <template>
      <div v-if="error">
        <p>Something went wrong.</p>
        <pre v-if="debug">{{ error.stack }}</pre>
      </div>
      <slot v-else />
    </template>
    
    <script setup lang="ts">
    import { ref, onErrorCaptured } from 'vue'
    
    const props = defineProps<{
      debug?: boolean
    }>()
    
    const error = ref<Error | null>(null)
    
    onErrorCaptured((err) => {
      error.value = err as Error
      // return false to stop further propagation (optional)
      return false
    })
    </script>

    Usage:

    <ErrorBoundary>
      <DangerousComponent />
    </ErrorBoundary>
  2. Global error handler

    In main.ts:

    import { createApp } from 'vue'
    import App from './App.vue'
    
    const app = createApp(App)
    
    app.config.errorHandler = (err, instance, info) => {
      logError({
        message: (err as Error).message,
        stack: (err as Error).stack,
        component: instance?.$options.name,
        info
      })
    }
    
    app.mount('#app')
  3. Unhandled promise rejections

    • Catch in composables with try/catch.

    • Optionally:

      window.addEventListener('unhandledrejection', (event) => {
        logError({ type: 'unhandledrejection', reason: event.reason })
      })

Use Sentry, LogRocket, or your backend logging pipeline, ideally enriched with:

  • current route,
  • user id,
  • environment.

3.6 Dependency injection (provide / inject) #

Analogous to scoped DI containers/context.

Define an injection key

import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')

Provide in an ancestor component

import { ref, provide } from 'vue'
import { ThemeKey } from '@/di/keys'

const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)

Inject in any descendant

import { inject } from 'vue'
import { ThemeKey } from '@/di/keys'

const theme = inject(ThemeKey)
if (!theme) {
  throw new Error('Theme not provided')
}

theme.value = 'dark'

Use provide/inject for:

  • Cross-cutting concerns: theme, i18n, layout config.
  • Complex component trees (stepper, wizard) to share internal state.

Don’t use it as a replacement for global app state (Pinia is better for that).


4. ECOSYSTEM INTEGRATION #

4.1 Vue Router: navigation guards, lazy loading #

Basic setup with lazy-loaded routes

// router.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue') // lazy
  },
  {
    path: '/users/:id',
    name: 'user',
    component: () => import('@/views/UserView.vue'),
    meta: { requiresAuth: true }
  }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

In main.ts:

import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'

createApp(App)
  .use(router)
  .mount('#app')

In-component usage

import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

router.push({ name: 'user', params: { id: 123 } })
console.log(route.params.id)

Global navigation guard (auth)

// router.ts
import { useUserStore } from '@/stores/userStore'

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()

  if (to.meta.requiresAuth && !userStore.isAuthenticated) {
    return next({ name: 'login', query: { redirect: to.fullPath } })
  }

  next()
})

Per-route guard

const routes: RouteRecordRaw[] = [
  {
    path: '/admin',
    name: 'admin',
    component: () => import('@/views/AdminView.vue'),
    beforeEnter: (to, from, next) => {
      const userStore = useUserStore()
      if (!userStore.isAdmin) return next({ name: 'home' })
      next()
    }
  }
]

In-component guards (for unsaved changes)

import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const hasUnsavedChanges = ref(false)

onBeforeRouteLeave((to, from, next) => {
  if (hasUnsavedChanges.value) {
    const ignore = !window.confirm('Discard unsaved changes?')
    if (ignore) return next(false)
  }
  next()
})

4.2 API integration: TanStack Query vs axios #

axios: low-level HTTP client

// api/http.ts
import axios from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  withCredentials: true
})

http.interceptors.request.use((config) => {
  const authStore = useAuthStore()
  if (authStore.token) {
    config.headers = config.headers ?? {}
    config.headers.Authorization = `Bearer ${authStore.token}`
  }
  return config
})

http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      useAuthStore().logout()
    }
    return Promise.reject(error)
  }
)

TanStack Query (Vue Query): server-state manager

Handles:

  • Caching.
  • Deduped requests.
  • Background refetch.
  • Stale vs fresh data.

Setup:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query'

const vueQueryOptions: VueQueryPluginOptions = {
  queryClientConfig: {
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        retry: 1
      }
    }
  }
}

createApp(App)
  .use(VueQueryPlugin, vueQueryOptions)
  .mount('#app')

Define a query hook:

// queries/useUserQuery.ts
import { useQuery } from '@tanstack/vue-query'
import { http } from '@/api/http'

export interface User {
  id: number
  name: string
}

function fetchUser(id: number): Promise<User> {
  return http.get(`/users/${id}`).then((res) => res.data as User)
}

export function useUserQuery(id: number) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id)
  })
}

Use in a component:

<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useUserQuery } from '@/queries/useUserQuery'

const route = useRoute()
const userId = Number(route.params.id)

const { data: user, isLoading, error } = useUserQuery(userId)
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="error">Failed to load user.</div>
  <div v-else>
    {{ user.name }}
  </div>
</template>

When to use what

  • Simple apps / limited endpoints:
    • axios (or fetch) inside composables is fine.
  • Larger apps with lots of shared server data:
    • axios (or fetch) + TanStack Query for server-state.
    • Pinia for client-state.

4.3 Form handling: VeeValidate / FormKit with backend validation sync #

VeeValidate #

  • Form handling + validation library with schema support (Yup/Zod, etc.).

Example:

<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  email: yup.string().required().email(),
  password: yup.string().required().min(8)
})

async function onSubmit(values: { email: string; password: string }) {
  try {
    await login(values)
  } catch (e: any) {
    // Option 1: throw object with field-level errors:
    // throw { email: 'Email already taken' }
    // Option 2: use setFieldError (see VeeValidate docs)
  }
}
</script>

<template>
  <Form :validation-schema="schema" @submit="onSubmit">
    <div>
      <label>Email</label>
      <Field name="email" type="email" />
      <ErrorMessage name="email" />
    </div>

    <div>
      <label>Password</label>
      <Field name="password" type="password" />
      <ErrorMessage name="password" />
    </div>

    <button type="submit">Login</button>
  </Form>
</template>

Sync with backend validation

Define error response like:

{
  "errors": {
    "email": ["Email is already taken"],
    "password": ["Password too weak"]
  }
}

Then, in onSubmit, catch the error and map these to field errors using VeeValidate APIs.

FormKit #

  • Higher-level, schema-driven form system.
  • Good for complex or dynamic forms.

Simple example:

<FormKit
  type="form"
  v-model="formData"
  :actions="false"
  @submit="onSubmit"
>
  <FormKit
    name="email"
    type="email"
    label="Email"
    validation="required|email"
  />

  <FormKit
    name="password"
    type="password"
    label="Password"
    validation="required|length:8"
  />

  <button type="submit">Submit</button>
</FormKit>

Concept is similar: map backend field errors into FormKit’s error system.


4.4 Testing: Vitest + Testing Library #

  • Vitest: Vite-native test runner (Jest-like API).
  • Vue Testing Library: Testing Library for Vue (focus on user-centric tests).

Component:

<!-- Counter.vue -->
<template>
  <button @click="increment">{{ count }}</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

Test:

// Counter.spec.ts
import { render, fireEvent, screen } from '@testing-library/vue'
import Counter from './Counter.vue'
import { describe, it, expect } from 'vitest'

describe('Counter', () => {
  it('increments on click', async () => {
    render(Counter)

    const button = screen.getByRole('button')
    expect(button.textContent).toBe('0')

    await fireEvent.click(button)
    expect(button.textContent).toBe('1')
  })
})

Testing a composable:

// useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
  it('increments', () => {
    const { count, increment } = useCounter(0)
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })
})

Patterns:

  • Test components by behavior and rendered output, not internal refs.
  • Wrap tests with providers (router, Pinia, Vue Query) as needed.

4.5 DevTools and debugging techniques #

Vue DevTools

  • Inspect component tree.
  • View props, state, computed values.
  • Time-travel debugging for Pinia.
  • Inspect router and Vuex/Pinia/Query plugins.

Debugging tips

  • Use browser DevTools:

    • Place debugger inside setup or composables.
    • Vite’s source maps let you debug .vue SFCs and TS.
  • Enable performance markers (dev only):

    import { createApp } from 'vue'
    import App from './App.vue'
    
    const app = createApp(App)
    app.config.performance = true
    app.mount('#app')
  • Common reactivity gotchas:

    • Changing a non-reactive object won’t trigger updates:
      • Ensure you used ref/reactive to create state.
    • Replacing a whole reactive object:
      • With reactive, prefer mutating properties instead of reassigning the variable.
      • Or use ref for the object if you need to replace it wholesale.

defineExpose for introspection

<!-- Child.vue -->
<script setup lang="ts">
import { ref, defineExpose } from 'vue'

const count = ref(0)

defineExpose({ count })
</script>

Parent (in dev console):

  • Get a ref to child (ref="childRef").
  • Inspect childRef.value.count.

Linting & tooling

  • ESLint + Prettier + TypeScript:
    • eslint-plugin-vue
    • @vue/eslint-config-typescript
  • Vite + @vitejs/plugin-vue:
    • Fast HMR and excellent DX for Vue 3.

Here are the absolute best free resources specifically for experienced back-end developers (you already think in terms of modules, services, dependency injection, clean architecture, testing, typing, etc.) who want to become dangerous with modern Vue 3 (2024–2025 best practices) extremely fast — zero fluff, zero “what is a variable”.

Phase 1 – 1-Day Mental Model Shift (Read these in order)

  1. Vue 3 Reactivity in Depth – 30 minutes
    https://vuejs.org/guide/extras/reactivity-in-depth.html
    This is the single most important page for back-end minds.
    After reading this, you will forever think of Vue refs/reactives exactly like Spring beans or DI services that automatically trigger recomputation when injected values change.

  2. Composition API + <script setup> + SFC syntax sugar – 20 minutes
    https://vuejs.org/api/sfc-script-setup.html
    Understand that this is literally just “functions returning reactive state + side effects” — exactly like a service class with private state and public methods.

  3. Composables = your new services / hooks / utilities – 25 minutes
    https://vuejs.org/guide/reusability/composables.html
    This is the #1 architectural tool in real Vue 3 codebases in 2024–2025.
    Back-end translation: composables = pure functions or classes you would normally put in /services, /lib, /utils, /domain.

  4. Official Vue 3 Style Guide – Priority A & B rules only – 15 minutes
    https://vuejs.org/style-guide/
    Treat this exactly like your team’s ESLint + architecture bible.

After these 4 links (total < 2 hours), you already understand 85% of modern Vue 3 architecture better than most front-end devs who have been using it for years.

Phase 2 – Real Production Patterns (study these codebases like architecture reviews)

These are the gold-standard repos that every senior Vue dev studies in 2024–2025.

  1. Vitesse – the real “how a serious Vue 3 app is structured in 2025”
    https://github.com/antfu/vitesse
    Created by Anthony Fu (Vue core team).
    This is the template that literally every top Vue developer forks or copies from.

    Pay special attention to:

    • /composables/ → your services layer
    • /stores/ → Pinia stores (your domain / application services)
    • /components/ → only UI, almost no logic
    • auto-imports, env variables, aliases, layouts, etc.
  2. VueUse – the standard library of composables
    https://github.com/vueuse/vueuse
    Open any file (e.g. useFetch.ts, useDebounceFn.ts, useStorage.ts).
    This is how you write bulletproof, typed, testable, tree-shakable services in Vue 3.

  3. Nuxt 3 – if you ever want file-based routing + SSR (most serious apps use it)
    https://github.com/nuxt/nuxt
    Just skim the examples/ folder or clone https://github.com/nuxt/starter
    Nuxt 3 is to Vue what NestJS is to Node.js — opinionated, clean architecture, perfect for back-end minds.

Phase 3 – “Back-end to Vue 3” Mapping Cheat Sheet (memorize this)

You already know → Modern Vue 3 equivalent (2025)

  • Service classes / domain logic → composables/useXxx.ts
  • Dependency injection → provide() / inject() or just import the composable
  • Repository pattern → composables/useApi.ts or useUserRepository.ts
  • Application/State layer → Pinia stores (setup syntax)
  • Global singletons → Pinia store or composables/useGlobalThing.ts
  • Controllers / route handlers → Vue Router + definePageMeta (Nuxt) or navigation guards
  • Middleware → Vue Router navigation guards or Nuxt middleware
  • Validation → Zod + composable (e.g. useForm(validationSchema))
  • Clean Architecture / Hexagonal → composables (domain) + stores (application) + components (UI adapters)

Phase 4 – Fastest Hands-On Ramp-Up (2–3 days max)

Do this exact weekend project (most back-end devs finish it in < 18 hours):

  1. npm create vite@latest my-app -- --template vue-ts
  2. Add:
    • Pinia
    • Vue Router
    • unplugin-auto-import
    • unplugin-vue-components
    • @vueuse/core
    • zod + vee-validate or zod-form-data
  3. Re-implement one of your real back-end projects as a front-end admin/dashboard (e.g. CRUD for users/orders/products with proper auth, pagination, filters, realtime updates).
  4. Force yourself to put ZERO logic in components → everything must live in composables or Pinia stores.

After you finish this, you are no longer “a back-end dev learning Vue” — you are legitimately dangerous with modern Vue 3.

Bonus: YouTube channels that don’t waste your time (back-end friendly)

  • Anthony Fu (all videos) – https://youtube.com/@antfu
    Literally the guy who shapes Vue 3 direction.
  • Alex Kyriakidis (Vue/Pinia deep dives) – https://youtube.com/@alexkyriakidis
    Speaks like a software engineer, not a tutorial YouTuber.
  • Vue Mastery (free videos only) – filter by “Composition API” or “Pinia”

Follow exactly this path and you’ll be writing production-grade Vue 3 better than 95% of front-end developers in under a week.

Vue 3 Starter Structure (TS + Pinia + Router + TanStack Query + Vee-Validate + Zod) #

Table of Contents


1. Overview & Goals #

This is a minimal but idiomatic Vue 3 starter that includes:

  • Vue 3 + TypeScript + <script setup>.
  • Pinia for client-state.
  • Vue Router for routing.
  • TanStack Query for server-state.
  • axios for HTTP.
  • Zod + Vee-Validate for type-safe validation.
  • unplugin-auto-import (auto-import ref, computed, router hooks, etc.).
  • unplugin-vue-components (auto-register components).

Focus: a structure you can extend in a serious app, while remaining small enough to read in one sitting.


2. Directory Structure #

A minimal, organized src/:

src/
  api/
    auth.ts
    http.ts
    users.ts
  components/
    layout/
      AppShell.vue
  composables/
    useAuth.ts
  plugins/
    vee-validate.ts
    vue-query.ts
  queries/
    useUsersQuery.ts
  router/
    index.ts
  stores/
    auth.ts
  validation/
    schemas.ts
  views/
    HomeView.vue
    UsersView.vue
    LoginView.vue
  App.vue
  main.ts

auto-imports.d.ts        # generated by unplugin-auto-import
components.d.ts          # generated by unplugin-vue-components
env.d.ts                 # Vue SFC declarations

Root files:

  • vite.config.ts – bundler config + auto-import/plugins.
  • tsconfig.json – TS config with @ alias → src.
  • package.json – scripts & dependencies.

3. Tooling & Configuration #

3.1 package.json (key deps) #

Partial example:

{
  "name": "vue3-starter",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit"
  },
  "dependencies": {
    "axios": "^1.7.0",
    "pinia": "^2.1.7",
    "vue": "^3.5.0",
    "vue-router": "^4.4.0",
    "@tanstack/vue-query": "^5.52.0",
    "vee-validate": "^4.13.0",
    "@vee-validate/zod": "^4.13.0",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@tanstack/eslint-plugin-query": "^5.52.0",
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.6.0",
    "unplugin-auto-import": "^0.17.0",
    "unplugin-vue-components": "^0.27.0",
    "vite": "^5.4.0",
    "vue-tsc": "^2.0.0"
  }
}

Adjust versions as needed.


3.2 tsconfig.json #

Configure TS with @ as an alias to src:

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "types": ["vite/client"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": []
}

3.3 Vite config with auto-import & components #

vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
  plugins: [
    vue(),

    AutoImport({
      imports: [
        'vue',
        'vue-router',
        'pinia',
        {
          '@tanstack/vue-query': [
            'useQuery',
            'useMutation',
            'useQueryClient'
          ]
        }
      ],
      dts: 'auto-imports.d.ts',
      vueTemplate: true
    }),

    Components({
      dts: 'components.d.ts',
      directoryAsNamespace: true
    })
  ],
  resolve: {
    alias: {
      '@': '/src'
    }
  }
})

Effects:

  • You can use ref, computed, useRouter, useRoute, useQuery, defineStore, etc. without importing them manually.
  • Components in src/components are auto-registered by name (e.g. AppShell.vue<AppShell />).

3.4 Type declarations for auto-imports & Vue #

env.d.ts (in src/ or project root):

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

auto-imports.d.ts & components.d.ts are generated by the plugins; commit them or regenerate as needed.


4. Core App Files #

4.1 src/main.ts #

Entry point: create app, install router, Pinia, Vue Query, Vee-Validate.

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { vueQueryOptions } from './plugins/vue-query'
import './plugins/vee-validate' // config + global rules if needed
import './assets/main.css' // optional

const app = createApp(App)

const pinia = createPinia()
app.use(pinia)

app.use(router)
app.use(VueQueryPlugin, vueQueryOptions)

app.mount('#app')

Note: ref, computed, etc. are auto-imported by unplugin-auto-import; we only explicitly import plugin entrypoints here.


4.2 src/App.vue #

Global shell + router outlet.

<template>
  <AppShell>
    <RouterView />
  </AppShell>
</template>

<script setup lang="ts">
// AppShell is auto-registered by unplugin-vue-components
</script>

5. Routing #

5.1 src/router/index.ts #

Basic router + lazy-loaded views + simple auth guard hook.

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue')
  },
  {
    path: '/users',
    name: 'users',
    component: () => import('@/views/UsersView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/LoginView.vue')
  }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach((to, from, next) => {
  const auth = useAuthStore()

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return next({ name: 'login', query: { redirect: to.fullPath } })
  }

  next()
})

5.2 Example views #

5.2.1 HomeView – simple local state + store usage

src/views/HomeView.vue:

<template>
  <section class="space-y-4">
    <h1 class="text-2xl font-semibold">Home</h1>

    <p>Local counter: {{ count }}</p>
    <button @click="count++" class="btn">
      Increment
    </button>

    <div v-if="auth.isAuthenticated">
      <p>Logged in as: {{ auth.user?.email }}</p>
      <button @click="auth.logout" class="btn-secondary">
        Logout
      </button>
    </div>
    <div v-else>
      <RouterLink :to="{ name: 'login' }" class="btn-primary">
        Login
      </RouterLink>
    </div>
  </section>
</template>

<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'

// `ref` is auto-imported thanks to unplugin-auto-import
const count = ref(0)

const auth = useAuthStore()
</script>

5.2.2 UsersView – TanStack Query list + auth guard

src/views/UsersView.vue:

<template>
  <section class="space-y-4">
    <h1 class="text-2xl font-semibold">Users</h1>

    <div v-if="isLoading">Loading users...</div>
    <div v-else-if="error">
      Failed to load users.
      <button @click="refetch" class="btn-secondary">
        Retry
      </button>
    </div>
    <ul v-else>
      <li
        v-for="user in users"
        :key="user.id"
        class="border-b py-2"
      >
        {{ user.name }} – {{ user.email }}
      </li>
    </ul>
  </section>
</template>

<script setup lang="ts">
import { useUsersQuery } from '@/queries/useUsersQuery'

const { data, isLoading, error, refetch } = useUsersQuery()

const users = computed(() => data.value ?? [])
</script>

6. State Management with Pinia #

6.1 src/stores/auth.ts #

Simple auth store used by router guard & UI.

import { defineStore } from 'pinia'

export interface AuthUser {
  id: number
  email: string
}

interface AuthState {
  user: AuthUser | null
  token: string | null
}

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.token
  },
  actions: {
    setAuth(user: AuthUser, token: string) {
      this.user = user
      this.token = token
    },
    logout() {
      this.user = null
      this.token = null
    }
  }
})

7. Server-State with TanStack Query #

7.1 src/plugins/vue-query.ts #

Configure a QueryClient with sensible defaults.

import type { VueQueryPluginOptions } from '@tanstack/vue-query'
import { QueryClient } from '@tanstack/vue-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,
      retry: 1,
      refetchOnWindowFocus: false
    }
  }
})

export const vueQueryOptions: VueQueryPluginOptions = {
  queryClient
}

7.2 src/queries/useUsersQuery.ts #

Server-state query composable for users.

import { useQuery } from '@tanstack/vue-query'
import { getUsers } from '@/api/users'

export interface User {
  id: number
  name: string
  email: string
}

export function useUsersQuery() {
  return useQuery<User[], Error>({
    queryKey: ['users'],
    queryFn: getUsers
  })
}

8. HTTP Layer with axios #

8.1 src/api/http.ts #

Central axios instance + auth interceptor.

import axios from 'axios'
import { useAuthStore } from '@/stores/auth'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? 'https://example.com/api',
  withCredentials: true
})

http.interceptors.request.use((config) => {
  const auth = useAuthStore()
  if (auth.token) {
    config.headers = config.headers ?? {}
    config.headers.Authorization = `Bearer ${auth.token}`
  }
  return config
})

http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const auth = useAuthStore()
      auth.logout()
    }
    return Promise.reject(error)
  }
)

8.2 src/api/users.ts #

import { http } from './http'
import type { User } from '@/queries/useUsersQuery'

export async function getUsers(): Promise<User[]> {
  const response = await http.get<User[]>('/users')
  return response.data
}

8.3 src/api/auth.ts #

Simple login endpoint:

import { http } from './http'
import type { AuthUser } from '@/stores/auth'

export interface LoginPayload {
  email: string
  password: string
}

export interface LoginResponse {
  user: AuthUser
  token: string
}

export async function login(payload: LoginPayload): Promise<LoginResponse> {
  const response = await http.post<LoginResponse>('/auth/login', payload)
  return response.data
}

9. Forms & Validation (Vee-Validate + Zod) #

9.1 src/validation/schemas.ts #

Zod schemas shared across client+server (conceptually).

import { z } from 'zod'

export const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters')
})

export type LoginFormValues = z.infer<typeof loginSchema>

9.2 src/plugins/vee-validate.ts #

Global Vee-Validate config + Zod integration.

import { configure } from 'vee-validate'
import { z } from 'zod'

// Example global configuration (optional)
configure({
  validateOnBlur: true,
  validateOnChange: true,
  validateOnInput: false,
  validateOnModelUpdate: true
})

// Optional: where you could attach global messages/localization
// or Zod transforms if needed.

9.3 Login form view #

src/views/LoginView.vue – Vee-Validate + Zod login form.

<template>
  <section class="max-w-sm mx-auto space-y-4">
    <h1 class="text-2xl font-semibold">Login</h1>

    <Form
      :validation-schema="validationSchema"
      @submit="onSubmit"
      v-slot="{ errors, isSubmitting }"
    >
      <div class="space-y-1">
        <label for="email">Email</label>
        <Field
          id="email"
          name="email"
          type="email"
          class="input"
        />
        <span class="text-red-600 text-sm">{{ errors.email }}</span>
      </div>

      <div class="space-y-1">
        <label for="password">Password</label>
        <Field
          id="password"
          name="password"
          type="password"
          class="input"
        />
        <span class="text-red-600 text-sm">{{ errors.password }}</span>
      </div>

      <button
        type="submit"
        class="btn-primary w-full mt-4"
        :disabled="isSubmitting"
      >
        <span v-if="isSubmitting">Logging in...</span>
        <span v-else>Login</span>
      </button>

      <p v-if="formError" class="text-red-600 text-sm mt-2">
        {{ formError }}
      </p>
    </Form>
  </section>
</template>

<script setup lang="ts">
import { Form, Field } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { loginSchema, type LoginFormValues } from '@/validation/schemas'
import { useAuthStore } from '@/stores/auth'
import { login } from '@/api/auth'
import { useRouter, useRoute } from 'vue-router'

const auth = useAuthStore()
const router = useRouter()
const route = useRoute()

const validationSchema = toTypedSchema(loginSchema)

const formError = ref<string | null>(null)

async function onSubmit(values: LoginFormValues) {
  formError.value = null
  try {
    const { user, token } = await login(values)
    auth.setAuth(user, token)

    const redirect = (route.query.redirect as string | undefined) ?? '/'
    router.push(redirect)
  } catch (error: any) {
    // Example backend validation error shape:
    // { errors: { email: ['Email not found'] } }
    const message =
      error.response?.data?.message ??
      'Login failed. Please check your credentials.'
    formError.value = message
  }
}
</script>

Here we use:

  • Form and Field from Vee-Validate.
  • toTypedSchema from @vee-validate/zod to hook up Zod.
  • Type-safe LoginFormValues from Zod schema.

10. Composables #

10.1 src/composables/useAuth.ts #

A small composable to wrap auth store behavior (for reuse and nicer API).

import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'

export function useAuth() {
  const auth = useAuthStore()
  const router = useRouter()

  const isAuthenticated = computed(() => auth.isAuthenticated)
  const user = computed(() => auth.user)

  async function logoutAndRedirect() {
    auth.logout()
    await router.push({ name: 'home' })
  }

  return {
    isAuthenticated,
    user,
    logoutAndRedirect
  }
}

Usage anywhere:

const { isAuthenticated, user, logoutAndRedirect } = useAuth()

11. How auto-import & auto-components change your coding style #

With unplugin-auto-import & unplugin-vue-components configured:

  • In components/composables, you just write:

    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    const router = useRouter()
    const route = useRoute()
    const store = useAuthStore()

    without explicitly:

    import { ref, computed } from 'vue'
    import { useRouter, useRoute } from 'vue-router'
    import { useAuthStore } from '@/stores/auth'
  • Components in src/components are available without local registration:

    • src/components/layout/AppShell.vue<AppShell /> in App.vue.

Example AppShell using a basic layout:

src/components/layout/AppShell.vue:

<template>
  <div class="min-h-screen flex flex-col">
    <header class="border-b px-4 py-2 flex items-center justify-between">
      <RouterLink :to="{ name: 'home' }" class="font-bold">
        My App
      </RouterLink>

      <nav class="space-x-4">
        <RouterLink :to="{ name: 'home' }">Home</RouterLink>
        <RouterLink :to="{ name: 'users' }">Users</RouterLink>
      </nav>
    </header>

    <main class="flex-1 px-4 py-6">
      <slot />
    </main>
  </div>
</template>

<script setup lang="ts">
/*
  No imports needed for RouterLink (from vue-router)
  because `unplugin-vue-components` can auto-register 
  framework components too if configured. If not, you can
  explicitly import it, or register specific resolvers.
*/
</script>

This setup gives you:

  • A clear separation of concerns (router, stores, queries, API, validation).
  • Modern Composition API with <script setup> and strong TypeScript types.
  • Server-state (TanStack Query) vs client-state (Pinia) separation.
  • Minimal ceremony in components thanks to auto-import and auto-components.

Next, we can:

  • Add testing (Vitest + Vue Testing Library) on top of this, and
  • Sketch a small feature end-to-end (route → view → query → form → store).

Vue 3 Starter – Testing & End‑to‑End Feature #

Table of Contents


1. Testing Stack (Vitest + Vue Testing Library) #

This builds on the previous starter structure and adds:

  • Vitest as the test runner.
  • jsdom test environment.
  • Vue Testing Library for component tests.
  • @testing-library/jest-dom for better DOM assertions.
  • @pinia/testing for isolated store tests.

1.1 Dependencies #

Add (or extend) devDependencies in package.json:

{
  "devDependencies": {
    "@testing-library/jest-dom": "^6.4.0",
    "@testing-library/vue": "^8.1.0",
    "@vitejs/plugin-vue": "^5.0.0",
    "@pinia/testing": "^0.1.3",
    "jsdom": "^24.0.0",
    "vitest": "^2.1.0"
  },
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui"
  }
}

(Other deps from the starter – Vue, Vite, Pinia, etc. – stay as previously defined.)

1.2 vitest.config.ts #

Create vitest.config.ts at the root:

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    coverage: {
      reporter: ['text', 'html']
    }
  }
})

Notes:

  • We mirror the @ alias from vite.config.ts.
  • setupFiles points to our test bootstrap (see below).

1.3 test/setup.ts #

Create test/setup.ts:

import '@testing-library/jest-dom'

This gives you assertions like:

  • expect(element).toBeInTheDocument()
  • expect(element).toHaveTextContent('foo')

…in addition to Vitest’s core assertions.

1.4 Example tests #

We’ll add three kinds of tests:

  • A store unit test (auth store).
  • A simple component test (HomeView).
  • A more integrated test for the new ProfileView (in §2.7).

1.4.1 Store test – src/stores/auth.ts

If you slightly extend your auth store (see §2.4), a basic test might look like:

test/stores/auth.spec.ts:

import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { describe, it, expect, beforeEach } from 'vitest'

describe('auth store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('sets auth user and token', () => {
    const auth = useAuthStore()

    auth.setAuth(
      { id: 1, email: '[email protected]', name: 'Alice' },
      'fake-token'
    )

    expect(auth.isAuthenticated).toBe(true)
    expect(auth.user?.email).toBe('[email protected]')
    expect(auth.token).toBe('fake-token')
  })

  it('logs out', () => {
    const auth = useAuthStore()

    auth.setAuth(
      { id: 1, email: '[email protected]', name: 'Alice' },
      'fake-token'
    )
    auth.logout()

    expect(auth.isAuthenticated).toBe(false)
    expect(auth.user).toBeNull()
    expect(auth.token).toBeNull()
  })
})

1.4.2 Component test – src/views/HomeView.vue

Recall HomeView.vue from the starter:

  • Shows a local counter.
  • Shows login link or logout button based on auth state.

test/views/HomeView.spec.ts:

import { render, screen, fireEvent } from '@testing-library/vue'
import HomeView from '@/views/HomeView.vue'
import { describe, it, expect } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createTestingPinia } from '@pinia/testing'

describe('HomeView', () => {
  it('increments local counter when button is clicked', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: [] // no real routes needed for this test
    })

    render(HomeView, {
      global: {
        plugins: [
          router,
          createTestingPinia({
            stubActions: false
          })
        ]
      }
    })

    const counterText = () => screen.getByText(/Local counter:/)
    expect(counterText()).toHaveTextContent('Local counter: 0')

    const incrementButton = screen.getByRole('button', { name: /increment/i })
    await fireEvent.click(incrementButton)
    await fireEvent.click(incrementButton)

    expect(counterText()).toHaveTextContent('Local counter: 2')
  })
})

This pattern:

  • Uses createTestingPinia so stores can be used but don’t leak across tests.
  • Uses createMemoryHistory for in-memory router in tests.

We’ll add a more realistic integration test for ProfileView in §2.7.


2. End‑to‑End Feature: Profile Edit Flow #

Goal: implement a Profile page that:

  • Has a route (/profile).
  • Uses TanStack Query to fetch the current user profile.
  • Shows a form (Vee-Validate + Zod) to edit name/bio.
  • Submits via an API module and
  • Updates the auth store with the new user data.

Then we’ll add a test that runs through the flow.

2.1 Router wiring #

Extend src/router/index.ts to add /profile (protected):

// ...existing imports...
import { useAuthStore } from '@/stores/auth'

// existing routes array:
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue')
  },
  {
    path: '/users',
    name: 'users',
    component: () => import('@/views/UsersView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/profile',
    name: 'profile',
    component: () => import('@/views/ProfileView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/LoginView.vue')
  }
]

// ...existing router + beforeEach guard...

The existing beforeEach guard (from the starter) will redirect unauthenticated users to /login.

2.2 API: src/api/profile.ts #

Add a profile API that uses the shared axios instance:

src/api/profile.ts:

import { http } from './http'
import type { AuthUser } from '@/stores/auth'

export interface Profile extends AuthUser {
  bio?: string | null
}

export async function getProfile(): Promise<Profile> {
  const res = await http.get<Profile>('/me')
  return res.data
}

export interface UpdateProfilePayload {
  name: string
  bio?: string | null
}

export async function updateProfile(
  payload: UpdateProfilePayload
): Promise<Profile> {
  const res = await http.put<Profile>('/me', payload)
  return res.data
}

Assumptions:

  • Backend exposes GET /me and PUT /me returning a full profile.

2.3 Query: src/queries/useProfileQuery.ts #

Add a TanStack Query composable:

src/queries/useProfileQuery.ts:

import { useQuery } from '@tanstack/vue-query'
import { getProfile } from '@/api/profile'
import type { Profile } from '@/api/profile'

export function useProfileQuery() {
  return useQuery<Profile, Error>({
    queryKey: ['profile'],
    queryFn: getProfile
  })
}

Usage is straightforward:

const { data: profile, isLoading, error, refetch } = useProfileQuery()

2.4 Store: extend src/stores/auth.ts #

To keep auth state and profile consistent, we’ll:

  • Add name to AuthUser.
  • Add an updateUserFromProfile action.

Complete src/stores/auth.ts (revised):

import { defineStore } from 'pinia'

export interface AuthUser {
  id: number
  email: string
  name: string
}

interface AuthState {
  user: AuthUser | null
  token: string | null
}

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.token
  },
  actions: {
    setAuth(user: AuthUser, token: string) {
      this.user = user
      this.token = token
    },
    logout() {
      this.user = null
      this.token = null
    },
    updateUserFromProfile(profile: { id: number; email: string; name: string }) {
      if (this.user && this.user.id === profile.id) {
        this.user.email = profile.email
        this.user.name = profile.name
      } else {
        this.user = {
          id: profile.id,
          email: profile.email,
          name: profile.name
        }
      }
    }
  }
})

Update src/api/auth.ts so login returns a user with name:

import { http } from './http'
import type { AuthUser } from '@/stores/auth'

export interface LoginPayload {
  email: string
  password: string
}

export interface LoginResponse {
  user: AuthUser
  token: string
}

export async function login(payload: LoginPayload): Promise<LoginResponse> {
  const response = await http.post<LoginResponse>('/auth/login', payload)
  return response.data
}

2.5 Validation: extend src/validation/schemas.ts #

Add a profile schema using Zod:

src/validation/schemas.ts (extended):

import { z } from 'zod'

export const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters')
})

export type LoginFormValues = z.infer<typeof loginSchema>

export const profileSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  bio: z
    .string()
    .max(160, 'Bio must be at most 160 characters')
    .optional()
})

export type ProfileFormValues = z.infer<typeof profileSchema>

We’ll use profileSchema in the ProfileView via @vee-validate/zod.

2.6 View: src/views/ProfileView.vue #

The page that ties everything together:

  • Route: /profile.
  • Query: useProfileQuery().
  • Form: Vee-Validate + Zod.
  • Store: updates auth user via updateUserFromProfile.
  • Mutation: useMutation(updateProfile) with cache update.

src/views/ProfileView.vue:

<template>
  <section class="max-w-md mx-auto space-y-4">
    <h1 class="text-2xl font-semibold">Profile</h1>

    <div v-if="isLoading">Loading profile...</div>

    <div v-else-if="error">
      Failed to load profile.
      <button @click="refetch" class="btn-secondary">Retry</button>
    </div>

    <Form
      v-else
      :validation-schema="validationSchema"
      :initial-values="initialValues"
      @submit="onSubmit"
      v-slot="{ errors, isSubmitting }"
    >
      <div class="space-y-1">
        <label for="name">Name</label>
        <Field id="name" name="name" class="input" />
        <span class="text-red-600 text-sm">{{ errors.name }}</span>
      </div>

      <div class="space-y-1">
        <label for="bio">Bio</label>
        <Field
          id="bio"
          name="bio"
          as="textarea"
          rows="3"
          class="input"
        />
        <span class="text-red-600 text-sm">{{ errors.bio }}</span>
      </div>

      <button
        type="submit"
        class="btn-primary mt-4"
        :disabled="isSubmitting || isUpdating"
      >
        <span v-if="isSubmitting || isUpdating">Saving...</span>
        <span v-else>Save</span>
      </button>

      <p v-if="formError" class="text-red-600 text-sm mt-2">
        {{ formError }}
      </p>

      <p v-if="formSuccess" class="text-green-600 text-sm mt-2">
        Profile updated.
      </p>
    </Form>
  </section>
</template>

<script setup lang="ts">
import { Form, Field } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { profileSchema, type ProfileFormValues } from '@/validation/schemas'
import { useProfileQuery } from '@/queries/useProfileQuery'
import { updateProfile } from '@/api/profile'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useAuthStore } from '@/stores/auth'

const validationSchema = toTypedSchema(profileSchema)

const { data: profile, isLoading, error, refetch } = useProfileQuery()

const auth = useAuthStore()
const queryClient = useQueryClient()

const formError = ref<string | null>(null)
const formSuccess = ref(false)

const initialValues = computed<ProfileFormValues>(() => ({
  name: profile.value?.name ?? '',
  bio: profile.value?.bio ?? ''
}))

const { mutateAsync: saveProfile, isPending: isUpdating } = useMutation({
  mutationFn: updateProfile,
  onSuccess(updated) {
    // Update cache & auth store
    queryClient.setQueryData(['profile'], updated)
    auth.updateUserFromProfile(updated)
    formSuccess.value = true
  }
})

async function onSubmit(values: ProfileFormValues) {
  formError.value = null
  formSuccess.value = false

  try {
    await saveProfile({
      name: values.name,
      bio: values.bio
    })
  } catch (err: any) {
    formError.value =
      err.response?.data?.message ?? 'Failed to update profile.'
  }
}
</script>

End-to-end, this route shows the live profile from the server, validates input, persists changes via an API call, updates the local auth store, and signals success.

2.7 Test: ProfileView.spec.ts #

Now we’ll write a test that verifies:

  • The profile is loaded and rendered.
  • The user can change their name.
  • Saving triggers the update API and shows a success message.

We’ll mock the profile API and wire up Pinia + Vue Query + Router.

test/views/ProfileView.spec.ts:

import { render, screen, fireEvent } from '@testing-library/vue'
import ProfileView from '@/views/ProfileView.vue'
import { describe, it, expect, vi } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
import { createTestingPinia } from '@pinia/testing'
import type { Profile } from '@/api/profile'

// Mock the profile API module
vi.mock('@/api/profile', () => {
  const profile: Profile = {
    id: 1,
    email: '[email protected]',
    name: 'Alice',
    bio: 'Hello!'
  }

  return {
    getProfile: vi.fn().mockResolvedValue(profile),
    updateProfile: vi.fn(async (payload: { name: string; bio?: string | null }) => ({
      ...profile,
      ...payload
    }))
  }
})

describe('ProfileView', () => {
  it('loads and updates profile', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: [
        {
          path: '/',
          name: 'profile',
          component: ProfileView
        }
      ]
    })

    const queryClient = new QueryClient()

    render(ProfileView, {
      global: {
        plugins: [
          [VueQueryPlugin, { queryClient }],
          router,
          createTestingPinia({
            initialState: {
              auth: {
                user: {
                  id: 1,
                  email: '[email protected]',
                  name: 'Alice'
                },
                token: 'fake-token'
              }
            },
            stubActions: false
          })
        ]
      }
    })

    // Wait for profile data to load (initial value "Alice" in name field)
    const nameInput = (await screen.findByLabelText('Name')) as HTMLInputElement
    expect(nameInput.value).toBe('Alice')

    // Update name
    await fireEvent.update(nameInput, 'Alice Updated')

    const saveButton = screen.getByRole('button', { name: /save/i })
    await fireEvent.click(saveButton)

    // Wait for success message
    await screen.findByText(/profile updated/i)

    // Expect name input to reflect the updated value
    expect(nameInput.value).toBe('Alice Updated')
  })
})

This test demonstrates:

  • Route → View: Component is rendered with a router instance (though we don’t navigate between routes here).
  • View → Query: useProfileQuery is called; we mock getProfile.
  • View → Form: User interacts with form fields validated by Vee-Validate/Zod.
  • Form → API → Store:
    • On submit, updateProfile is called (mocked).
    • The success callback updates the profile cache and store.
    • UI shows a success message and updated input value.

You now have:

  • A testing setup (Vitest + Vue Testing Library) that can exercise components, stores, and composables.
  • A full vertical feature (Profile edit) that uses:
    • RouterViewQueryForm/ValidationAPIStoreUI feedback.

From here you can:

  • Factor testing helpers into test/utils.ts (e.g. renderWithPlugins).
  • Introduce MSW (Mock Service Worker) if you want higher-fidelity API mocking across many tests.
  • Expand the pattern to other CRUD flows (lists, details, creation wizards, etc.).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment