GUIDE

Vue.js & Nuxt: Progressive Web Applications

A deep technical guide to Vue.js 3 and Nuxt.js: Composition API, Options API, Pinia state management, Nuxt 3 SSR/SSG/ISR, Vue Router, component patterns (slots, provide/inject, teleport), Vitest testing, Vue DevTools, transitions and animations, and Vite build tool. Based on building webgym-spa, a white-label Nuxt application serving multiple gym brands.

Vue.js 3Nuxt 3Composition APIOptions APIPiniaVue RouterVitestViteVue DevToolsTeleportTypeScript

Table of Contents

  1. 1. Composition API vs Options API
  2. 2. State Management: Vuex to Pinia
  3. 3. Nuxt 3: SSR, SSG, and ISR
  4. 4. Vue Router and Navigation Guards
  5. 5. Component Design Patterns
  6. 6. Transitions and Animations
  7. 7. Testing with Vitest
  8. 8. Vue DevTools
  9. 9. Vite: The Build Tool
  10. 10. Vue 3.5, Vapor Mode, and Nuxt 4

1. Composition API vs Options API

Vue 3's Composition API replaces the Options API's object-based organization (data, computed, methods, watch) with a function-based approach. Logic is grouped by feature concern rather than by option type. A component that manages both search and pagination previously scattered related code across data, computed, methods, and watch; with the Composition API, each concern becomes a composable function.

Options API: The Classic Approach

The Options API organizes component logic by option type. It is still fully supported in Vue 3 and remains a valid choice for simpler components. However, as components grow, related logic gets fragmented across different options, making it harder to maintain.

// Options API: logic organized by option type
export default {
  data() {
    return {
      query: '',
      results: [] as Member[],
      page: 1,
      isSearching: false,
    };
  },
  computed: {
    activeCount(): number {
      return this.results.filter(m => m.status === 'active').length;
    },
  },
  watch: {
    query(newVal: string) {
      this.search(newVal);   // search logic here
    },
    page() {
      this.search(this.query); // but pagination resets search too
    },
  },
  methods: {
    async search(q: string) {
      this.isSearching = true;
      this.results = await memberApi.search(q, this.page);
      this.isSearching = false;
    },
  },
  mounted() {
    this.search('');
  },
};
The Options API scatters search-related code across data, computed, watch, methods, and mounted. With the Composition API, the same logic lives in a single useMemberSearch composable, making it easier to read, test, and reuse across components.

The Reactivity System

Vue 3's reactivity is built on ES6 Proxies (replacing Vue 2's Object.defineProperty). ref() wraps a primitive value in a reactive container (access via .value). reactive() makes an entire object reactive (no .value needed). computed() creates derived values that cache and update automatically. watch() and watchEffect() handle side effects when reactive values change.

<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useMemberSearch } from '@/composables/useMemberSearch';
import { usePagination } from '@/composables/usePagination';
import type { Member, MemberFilters } from '@/types';

// Composables: encapsulated, reusable reactive logic
const { query, results, isSearching, search } = useMemberSearch();
const { page, pageSize, total, totalPages, goToPage } = usePagination();

// Local reactive state
const filters = reactive<MemberFilters>({
  status: 'all',
  plan: null,
  sortBy: 'name',
});

const selectedMember = ref<Member | null>(null);

// Computed: derived values with automatic caching
const activeCount = computed(() =>
  results.value.filter(m => m.status === 'active').length
);

const hasFilters = computed(() =>
  filters.status !== 'all' || filters.plan !== null
);

// Watch: react to filter changes
watch(filters, (newFilters) => {
  goToPage(1); // Reset pagination on filter change
  search(query.value, newFilters);
}, { deep: true });

// Lifecycle
onMounted(async () => {
  await search('', filters);
});
</script>

Composables: The Heart of Code Reuse

A composable is a function that uses Vue's Composition API to encapsulate and reuse stateful logic. Unlike mixins (which had naming collisions and implicit dependencies), composables have explicit inputs and outputs, clear dependency tracking, and full TypeScript support. Each composable is a self-contained module with its own reactive state, computed properties, and watchers.

