Vue 3’s Composition API with <script setup> provides fine-grained reactivity, better TypeScript integration, and shareable composables. Pinia replaces Vuex with a simpler, TypeScript-first store design. Nuxt 4 adds file-based server-side rendering, server routes (/server/api), and auto-imports. Claude Code generates Vue components with the Composition API, Pinia stores, custom composables, and the Nuxt server routes that power full-stack Vue apps.
CLAUDE.md for Vue 3 Projects
## Vue 3 Stack
- Vue 3 + TypeScript with <script setup> syntax (no Options API)
- State: Pinia (not Vuex) — one store per domain
- Router: Vue Router 4 with typed routes
- Build: Vite 6 with @vitejs/plugin-vue
- Testing: Vitest + Vue Test Utils 2 (@vue/test-utils)
- Framework: Nuxt 4 for full-stack (or Vite-only SPA)
- CSS: Tailwind + scoped styles in single-file components
- Forms: VeeValidate v4 + Zod
- HTTP: ofetch (Nuxt built-in) or axios for SPA
Vue Component with script setup
<!-- components/OrderCard.vue — fully typed Composition API component -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/orders'
import { formatCurrency, formatDate } from '@/utils/formatters'
interface Order {
id: string
customerName: string
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'
totalCents: number
createdAt: string
}
const props = defineProps<{
order: Order
showActions?: boolean
}>()
const emit = defineEmits<{
cancel: [orderId: string]
view: [orderId: string]
}>()
const router = useRouter()
const orderStore = useOrderStore()
// Computed properties
const statusColor = computed(() => ({
pending: 'bg-yellow-100 text-yellow-800',
processing: 'bg-blue-100 text-blue-800',
shipped: 'bg-purple-100 text-purple-800',
delivered: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
}[props.order.status]))
const isCancellable = computed(() =>
['pending'].includes(props.order.status)
)
const formattedTotal = computed(() =>
formatCurrency(props.order.totalCents)
)
// Methods
const handleView = () => {
router.push({ name: 'order-detail', params: { id: props.order.id } })
emit('view', props.order.id)
}
const handleCancel = async () => {
await orderStore.cancelOrder(props.order.id)
emit('cancel', props.order.id)
}
</script>
<template>
<div class="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-gray-900">#{{ order.id.slice(-6) }}</h3>
<p class="text-sm text-gray-600">{{ order.customerName }}</p>
<p class="text-xs text-gray-400 mt-1">{{ formatDate(order.createdAt) }}</p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-900">{{ formattedTotal }}</p>
<span :class="['text-xs px-2 py-1 rounded-full mt-1 inline-block', statusColor]">
{{ order.status }}
</span>
</div>
</div>
<div v-if="showActions" class="mt-3 flex gap-2">
<button
@click="handleView"
class="btn btn-secondary btn-sm"
>
View Details
</button>
<button
v-if="isCancellable"
@click="handleCancel"
class="btn btn-danger btn-sm"
:disabled="orderStore.cancellingIds.has(order.id)"
>
{{ orderStore.cancellingIds.has(order.id) ? 'Cancelling...' : 'Cancel' }}
</button>
</div>
</div>
</template>
Pinia Store
// stores/orders.ts — Pinia store with TypeScript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Order, CreateOrderInput } from '@/types'
export const useOrderStore = defineStore('orders', () => {
// State
const orders = ref<Order[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const cancellingIds = ref<Set<string>>(new Set())
// Getters (computed)
const pendingOrders = computed(() =>
orders.value.filter(o => o.status === 'pending')
)
const orderById = computed(() =>
(id: string) => orders.value.find(o => o.id === id)
)
const totalRevenue = computed(() =>
orders.value
.filter(o => o.status === 'delivered')
.reduce((sum, o) => sum + o.totalCents, 0)
)
// Actions
async function fetchOrders(params?: { status?: string; query?: string }) {
isLoading.value = true
error.value = null
try {
const data = await $fetch<Order[]>('/api/orders', { query: params })
orders.value = data
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function createOrder(input: CreateOrderInput): Promise<Order> {
const order = await $fetch<Order>('/api/orders', {
method: 'POST',
body: input,
})
orders.value.unshift(order)
return order
}
async function cancelOrder(id: string): Promise<void> {
cancellingIds.value.add(id)
try {
const updated = await $fetch<Order>(`/api/orders/${id}/cancel`, {
method: 'POST',
})
const index = orders.value.findIndex(o => o.id === id)
if (index !== -1) {
orders.value[index] = updated
}
} finally {
cancellingIds.value.delete(id)
}
}
return {
// State
orders,
isLoading,
error,
cancellingIds,
// Getters
pendingOrders,
orderById,
totalRevenue,
// Actions
fetchOrders,
createOrder,
cancelOrder,
}
})
Composable for Data Fetching
// composables/useOrders.ts — reusable fetch composable
import { ref, watch, toValue, type MaybeRefOrGetter } from 'vue'
import type { Order } from '@/types'
export function useOrders(initialQuery: MaybeRefOrGetter<string> = '') {
const orders = ref<Order[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const fetch = async (query: string) => {
isLoading.value = true
error.value = null
try {
const data = await $fetch<Order[]>('/api/orders', {
query: { q: query },
})
orders.value = data
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
// Watch for query changes and re-fetch
watch(
() => toValue(initialQuery),
(q) => fetch(q),
{ immediate: true }
)
return { orders, isLoading, error, refetch: () => fetch(toValue(initialQuery)) }
}
// Usage in component:
// const searchQuery = ref('')
// const { orders, isLoading } = useOrders(searchQuery)
// Both `searchQuery` as a ref or a plain string work
Nuxt 4 Page + Server Route
<!-- pages/orders/index.vue — Nuxt 4 file-based routing -->
<script setup lang="ts">
import { ref, computed } from 'vue'
// Nuxt 4 composables — auto-imported
const { data: orders, refresh, pending } = await useFetch('/api/orders', {
query: { page: 1 },
})
const searchQuery = ref('')
// useAsyncData for conditional fetch
const { data: searchResults } = await useAsyncData(
'order-search',
() => $fetch('/api/orders/search', { query: { q: searchQuery.value } }),
{ watch: [searchQuery] }
)
const displayedOrders = computed(() =>
searchQuery.value ? (searchResults.value ?? []) : (orders.value ?? [])
)
useSeoMeta({
title: 'Orders',
description: 'Manage your orders',
})
</script>
<template>
<div>
<h1>Orders</h1>
<input
v-model="searchQuery"
placeholder="Search orders..."
class="search-input"
/>
<div v-if="pending">Loading...</div>
<div v-else-if="displayedOrders.length === 0">
No orders found.
</div>
<div v-else class="grid gap-4">
<OrderCard
v-for="order in displayedOrders"
:key="order.id"
:order="order"
:show-actions="true"
/>
</div>
<button @click="refresh">Refresh</button>
</div>
</template>
// server/api/orders/index.get.ts — Nuxt server route
import { defineEventHandler, getQuery } from 'h3'
import { z } from 'zod'
import { db } from '~/server/lib/db'
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.string().optional(),
})
export default defineEventHandler(async (event) => {
const query = QuerySchema.parse(getQuery(event))
// Check auth
const user = event.context.user
if (!user) throw createError({ statusCode: 401 })
const orders = await db.orders.list({
userId: user.id,
...query,
})
return orders
})
Component Testing with Vitest
// components/__tests__/OrderCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import OrderCard from '../OrderCard.vue'
const mockOrder = {
id: 'ord_123',
customerName: 'Alice Smith',
status: 'pending' as const,
totalCents: 4999,
createdAt: '2026-12-13T10:00:00Z',
}
describe('OrderCard', () => {
beforeEach(() => setActivePinia(createPinia()))
it('renders order details', () => {
const wrapper = mount(OrderCard, {
props: { order: mockOrder },
})
expect(wrapper.text()).toContain('ord_123'.slice(-6))
expect(wrapper.text()).toContain('Alice Smith')
expect(wrapper.text()).toContain('$49.99')
})
it('shows cancel button for pending orders', () => {
const wrapper = mount(OrderCard, {
props: { order: mockOrder, showActions: true },
})
expect(wrapper.find('[data-testid="cancel-btn"]').exists()).toBe(true)
})
it('emits cancel event on click', async () => {
const wrapper = mount(OrderCard, {
props: { order: mockOrder, showActions: true },
})
await wrapper.find('[data-testid="cancel-btn"]').trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('cancel')![0]).toEqual(['ord_123'])
})
})
For the Nuxt 4 advanced patterns including middleware and plugins, see the Nuxt guide for full-stack Nuxt development. For the HTMX and Alpine.js alternative for simpler server-rendered interactivity, the HTMX guide covers the hypermedia approach. The Claude Skills 360 bundle includes Vue 3 skill sets covering Composition API, Pinia stores, and Nuxt server routes. Start with the free tier to try Vue component generation.