Claude Code for Vue.js: Composition API, Pinia, and Vue 3 Patterns — Claude Skills 360 Blog
Blog / Development / Claude Code for Vue.js: Composition API, Pinia, and Vue 3 Patterns
Development

Claude Code for Vue.js: Composition API, Pinia, and Vue 3 Patterns

Published: June 7, 2026
Read time: 9 min read
By: Claude Skills 360

Vue 3’s Composition API with <script setup> is a significant improvement over the Options API in terms of TypeScript integration and code reuse. Claude Code generates Vue 3 code in the modern pattern — <script setup>, defineProps/defineEmits macros, composables instead of mixins, and Pinia for state management.

This guide covers Vue.js 3 with Claude Code: Composition API patterns, Pinia stores, Vue Router 4, composables, and component testing.

Project Setup and CLAUDE.md

Set up a new Vue 3 project with TypeScript, Pinia, Vue Router 4,
and Vitest for testing. Non-negotiable: Composition API with script setup.

Claude Code runs npm create vue@latest with these options and configures:

## Vue 3 Project Conventions
- Vue 3.4+ with Composition API + <script setup> — no Options API
- TypeScript strict mode — all props/emits typed
- State: Pinia stores in src/stores/{resource}.ts
- Router: src/router/index.ts — route-level code splitting default
- Composables: src/composables/use{Name}.ts
- Component naming: PascalCase files, multi-word names (no single-word)
- Props: defineProps<{ propName: Type }>() — no string arrays
- Emits: defineEmits<{ eventName: [args] }>() — typed
- Testing: Vitest + @vue/test-utils, component tests in *.test.ts next to component
- No Vuex — use Pinia only

Composition API Patterns

Create a UserProfile component that fetches user data,
handles loading/error states, and lets the user edit their profile.
<!-- src/components/UserProfile.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { useNotifications } from '@/composables/useNotifications'

interface Props {
  userId: string
  readonly?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  readonly: false,
})

const emit = defineEmits<{
  updated: [userId: string]
  cancelled: []
}>()

const userStore = useUserStore()
const { notify } = useNotifications()

const isEditing = ref(false)
const isSaving = ref(false)

// Computed from store — reactive
const user = computed(() => userStore.getById(props.userId))
const isLoading = computed(() => userStore.isLoadingUser(props.userId))

// Local form state — separate from store state
const form = ref({
  name: '',
  email: '',
  bio: '',
})

async function startEditing() {
  if (!user.value) return
  form.value = {
    name: user.value.name,
    email: user.value.email,
    bio: user.value.bio ?? '',
  }
  isEditing.value = true
}

async function saveProfile() {
  isSaving.value = true
  try {
    await userStore.updateUser(props.userId, form.value)
    isEditing.value = false
    notify({ type: 'success', message: 'Profile updated' })
    emit('updated', props.userId)
  } catch (error) {
    notify({ type: 'error', message: 'Failed to update profile' })
  } finally {
    isSaving.value = false
  }
}

function cancel() {
  isEditing.value = false
  emit('cancelled')
}

// Fetch user data on mount if not already in store
if (!user.value) {
  userStore.fetchUser(props.userId)
}
</script>

<template>
  <div class="user-profile">
    <div v-if="isLoading" class="skeleton-loader" aria-busy="true" />
    
    <template v-else-if="user">
      <div v-if="!isEditing">
        <h2>{{ user.name }}</h2>
        <p>{{ user.email }}</p>
        <p v-if="user.bio">{{ user.bio }}</p>
        
        <button v-if="!readonly" @click="startEditing">Edit Profile</button>
      </div>
      
      <form v-else @submit.prevent="saveProfile">
        <label>
          Name
          <input v-model="form.name" type="text" required />
        </label>
        
        <label>
          Email
          <input v-model="form.email" type="email" required />
        </label>
        
        <label>
          Bio
          <textarea v-model="form.bio" rows="3" />
        </label>
        
        <div class="actions">
          <button type="submit" :disabled="isSaving">
            {{ isSaving ? 'Saving...' : 'Save' }}
          </button>
          <button type="button" @click="cancel">Cancel</button>
        </div>
      </form>
    </template>
    
    <p v-else>User not found.</p>
  </div>
</template>

Pinia Stores

Create a Pinia store for authentication.
It should handle: login, logout, token refresh, and persist the session.
// src/stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useRouter } from 'vue-router'

interface User {
  id: string
  email: string
  name: string
  role: 'admin' | 'user'
}