// composables/useMemberSearch.ts
import { ref, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import type { Member, MemberFilters } from '@/types';
import { memberApi } from '@/api/members';

export function useMemberSearch(debounceMs = 300) {
  const query = ref('');
  const results = ref<Member[]>([]);
  const isSearching = ref(false);
  const error = ref<Error | null>(null);

  const performSearch = useDebounceFn(async (q: string, filters?: MemberFilters) => {
    isSearching.value = true;
    error.value = null;
    try {
      results.value = await memberApi.search(q, filters);
    } catch (e) {
      error.value = e as Error;
      results.value = [];
    } finally {
      isSearching.value = false;
    }
  }, debounceMs);

  // Auto-search when query changes
  watch(query, (q) => performSearch(q));

  function search(q: string, filters?: MemberFilters) {
    query.value = q;
    return performSearch(q, filters);
  }

  return { query, results, isSearching, error, search } as const;
}

VueUse: The Essential Composable Library

VueUse is a collection of 200+ composables covering browser APIs, sensors, animations, state management, and utilities. Instead of writing your own useLocalStorage, useMediaQuery, useIntersectionObserver, or useFetch, VueUse provides tested, TypeScript-native implementations. It is the de facto standard utility library for Vue 3 projects.

2. State Management: Vuex to Pinia

Vuex: The Legacy Standard

Vuex was Vue 2's official state management library. It enforces a strict pattern: state (the data), getters (computed derived state), mutations (synchronous state changes), and actions (async operations that commit mutations). While effective, Vuex had pain points: no TypeScript support, verbose boilerplate, and the mutation/action distinction that added complexity without proportional benefit.

Pinia: The Modern Replacement

Pinia is the official replacement for Vuex in Vue 3. It eliminates mutations entirely (actions directly modify state), provides first-class TypeScript support with full type inference, supports multiple stores without nested modules, and integrates with Vue DevTools. Each store is a composable function, making it natural to use alongside the Composition API.

// stores/members.ts - Pinia store with TypeScript
import { defineStore } from 'pinia';
import { memberApi } from '@/api/members';
import type { Member, MemberFilters, CreateMemberDto } from '@/types';

interface MembersState {
  members: Member[];
  selectedId: string | null;
  filters: MemberFilters;
  loading: boolean;
  error: string | null;
}

export const useMembersStore = defineStore('members', {
  state: (): MembersState => ({
    members: [],
    selectedId: null,
    filters: { status: 'all', plan: null, sortBy: 'name' },
    loading: false,
    error: null,
  }),

  getters: {
    activeMembers: (state) => state.members.filter(m => m.status === 'active'),
    selectedMember: (state) => state.members.find(m => m.id === state.selectedId) ?? null,
    membersByPlan: (state) => {
      const grouped = new Map<string, Member[]>();
      for (const m of state.members) {
        const plan = m.planName ?? 'No Plan';
        if (!grouped.has(plan)) grouped.set(plan, []);
        grouped.get(plan)!.push(m);
      }
      return grouped;
    },
  },

  actions: {
    async fetchMembers() {
      this.loading = true;
      this.error = null;
      try {
        this.members = await memberApi.list(this.filters);
      } catch (e) {
        this.error = (e as Error).message;
      } finally {
        this.loading = false;
      }
    },

    async createMember(dto: CreateMemberDto) {
      const member = await memberApi.create(dto);
      this.members.push(member);
      return member;
    },

    async deleteMember(id: string) {
      await memberApi.delete(id);
      this.members = this.members.filter(m => m.id !== id);
      if (this.selectedId === id) this.selectedId = null;
    },
  },
});

Setup Stores: Composition API Syntax

Pinia also supports a "setup store" syntax that mirrors the Composition API. Instead of options (state, getters, actions), you use ref, computed, and regular functions. This approach provides maximum flexibility and is preferred when stores contain complex logic with watchers and composables.

// Setup store syntax: full Composition API power
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null);
  const token = ref<string | null>(localStorage.getItem('token'));
  const isAuthenticated = computed(() => !!token.value && !!user.value);
  const isAdmin = computed(() => user.value?.role === 'admin');

  async function login(credentials: LoginDto) {
    const response = await authApi.login(credentials);
    token.value = response.token;
    user.value = response.user;
    localStorage.setItem('token', response.token);
  }

  function logout() {
    token.value = null;
    user.value = null;
    localStorage.removeItem('token');
  }

  // Auto-fetch user profile when token exists
  watch(token, async (newToken) => {
    if (newToken && !user.value) {
      try { user.value = await authApi.getProfile(); }
      catch { logout(); }
    }
  }, { immediate: true });

  return { user, token, isAuthenticated, isAdmin, login, logout };
});

