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.