export const useAuthStore = defineStore('auth', () => {
  const router = useRouter()
  
  // State
  const user = ref<User | null>(null)
  const accessToken = ref<string | null>(localStorage.getItem('access_token'))
  const isLoading = ref(false)
  
  // Getters (computed)
  const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // Actions
  async function login(email: string, password: string) {
    isLoading.value = true
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      
      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.message ?? 'Login failed')
      }
      
      const data = await response.json()
      
      accessToken.value = data.accessToken
      user.value = data.user
      
      // Persist token
      localStorage.setItem('access_token', data.accessToken)
      
      await router.push('/dashboard')
      
    } finally {
      isLoading.value = false
    }
  }
  
  async function logout() {
    // Invalidate server session
    await fetch('/api/auth/logout', {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken.value}` },
    }).catch(() => {}) // Don't fail if server is unavailable
    
    accessToken.value = null
    user.value = null
    localStorage.removeItem('access_token')
    
    await router.push('/login')
  }
  
  async function fetchCurrentUser() {
    if (!accessToken.value) return
    
    try {
      const response = await fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${accessToken.value}` },
      })
      
      if (response.status === 401) {
        // Token expired
        await logout()
        return
      }
      
      user.value = await response.json()
    } catch {
      await logout()
    }
  }
  
  // Initialize on store creation
  if (accessToken.value) {
    fetchCurrentUser()
  }
  
  return {
    user, accessToken, isLoading,
    isAuthenticated, isAdmin,
    login, logout, fetchCurrentUser,
  }
})

Composables for Reusable Logic

Create a composable for paginated API requests.
It should handle loading state, error handling, and page navigation.
// src/composables/usePagination.ts
import { ref, computed, watch } from 'vue'

interface PaginationOptions<T> {
  fetcher: (page: number, pageSize: number) => Promise<{ data: T[]; total: number }>
  pageSize?: number
  initialPage?: number
}

export function usePagination<T>({
  fetcher,
  pageSize = 20,
  initialPage = 1,
}: PaginationOptions<T>) {
  const data = ref<T[]>([]) as Ref<T[]>
  const total = ref(0)
  const currentPage = ref(initialPage)
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  
  const totalPages = computed(() => Math.ceil(total.value / pageSize))
  const hasNextPage = computed(() => currentPage.value < totalPages.value)
  const hasPrevPage = computed(() => currentPage.value > 1)
  
  async function fetchPage(page: number) {
    isLoading.value = true
    error.value = null
    
    try {
      const result = await fetcher(page, pageSize)
      data.value = result.data
      total.value = result.total
      currentPage.value = page
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to load data'
    } finally {
      isLoading.value = false
    }
  }
  
  const nextPage = () => hasNextPage.value && fetchPage(currentPage.value + 1)
  const prevPage = () => hasPrevPage.value && fetchPage(currentPage.value - 1)
  const goToPage = (page: number) => fetchPage(Math.max(1, Math.min(page, totalPages.value)))
  
  // Initial fetch
  fetchPage(initialPage)
  
  return {
    data, total, currentPage, totalPages,
    isLoading, error,
    hasNextPage, hasPrevPage,
    nextPage, prevPage, goToPage, refresh: () => fetchPage(currentPage.value),
  }
}
<!-- Usage in a component -->
<script setup lang="ts">
import { usePagination } from '@/composables/usePagination'
import { api } from '@/lib/api'

const { data: users, isLoading, currentPage, totalPages, nextPage, prevPage } = usePagination({
  fetcher: (page, size) => api.get(`/users?page=${page}&pageSize=${size}`),
  pageSize: 25,
})
</script>

Vue Router 4 Guards

Protect routes so unauthenticated users redirect to login.
Admins-only routes should redirect non-admins to a 403 page.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: () => import('@/views/LoginView.vue') },
    {
      path: '/dashboard',
      component: () => import('@/views/DashboardView.vue'),
      meta: { requiresAuth: true },
    },
    {
      path: '/admin',
      component: () => import('@/views/AdminView.vue'),
      meta: { requiresAuth: true, requiresAdmin: true },
    },
    { path: '/403', component: () => import('@/views/ForbiddenView.vue') },
    { path: '/:pathMatch(.*)*', redirect: '/' },
  ],
})

router.beforeEach(async (to) => {
  const auth = useAuthStore()
  
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
  
  if (to.meta.requiresAdmin && !auth.isAdmin) {
    return { path: '/403' }
  }
})

export default router

Testing Vue Components

Write tests for the UserProfile component.
Cover: loading state, successful display, edit form, and save error.
// src/components/UserProfile.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import UserProfile from './UserProfile.vue'
import { useUserStore } from '@/stores/user'

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

  it('shows loading skeleton while fetching', () => {
    const store = useUserStore()
    store.isLoadingUser = () => true
    
    const wrapper = mount(UserProfile, { props: { userId: '123' } })
    
    expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
    expect(wrapper.find('h2').exists()).toBe(false)
  })

  it('displays user data when loaded', async () => {
    const store = useUserStore()
    store.setUser({ id: '123', name: 'Alice', email: '[email protected]', bio: 'Developer' })
    
    const wrapper = mount(UserProfile, { props: { userId: '123' } })
    
    expect(wrapper.find('h2').text()).toBe('Alice')
    expect(wrapper.text()).toContain('[email protected]')
  })

  it('shows edit form when edit button clicked', async () => {
    const store = useUserStore()
    store.setUser({ id: '123', name: 'Alice', email: '[email protected]' })
    
    const wrapper = mount(UserProfile, { props: { userId: '123' } })
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.find('form').exists()).toBe(true)
    expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe('Alice')
  })
})

For integrating Vue with a backend API including authentication flows, see the authentication guide. For testing patterns including component tests and integration tests, see the testing and debugging guide. The Claude Skills 360 bundle includes Vue.js skill sets for Composition API patterns and Pinia state management. Start with the free tier to try Vue component generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free