3. Nuxt 3: SSR, SSG, and ISR

Nuxt 3 is the meta-framework for Vue.js, analogous to Next.js for React. It provides file-based routing, server-side rendering (SSR), static site generation (SSG), incremental static regeneration (ISR), auto-imports, and a module ecosystem. Built on Vue 3 and Nitro (a universal server engine), it supports deployment to Node.js, serverless functions, edge workers, and static hosting.

Rendering Modes: SSR, SSG, and ISR

Nuxt supports multiple rendering strategies: Universal SSR renders on the server and hydrates on the client for every request. SSG (Static Site Generation) pre-renders all pages at build time into static HTML. ISR (Incremental Static Regeneration) serves cached static pages but revalidates them in the background after a configurable time window (using swr in routeRules). Hybrid rendering mixes these strategies per route. For the white-label app, we used ISR for gym listing pages (revalidated hourly) and full SSR for member dashboards requiring fresh data.

// nuxt.config.ts - Nuxt 3 configuration for white-label app
export default defineNuxtConfig({
  ssr: true,

  routeRules: {
    '/': { prerender: true },                    // Static: homepage
    '/gyms': { swr: 3600 },                      // ISR: revalidate hourly
    '/gyms/**': { swr: 300 },                    // ISR: revalidate every 5 min
    '/dashboard/**': { ssr: true },              // SSR: always fresh
    '/api/**': { cors: true, cache: false },     // API: no cache
  },

  modules: [
    '@pinia/nuxt',
    '@nuxtjs/i18n',
    '@vueuse/nuxt',
    '@nuxt/image',
  ],

  i18n: {
    locales: [
      { code: 'en', file: 'en.json' },
      { code: 'es', file: 'es.json' },
    ],
    defaultLocale: 'es',
    strategy: 'prefix_except_default',
  },

  runtimeConfig: {
    apiSecret: process.env.API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || 'https://api.example.com',
      tenantId: process.env.NUXT_PUBLIC_TENANT_ID,
    },
  },
});

Data Fetching: useFetch and useAsyncData

Nuxt provides useFetch and useAsyncData composables for data fetching that work seamlessly in SSR. Data fetched on the server is serialized and transferred to the client, avoiding duplicate requests during hydration. The composables return reactive references for the data, pending state, and error, plus a refresh function for manual refetching.

// pages/gyms/[slug].vue - Dynamic page with SSR data fetching
<script setup lang="ts">
const route = useRoute();
const { locale } = useI18n();

// Data fetched on server, transferred to client on hydration
const { data: gym, error } = await useFetch<Gym>(
  () => `/api/gyms/${route.params.slug}`,
  {
    query: { lang: locale.value },
    transform: (data) => ({
      ...data,
      scheduleFormatted: formatSchedule(data.schedule, locale.value),
    }),
  }
);

// Client-only data: fetched after hydration
const { data: reviews } = useLazyFetch<Review[]>(
  () => `/api/gyms/${route.params.slug}/reviews`,
  { server: false } // Skip SSR, fetch on client only
);

// SEO meta tags from fetched data
useHead({
  title: () => gym.value?.name ?? 'Gym',
  meta: [
    { name: 'description', content: () => gym.value?.description ?? '' },
    { property: 'og:image', content: () => gym.value?.coverImage ?? '' },
  ],
});

if (error.value) {
  throw createError({ statusCode: 404, message: 'Gym not found' });
}
</script>

White-Label Architecture

A white-label application serves multiple brands from a single codebase. In Nuxt, this is achieved through runtime configuration: the tenant ID determines the theme (colors, logo, fonts), content, and API endpoints. Middleware resolves the tenant from the hostname, and a Pinia store holds the tenant configuration that drives the entire UI.

In production, webgym-spa was a single Nuxt application that served 40+ gym brands. Each gym had its own subdomain (mygym.example.com), custom colors, logo, and schedule. The tenant was resolved in server middleware from the hostname, and the theme was injected as CSS custom properties. This single codebase replaced what would have been 40+ separate deployments, reducing maintenance cost by 95%.

4. Vue Router and Navigation Guards

Vue Router provides client-side routing with history mode (clean URLs), hash mode (fallback), and memory mode (SSR). In Nuxt, routing is file-based: the pages/ directory structure defines routes automatically. Dynamic segments use bracket syntax ([id].vue), catch-all routes use spread syntax ([...slug].vue).

Navigation Guards

Navigation guards intercept route transitions for authentication, authorization, and data prefetching. Global guards (beforeEach) run on every navigation. Route-level guards (beforeEnter) run on specific routes. Component guards (onBeforeRouteLeave) handle unsaved changes prompts. In Nuxt, use route middleware files for clean guard organization.

// middleware/auth.ts - Nuxt route middleware
export default defineNuxtRouteMiddleware((to, from) => {
  const authStore = useAuthStore();

  if (!authStore.isAuthenticated) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath },
    });
  }

  // Role-based access control
  const requiredRole = to.meta.role as string | undefined;
  if (requiredRole && authStore.user?.role !== requiredRole) {
    return navigateTo('/unauthorized');
  }
});

// middleware/tenant.global.ts - Global middleware: resolve tenant on every request
export default defineNuxtRouteMiddleware(async (to) => {
  const tenantStore = useTenantStore();
  if (tenantStore.isLoaded) return;

  const hostname = useRequestHeaders()['host'] || window.location.hostname;
  try {
    await tenantStore.resolveFromHostname(hostname);
  } catch {
    throw createError({ statusCode: 404, message: 'Gym not found' });
  }
});

File-Based Routing in Nuxt

pages/
  index.vue                     # /
  login.vue                     # /login
  gyms/
    index.vue                   # /gyms
    [slug].vue                  # /gyms/:slug (dynamic)
    [slug]/
      schedule.vue              # /gyms/:slug/schedule
      plans.vue                 # /gyms/:slug/plans
  dashboard/
    index.vue                   # /dashboard (requires auth middleware)
    profile.vue                 # /dashboard/profile
    subscriptions/
      index.vue                 # /dashboard/subscriptions
      [id].vue                  # /dashboard/subscriptions/:id
  [...slug].vue                 # Catch-all: 404 page

5. Component Design Patterns

Slots: Flexible Component Composition

Slots are Vue's mechanism for component composition, analogous to React's children prop but more powerful. Named slots allow multiple content areas. Scoped slots expose data from the child to the parent's slot content. The v-slot directive provides a clean API for both patterns.

<!-- DataTable.vue: generic table with scoped slots -->
<template>
  <div class="data-table">
    <div class="toolbar">
      <slot name="toolbar" :selectedCount="selectedIds.size">
        <span>{{ total }} items</span>
      </slot>
    </div>

    <table>
      <thead>
        <tr>
          <th v-for="col in columns" :key="col.key" @click="toggleSort(col.key)">
            {{ col.label }}
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in sortedData" :key="row.id">
          <!-- Scoped slot: parent decides how to render each cell -->
          <td v-for="col in columns" :key="col.key">
            <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
              {{ row[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>

    <slot name="pagination" :page="page" :totalPages="totalPages" :goTo="goToPage" />
  </div>
</template>

<!-- Usage: customizing cells and toolbar -->
<DataTable :data="members" :columns="memberColumns">
  <template #toolbar="{ selectedCount }">
    <button v-if="selectedCount" @click="bulkDelete">Delete {{ selectedCount }}</button>
  </template>
  <template #cell-status="{ value }">
    <StatusBadge :status="value" />
  </template>
  <template #cell-actions="{ row }">
    <button @click="editMember(row.id)">Edit</button>
  </template>
</DataTable>

Provide/Inject: Dependency Injection

Vue's provide/inject system enables passing data down the component tree without prop drilling, similar to React Context. A parent component provides values, and any descendant can inject them. With the Composition API, provide and inject accept InjectionKey symbols for type safety.

// Typed provide/inject with InjectionKey
import { provide, inject, type InjectionKey } from 'vue';

interface ThemeConfig {
  primaryColor: string;
  fontFamily: string;
  borderRadius: string;
}

const ThemeKey: InjectionKey<ThemeConfig> = Symbol('ThemeConfig');

// In a parent component or app setup
provide(ThemeKey, {
  primaryColor: tenantStore.theme.primary,
  fontFamily: tenantStore.theme.font,
  borderRadius: '8px',
});

// In any descendant component
const theme = inject(ThemeKey);
// theme is typed as ThemeConfig | undefined

Renderless Components

Renderless components contain logic but no template. They expose their state and methods through scoped slots, letting the parent control the rendering entirely. This pattern is powerful for reusable behaviors like form validation, drag-and-drop, infinite scroll, and data fetching wrappers.

Teleport: Rendering Outside the Component Tree

Vue's <Teleport> component renders its children into a different DOM node, outside the component's parent hierarchy. This is essential for modals, tooltips, notifications, and dropdowns that need to escape overflow:hidden or z-index stacking contexts while still being logically owned by their parent component.

<!-- ConfirmDialog.vue: modal teleported to body -->
<template>
  <Teleport to="body">
    <Transition name="fade">
      <div v-if="visible" class="modal-overlay" @click.self="close">
        <div class="modal-content" role="dialog" aria-modal="true">
          <h2>{{ title }}</h2>
          <p>{{ message }}</p>
          <div class="actions">
            <button @click="close">Cancel</button>
            <button @click="confirm" class="primary">Confirm</button>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
defineProps<{ title: string; message: string; visible: boolean }>();
const emit = defineEmits<{
  (e: 'confirm'): void;
  (e: 'close'): void;
}>();

function confirm() { emit('confirm'); }
function close() { emit('close'); }
</script>
Teleport solves a common CSS problem: a modal inside a deeply nested component inherits its ancestor's overflow:hidden or z-index stacking context. By teleporting to body, the modal renders at the top of the DOM while its reactive state stays bound to the parent component. Teleport also supports conditional targets with the disabled prop for SSR compatibility.

6. Transitions and Animations

Vue provides built-in <Transition> and <TransitionGroup> components that apply enter/leave animations when elements are inserted, updated, or removed from the DOM. These components work with CSS transitions, CSS animations, and JavaScript hooks, integrating seamlessly with v-if, v-show, dynamic components, and list rendering.

Single Element Transitions

<!-- Fade transition using CSS classes -->
<template>
  <button @click="show = !show">Toggle</button>
  <Transition name="fade" mode="out-in">
    <div v-if="show" class="panel">Content here</div>
  </Transition>
</template>

<style>
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

List Transitions with TransitionGroup

<TransitionGroup> animates list items as they are added, removed, or reordered. Unlike <Transition>, it renders an actual wrapper element (default <span>, configurable via the tag prop). The move class handles FLIP-based reorder animations automatically.

<!-- Animated member list with enter/leave/move transitions -->
<template>
  <TransitionGroup name="list" tag="ul" class="member-list">
    <li v-for="member in sortedMembers" :key="member.id">
      {{ member.name }} &mdash; {{ member.planName }}
    </li>
  </TransitionGroup>
</template>

<style>
.list-enter-active, .list-leave-active {
  transition: all 0.4s ease;
}
.list-enter-from, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
/* FLIP animation for reordering */
.list-move {
  transition: transform 0.4s ease;
}
/* Ensure leaving items are taken out of flow */
.list-leave-active {
  position: absolute;
}
</style>

JavaScript Hooks for Complex Animations

For animations that require JavaScript logic (e.g., staggered enters, physics-based motion, or GSAP integration), use the @before-enter, @enter, @leave hooks. Set :css="false" to skip CSS class detection and rely entirely on the JavaScript hooks.

<Transition
  @before-enter="onBeforeEnter"
  @enter="onEnter"
  @leave="onLeave"
  :css="false"
>
  <div v-if="show">Animated content</div>
</Transition>

<script setup lang="ts">
import gsap from 'gsap';

function onBeforeEnter(el: Element) {
  (el as HTMLElement).style.opacity = '0';
  (el as HTMLElement).style.transform = 'translateY(20px)';
}

function onEnter(el: Element, done: () => void) {
  gsap.to(el, { opacity: 1, y: 0, duration: 0.5, onComplete: done });
}

function onLeave(el: Element, done: () => void) {
  gsap.to(el, { opacity: 0, y: -20, duration: 0.3, onComplete: done });
}
</script>
Vue's transition system covers route transitions too. In Nuxt 3, configure page transitions globally in nuxt.config.ts with app: { pageTransition: { name: 'page', mode: 'out-in' } }, or per-page using definePageMeta({ pageTransition: { name: 'slide' } }). This provides smooth navigation animations across the entire application with minimal configuration.

7. Testing with Vitest

Vitest is a Vite-native testing framework that provides instant test execution, native ESM support, and a Jest-compatible API. It shares Vite's configuration (aliases, plugins, transforms), eliminating the config duplication that plagued Jest setups with Vue. Combined with Vue Test Utils, it enables testing components, composables, and stores with minimal boilerplate.

Testing Components

// MemberCard.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import MemberCard from '@/components/MemberCard.vue';
import type { Member } from '@/types';

const mockMember: Member = {
  id: '1', name: 'Ana Garcia', email: 'ana@example.com',
  status: 'active', planName: 'Premium', joinedAt: '2024-01-15',
};

describe('MemberCard', () => {
  it('displays member information', () => {
    const wrapper = mount(MemberCard, {
      props: { member: mockMember },
      global: { plugins: [createTestingPinia()] },
    });

    expect(wrapper.text()).toContain('Ana Garcia');
    expect(wrapper.text()).toContain('Premium');
    expect(wrapper.find('[data-testid="status-badge"]').text()).toBe('Active');
  });

  it('emits edit event when edit button is clicked', async () => {
    const wrapper = mount(MemberCard, {
      props: { member: mockMember },
      global: { plugins: [createTestingPinia()] },
    });

    await wrapper.find('[data-testid="edit-btn"]').trigger('click');
    expect(wrapper.emitted('edit')).toHaveLength(1);
    expect(wrapper.emitted('edit')![0]).toEqual([mockMember.id]);
  });
});

Testing Composables

// composables/useMemberSearch.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { flushPromises } from '@vue/test-utils';
import { useMemberSearch } from '@/composables/useMemberSearch';
import { memberApi } from '@/api/members';

vi.mock('@/api/members');

describe('useMemberSearch', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('searches members with debounce', async () => {
    const mockResults = [{ id: '1', name: 'Ana' }];
    vi.mocked(memberApi.search).mockResolvedValue(mockResults);

    // Must be called inside a Vue component context
    const { query, results, isSearching, search } = withSetup(() =>
      useMemberSearch(100) // 100ms debounce for tests
    );

    search('Ana');
    expect(isSearching.value).toBe(false); // Not yet (debounced)

    await vi.advanceTimersByTimeAsync(100);
    await flushPromises();

    expect(memberApi.search).toHaveBeenCalledWith('Ana', undefined);
    expect(results.value).toEqual(mockResults);
    expect(isSearching.value).toBe(false);
  });
});

Testing Pinia Stores

Pinia provides @pinia/testing for testing stores in isolation. createTestingPinia creates a fresh Pinia instance with optional initial state and auto-stubbed actions. You can test store getters with different state configurations, verify action side effects, and mock API calls without touching real backends.

// stores/members.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useMembersStore } from '@/stores/members';
import { memberApi } from '@/api/members';

vi.mock('@/api/members');

describe('useMembersStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.clearAllMocks();
  });

  it('fetches members and updates state', async () => {
    const mockMembers = [{ id: '1', name: 'Ana', status: 'active' }];
    vi.mocked(memberApi.list).mockResolvedValue(mockMembers);

    const store = useMembersStore();
    await store.fetchMembers();

    expect(store.members).toEqual(mockMembers);
    expect(store.loading).toBe(false);
    expect(store.error).toBeNull();
  });

  it('computes active members correctly', () => {
    const store = useMembersStore();
    store.$patch({
      members: [
        { id: '1', status: 'active' },
        { id: '2', status: 'expired' },
        { id: '3', status: 'active' },
      ],
    });

    expect(store.activeMembers).toHaveLength(2);
  });
});
In production, webgym-spa had 85% test coverage across composables and stores, with Vitest running the full suite in under 3 seconds. Component tests focused on user-facing behavior (clicking, form submission, displayed text) rather than internal state. The fast feedback loop meant developers ran tests on every save, catching regressions before they reached code review.

8. Vue DevTools

Vue DevTools is a browser extension (Chrome, Firefox, Edge) and a standalone Electron app for debugging Vue applications. It provides a component tree inspector, reactive state viewer, Pinia store debugger, Vue Router timeline, performance profiler, and event tracker. In Nuxt 3, an integrated DevTools panel (Nuxt DevTools) adds server route inspection, module management, and auto-import analysis.

Key Features

Component Inspector: Select any component in the tree to view its props, reactive state (refs, reactive objects), computed values, emitted events, and slot content in real time. Edit reactive values directly in the panel to test different states without changing code.

Pinia Integration: View all active stores, their current state, and a timeline of every action dispatched. Time-travel debugging lets you step backward and forward through state mutations, making it easy to trace bugs to the specific action that caused them.

Router and Timeline: The timeline tab shows every navigation event, lifecycle hook, and performance metric. Filter by component or event type to isolate specific behaviors. The router panel displays all registered routes, their params, and middleware chain.

Nuxt DevTools

Nuxt 3 ships with its own DevTools layer that opens as an in-app panel (Shift+Alt+D). It shows all auto-imported composables, active modules, server API routes, runtime config, and the application's component graph. The built-in VS Code integration lets you click a component in the panel and jump directly to its source file.

// nuxt.config.ts - Enable Nuxt DevTools
export default defineNuxtConfig({
  devtools: {
    enabled: true,       // Enable in development
    timeline: {
      enabled: true,     // Track component events in timeline
    },
  },
});
Vue DevTools is not just for debugging; it is an essential development workflow tool. Use the performance tab to identify unnecessary re-renders, the component inspector to verify prop flow, and the Pinia debugger to validate state transitions. The time-travel feature is particularly valuable when tracking down state bugs in complex multi-store applications.

9. Vite: The Build Tool

Vite is the default build tool for Vue 3 and Nuxt 3. It uses native ES modules during development for instant server start (no bundling), and Rollup for optimized production builds. Hot Module Replacement (HMR) reflects changes in under 50ms regardless of application size, a dramatic improvement over Webpack's full-bundle rebuild approach.

Configuration

// vite.config.ts - Vue 3 project configuration
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';

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

  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },

  build: {
    target: 'es2020',
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          'vendor-ui': ['@headlessui/vue', '@heroicons/vue'],
        },
      },
    },
  },
});

Key Advantages

Instant Dev Server: Vite serves source files as native ES modules. The browser requests each module individually, and Vite transforms them on-demand. Only the modules you actually import are processed, making startup time nearly constant regardless of application size.

Optimized Production Builds: Vite uses Rollup under the hood for production, applying tree-shaking, code-splitting, CSS extraction, and asset hashing. The manualChunks option lets you control vendor bundle splitting for optimal caching. Dynamic imports (() => import('./MyComponent.vue')) create automatic code-split points.

Plugin Ecosystem: Vite's plugin API is compatible with Rollup plugins, giving access to a vast ecosystem. Key plugins for Vue projects include @vitejs/plugin-vue (SFC support), unplugin-auto-import (auto-import Vue APIs), unplugin-vue-components (auto-register components), and vite-plugin-pwa (Progressive Web App support).

In Nuxt 3, Vite is the default bundler and is preconfigured by the framework. The Nuxt config extends Vite's config, so you rarely need a separate vite.config.ts. Use vite key in nuxt.config.ts to customize Vite-specific options like plugins, CSS preprocessor settings, and optimization overrides.

10. Vue 3.5, Vapor Mode, and Nuxt 4

Vue 3.5: Performance and DX Improvements

Vue 3.5 (stable) introduces Reactive Props Destructuring: props can be destructured directly in <script setup> while retaining reactivity, eliminating the need for toRefs(). New composables include useTemplateRef() for type-safe template refs and useId() for SSR-safe unique IDs. The <Teleport> component gains a defer prop for deferred rendering. Under the hood, Vue 3.5 achieves 56% memory savings for the reactivity system and up to 10x speed improvements for large reactive arrays and objects.

<script setup lang="ts">
// Vue 3.5: Reactive Props Destructuring (no toRefs needed)
const { name, status = 'active' } = defineProps<{
  name: string;
  status?: string;
}>();

// name and status are reactive — they track in computed/watch
const displayName = computed(() => `${name} (${status})`);

// Vue 3.5: useTemplateRef for type-safe refs
const inputRef = useTemplateRef<HTMLInputElement>('searchInput');

// Vue 3.5: useId for SSR-safe unique IDs
const id = useId(); // e.g., "v-0", "v-1" — consistent across server and client
</script>

<template>
  <label :for="id">Search</label>
  <input :id="id" ref="searchInput" />
  <p>{{ displayName }}</p>

  <!-- Vue 3.5: Deferred Teleport — waits for target to exist -->
  <Teleport to="#modal-container" defer>
    <Modal v-if="showModal" />
  </Teleport>
</template>

Vue 3.6 / Vapor Mode (Beta Dec 2025)

Vapor Mode (beta December 2025) is Vue's most significant performance advancement. It compiles templates directly to DOM operations, bypassing the Virtual DOM entirely. Performance benchmarks match Solid.js. Vapor Mode is opt-in at the component level, so VDOM and Vapor components can coexist in the same tree. The Alien Signals reactivity engine, adopted in Vue 3.6, is substantially faster than the previous Proxy-based system.

// Vue 3.6 Vapor Mode: opt-in per component
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue({
      vapor: true, // Enable Vapor compilation for opted-in components
    }),
  ],
});

// MemberCard.vapor.vue — compiled to direct DOM operations
<script setup lang="ts">
const { name, status } = defineProps<{ name: string; status: string }>();
const isActive = computed(() => status === 'active');
</script>

<template>
  <!-- No Virtual DOM diffing — direct DOM updates when signals change -->
  <div :class="{ active: isActive }">
    <h3>{{ name }}</h3>
    <span>{{ status }}</span>
  </div>
</template>

Nuxt 4 (Stable July 2025)

Nuxt 4 (stable July 2025) introduces a new app/ directory structure that separates application code from configuration. Shared data fetching avoids redundant server requests across layouts and pages. TypeScript support is improved with stricter type checking and better auto-completion. The CLI is significantly faster, and migration from Nuxt 3 is straightforward with a compatibility layer and automated codemods.

# Nuxt 4: New directory structure
my-app/
  app/                    # Application code (new in Nuxt 4)
    components/
    composables/
    layouts/
    middleware/
    pages/
    plugins/
    app.vue
  server/                 # Server routes and middleware
  shared/                 # Shared types and utils (app + server)
  nuxt.config.ts
  package.json

# Migration from Nuxt 3
npx nuxi upgrade --force  # Update to Nuxt 4
npx nuxi migrate          # Run automated codemods
Vue 3.5 is a drop-in upgrade with significant performance wins. Vapor Mode (3.6 beta) should be evaluated for performance-critical components like large lists and data tables. Nuxt 4 migration from Nuxt 3 is low-friction thanks to the compatibility layer. For new projects, start with Nuxt 4 and Vue 3.5; selectively adopt Vapor Mode for hot paths once it reaches stable.

Latest Updates (April 2026)

Vue 3.5: useTemplateRef, useId, and Reactivity Optimizations

Vue 3.5 (maintenance at v3.5.26) introduced useTemplateRef(), which obtains template refs via runtime string IDs instead of compile-time variable name matching. This enables dynamic ref bindings to changing IDs and works directly inside composables, returning a typed read-only ShallowRef. The new useId() generates unique IDs for accessibility attributes (similar to React's useId). Vue 3.5 also delivered major reactivity optimizations: memory usage for reactive objects dropped 56%, and the reactivity system was restructured to reduce re-computations for deeply nested reactive structures.

Vue 3.6 Vapor Mode: Beta With Full Feature Parity

Vue 3.6.0-beta.1 shipped in late 2025 with Vapor Mode achieving full feature parity with all stable Virtual DOM mode features (excluding Suspense). Vapor Mode compiles templates directly to DOM operations, bypassing the Virtual DOM entirely and matching Solid.js-level performance. The Alien Signals reactivity engine powering Vue 3.6 is substantially faster than the previous Proxy-based system. Vapor Mode is opt-in per component (.vapor.vue files), so VDOM and Vapor components coexist seamlessly. A stable release is projected for Q4 2026, with Transition and KeepAlive support expected in Q3.

Nuxt 4.2 and Ecosystem Maturity

Nuxt 4 (stable since July 2025) has reached v4.2.2, bringing a cleaner app/ directory structure, smarter built-in data fetching, improved TypeScript support, and a faster CLI. The Nuxt ecosystem in 2026 is stable and mature, with Nitro 3 (the server engine) being finalized for Nuxt 5. Vue 3.5 remains in maintenance (v3.5.26), providing a solid production foundation while Vapor Mode matures in the 3.6 beta.

For production projects today, use Vue 3.5 with Nuxt 4.2 as the stable baseline. Evaluate Vapor Mode in Vue 3.6 beta for performance-critical components like large data tables and virtualized lists. Once Vapor Mode reaches stable in Q4 2026, it will become the recommended rendering strategy for hot paths. New projects should start with Nuxt 4 and plan for Vapor adoption.

More Guides