Vue.js & Nuxt Integration Guide
This guide covers integrating SpecJet with Vue.js and Nuxt applications, including setup, configuration, and composable patterns.
Quick Setup
1. Install SpecJet
# In your Vue/Nuxt project
npm install --save-dev specjet
# Or globally
npm install -g specjet
2. Initialize SpecJet
# Initialize in your existing project
specjet init .
# This creates:
# ├── api-contract.yaml # Your API contract
# ├── specjet.config.js # Configuration
# └── src/ (or project root)
# ├── types/ # Generated TypeScript types
# ├── composables/ # Generated API composables
# └── mocks/ # Generated mock server
3. Configure for Vue/Nuxt
Update specjet.config.js for Vue conventions:
export default {
contract: './api-contract.yaml',
output: {
types: './types', // Nuxt auto-imports from here
client: './composables' // Vue composables directory
},
typescript: {
strictMode: true,
exportType: 'named',
clientName: 'useApi' // Vue-style naming
},
mock: {
port: 3001, // Avoid conflict with Nuxt dev server
cors: {
origin: ['http://localhost:3000'], // Nuxt dev server default
credentials: true
},
scenario: 'realistic'
}
};
4. Add Package.json Scripts
{
"scripts": {
"dev": "nuxt dev",
"api:generate": "specjet generate",
"api:watch": "specjet generate --watch",
"api:mock": "specjet mock",
"dev:with-mock": "concurrently \"npm run dev\" \"npm run api:mock\""
},
"devDependencies": {
"concurrently": "^7.6.0",
"specjet": "^0.1.0"
}
}
Vue 3 Composition API Integration
Basic API Composable
Create reusable composables for API calls:
// composables/useApi.ts
import { ref, computed } from 'vue';
import { ApiClient } from './client';
import type { User, CreateUserRequest } from '~/types/api';
// Create API client instance
const apiClient = new ApiClient(
process.env.NODE_ENV === 'development'
? 'http://localhost:3001'
: 'https://api.myapp.com'
);
// Global API state (optional)
const globalState = {
loading: ref(false),
error: ref<string | null>(null)
};
export const useApi = () => {
return {
client: apiClient,
globalLoading: globalState.loading,
globalError: globalState.error
};
};
export const useUsers = () => {
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchUsers = async () => {
try {
loading.value = true;
error.value = null;
const data = await apiClient.getUsers();
users.value = data;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch users';
users.value = [];
} finally {
loading.value = false;
}
};
const createUser = async (userData: CreateUserRequest): Promise<User | null> => {
try {
error.value = null;
const newUser = await apiClient.createUser(userData);
users.value.push(newUser);
return newUser;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create user';
return null;
}
};
const updateUser = async (id: number, userData: Partial<User>): Promise<User | null> => {
try {
error.value = null;
const updatedUser = await apiClient.updateUser(id, userData);
const index = users.value.findIndex(user => user.id === id);
if (index !== -1) {
users.value[index] = updatedUser;
}
return updatedUser;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update user';
return null;
}
};
const deleteUser = async (id: number): Promise<boolean> => {
try {
error.value = null;
await apiClient.deleteUser(id);
users.value = users.value.filter(user => user.id !== id);
return true;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete user';
return false;
}
};
// Computed properties
const activeUsers = computed(() =>
users.value.filter(user => user.isActive)
);
const userCount = computed(() => users.value.length);
return {
// State
users: readonly(users),
loading: readonly(loading),
error: readonly(error),
// Actions
fetchUsers,
createUser,
updateUser,
deleteUser,
// Computed
activeUsers,
userCount,
// Utilities
refreshUsers: fetchUsers,
clearError: () => { error.value = null; }
};
};
export const useUser = (id: MaybeRef<number>) => {
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchUser = async () => {
const userId = unref(id);
if (!userId) return;
try {
loading.value = true;
error.value = null;
const data = await apiClient.getUserById(userId);
user.value = data;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch user';
user.value = null;
} finally {
loading.value = false;
}
};
// Watch for ID changes
watch(() => unref(id), fetchUser, { immediate: true });
return {
user: readonly(user),
loading: readonly(loading),
error: readonly(error),
fetchUser,
clearError: () => { error.value = null; }
};
};
Using Composables in Vue Components
<!-- components/UserList.vue -->
<template>
<div class="user-list">
<h2>Users</h2>
<!-- Loading state -->
<div v-if="loading" class="loading">
Loading users...
</div>
<!-- Error state -->
<div v-else-if="error" class="error">
<p>Error: </p>
<button @click="refreshUsers">Retry</button>
</div>
<!-- Users list -->
<div v-else>
<div class="actions">
<button @click="showCreateForm = true">Add User</button>
<button @click="refreshUsers">Refresh</button>
<p>Total users: ( active)</p>
</div>
<div class="users">
<div
v-for="user in users"
:key="user.id"
class="user-card"
:class="{ inactive: !user.isActive }"
>
<h3></h3>
<p></p>
<div class="user-actions">
<button @click="editUser(user)">Edit</button>
<button @click="confirmDelete(user.id)" class="danger">Delete</button>
</div>
</div>
</div>
</div>
<!-- Create user form -->
<UserCreateForm
v-if="showCreateForm"
@created="handleUserCreated"
@cancel="showCreateForm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useUsers } from '~/composables/useApi';
import type { User } from '~/types/api';
const {
users,
loading,
error,
fetchUsers,
deleteUser,
activeUsers,
userCount,
refreshUsers
} = useUsers();
const showCreateForm = ref(false);
// Fetch users on component mount
onMounted(() => {
fetchUsers();
});
const handleUserCreated = (user: User) => {
showCreateForm.value = false;
// User is automatically added to the list by the composable
};
const editUser = (user: User) => {
// Navigate to edit form or open modal
console.log('Edit user:', user);
};
const confirmDelete = async (id: number) => {
if (confirm('Are you sure you want to delete this user?')) {
await deleteUser(id);
}
};
</script>
<style scoped>
.user-list {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.loading, .error {
text-align: center;
padding: 20px;
}
.error {
color: red;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.users {
display: grid;
gap: 15px;
}
.user-card {
border: 1px solid #ddd;
padding: 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-card.inactive {
opacity: 0.6;
}
.user-actions {
display: flex;
gap: 10px;
}
.danger {
background-color: #ff4757;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
</style>
Create User Form Component
<!-- components/UserCreateForm.vue -->
<template>
<div class="modal-overlay" @click.self="$emit('cancel')">
<div class="modal-content">
<h3>Create New User</h3>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="name">Name:</label>
<input
id="name"
v-model="form.name"
type="text"
required
:disabled="submitting"
/>
</div>
<div class="field">
<label for="email">Email:</label>
<input
id="email"
v-model="form.email"
type="email"
required
:disabled="submitting"
/>
</div>
<div class="field">
<label>
<input
v-model="form.isActive"
type="checkbox"
:disabled="submitting"
/>
Active
</label>
</div>
<div v-if="error" class="error">
</div>
<div class="actions">
<button type="button" @click="$emit('cancel')" :disabled="submitting">
Cancel
</button>
<button type="submit" :disabled="submitting">
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useUsers } from '~/composables/useApi';
import type { CreateUserRequest, User } from '~/types/api';
const emit = defineEmits<{
created: [user: User];
cancel: [];
}>();
const { createUser } = useUsers();
const form = reactive<CreateUserRequest>({
name: '',
email: '',
isActive: true
});
const submitting = ref(false);
const error = ref<string | null>(null);
const handleSubmit = async () => {
try {
submitting.value = true;
error.value = null;
const newUser = await createUser(form);
if (newUser) {
emit('created', newUser);
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create user';
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 400px;
width: 90%;
}
.field {
margin-bottom: 15px;
}
.field label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.field input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.error {
color: red;
margin-bottom: 15px;
}
.actions {
display: flex;
justify-content: space-between;
gap: 10px;
}
.actions button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.actions button[type="submit"] {
background-color: #007bff;
color: white;
}
.actions button[type="button"] {
background-color: #6c757d;
color: white;
}
</style>
Nuxt 3 Specific Integration
Nuxt Configuration
// nuxt.config.ts
export default defineNuxtConfig({
// Auto-import composables from SpecJet
imports: {
dirs: [
'composables',
'types'
]
},
// TypeScript configuration
typescript: {
strict: true,
typeCheck: true
},
// Runtime config for API URLs
runtimeConfig: {
public: {
apiUrl: process.env.NUXT_PUBLIC_API_URL || 'http://localhost:3001'
}
},
// Development server configuration
devServer: {
port: 3000
}
});
Environment Configuration
# .env
NUXT_PUBLIC_API_URL=http://localhost:3001
# .env.production
NUXT_PUBLIC_API_URL=https://api.myapp.com
Nuxt Composables with Runtime Config
// composables/useApi.ts (Nuxt version)
export const useApi = () => {
const config = useRuntimeConfig();
// Create API client with runtime config
const client = new ApiClient(config.public.apiUrl as string);
return {
client,
baseUrl: config.public.apiUrl
};
};
export const useUsers = () => {
const { client } = useApi();
// Use Nuxt's built-in state management
const users = useState<User[]>('users', () => []);
const loading = useState('users.loading', () => false);
const error = useState<string | null>('users.error', () => null);
const fetchUsers = async () => {
try {
loading.value = true;
error.value = null;
const data = await client.getUsers();
users.value = data;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch users';
} finally {
loading.value = false;
}
};
const createUser = async (userData: CreateUserRequest) => {
try {
error.value = null;
const newUser = await client.createUser(userData);
users.value = [...users.value, newUser];
return newUser;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create user';
throw err;
}
};
// Other methods...
return {
users: readonly(users),
loading: readonly(loading),
error: readonly(error),
fetchUsers,
createUser
// ... other methods
};
};
Server-Side Rendering with Nuxt
<!-- pages/users/index.vue -->
<template>
<div>
<Head>
<Title>Users - My App</Title>
</Head>
<h1>Users</h1>
<div v-if="pending">Loading users...</div>
<div v-else-if="error">Error: </div>
<div v-else>
<UserList :users="data || []" />
</div>
</div>
</template>
<script setup lang="ts">
import type { User } from '~/types/api';
// Fetch data on server-side
const { data, pending, error, refresh } = await useLazyAsyncData('users', async () => {
const { client } = useApi();
return await client.getUsers();
});
// Provide data to components
provide('users', { data, pending, error, refresh });
</script>
Dynamic Routes with Type Safety
<!-- pages/users/[id].vue -->
<template>
<div>
<Head>
<Title> - My App</Title>
</Head>
<div v-if="pending">Loading user...</div>
<div v-else-if="error">
<h1>User Not Found</h1>
<p></p>
<NuxtLink to="/users">Back to Users</NuxtLink>
</div>
<div v-else-if="user">
<h1></h1>
<p>Email: </p>
<p>Status: </p>
<div class="actions">
<NuxtLink :to="`/users/${user.id}/edit`">Edit User</NuxtLink>
<button @click="deleteUser" class="danger">Delete User</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { User } from '~/types/api';
const route = useRoute();
const router = useRouter();
const userId = computed(() => Number(route.params.id));
// Fetch user data
const { data: user, pending, error } = await useLazyAsyncData(
`user-${userId.value}`,
async () => {
const { client } = useApi();
return await client.getUserById(userId.value);
}
);
const deleteUser = async () => {
if (!user.value || !confirm(`Delete user ${user.value.name}?`)) {
return;
}
try {
const { client } = useApi();
await client.deleteUser(user.value.id);
await router.push('/users');
} catch (err) {
alert('Failed to delete user');
}
};
// Handle 404 errors
if (error.value) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
});
}
</script>
Nuxt Server API Routes
// server/api/users.get.ts
import { ApiClient } from '~/composables/client';
import type { User } from '~/types/api';
export default defineEventHandler(async (event): Promise<User[]> => {
const config = useRuntimeConfig();
// Use real API in production, mock in development
const apiUrl = process.env.NODE_ENV === 'development'
? 'http://localhost:3001'
: config.public.apiUrl;
const client = new ApiClient(apiUrl);
try {
return await client.getUsers();
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch users'
});
}
});
// server/api/users.post.ts
import type { CreateUserRequest, User } from '~/types/api';
export default defineEventHandler(async (event): Promise<User> => {
const body = await readBody<CreateUserRequest>(event);
// Validate request body
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required'
});
}
const { client } = useApi();
try {
return await client.createUser(body);
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to create user'
});
}
});
Pinia Store Integration
For complex state management with Pinia:
// stores/users.ts
import { defineStore } from 'pinia';
import type { User, CreateUserRequest } from '~/types/api';
export const useUsersStore = defineStore('users', () => {
// State
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const activeUsers = computed(() =>
users.value.filter(user => user.isActive)
);
const getUserById = computed(() =>
(id: number) => users.value.find(user => user.id === id)
);
// Actions
const { client } = useApi();
const fetchUsers = async () => {
try {
loading.value = true;
error.value = null;
users.value = await client.getUsers();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch users';
throw err;
} finally {
loading.value = false;
}
};
const createUser = async (userData: CreateUserRequest) => {
try {
error.value = null;
const newUser = await client.createUser(userData);
users.value.push(newUser);
return newUser;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create user';
throw err;
}
};
const updateUser = async (id: number, userData: Partial<User>) => {
try {
error.value = null;
const updatedUser = await client.updateUser(id, userData);
const index = users.value.findIndex(user => user.id === id);
if (index !== -1) {
users.value[index] = updatedUser;
}
return updatedUser;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update user';
throw err;
}
};
const deleteUser = async (id: number) => {
try {
error.value = null;
await client.deleteUser(id);
users.value = users.value.filter(user => user.id !== id);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete user';
throw err;
}
};
return {
// State
users: readonly(users),
loading: readonly(loading),
error: readonly(error),
// Getters
activeUsers,
getUserById,
// Actions
fetchUsers,
createUser,
updateUser,
deleteUser
};
});
Using Pinia Store in Components
<!-- components/UserListWithStore.vue -->
<template>
<div>
<h2>Users ()</h2>
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error"></div>
<div v-else>
<div v-for="user in users" :key="user.id" class="user-card">
<h3></h3>
<p></p>
<button @click="() => deleteUser(user.id)">Delete</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const usersStore = useUsersStore();
const { users, loading, error } = storeToRefs(usersStore);
const { fetchUsers, deleteUser } = usersStore;
// Fetch users on mount
onMounted(() => {
fetchUsers();
});
</script>
Authentication with Vue
Auth Composable
// composables/useAuth.ts
import { jwtDecode } from 'jwt-decode';
import type { User } from '~/types/api';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
}
export const useAuth = () => {
const authState = useState<AuthState>('auth', () => ({
user: null,
token: null,
isAuthenticated: false
}));
const { client } = useApi();
const setToken = (token: string) => {
authState.value.token = token;
authState.value.isAuthenticated = true;
client.setBearerToken(token);
// Store in cookie for SSR
const tokenCookie = useCookie('auth-token', {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
tokenCookie.value = token;
};
const clearAuth = () => {
authState.value = {
user: null,
token: null,
isAuthenticated: false
};
client.setAuth(null);
const tokenCookie = useCookie('auth-token');
tokenCookie.value = null;
};
const login = async (email: string, password: string) => {
try {
const response = await client.login({ email, password });
setToken(response.token);
authState.value.user = response.user;
return response.user;
} catch (err) {
clearAuth();
throw err;
}
};
const logout = async () => {
try {
await client.logout();
} catch (err) {
// Continue with logout even if API call fails
console.warn('Logout API call failed:', err);
} finally {
clearAuth();
}
};
const getCurrentUser = async () => {
try {
const user = await client.getCurrentUser();
authState.value.user = user;
return user;
} catch (err) {
clearAuth();
throw err;
}
};
// Initialize auth from cookie on app start
const initAuth = async () => {
const tokenCookie = useCookie('auth-token');
const token = tokenCookie.value;
if (token) {
try {
// Validate token
const decoded = jwtDecode(token);
if (decoded.exp && decoded.exp > Date.now() / 1000) {
setToken(token);
await getCurrentUser();
} else {
clearAuth();
}
} catch (err) {
clearAuth();
}
}
};
return {
// State
user: readonly(authState.value.user),
token: readonly(authState.value.token),
isAuthenticated: readonly(authState.value.isAuthenticated),
// Actions
login,
logout,
getCurrentUser,
initAuth,
clearAuth
};
};
Auth Middleware
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return navigateTo('/login');
}
});
Protected Page
<!-- pages/dashboard.vue -->
<template>
<div>
<h1>Dashboard</h1>
<p>Welcome, !</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
});
const { user } = useAuth();
</script>
Development Workflow
Concurrent Development Setup
{
"scripts": {
"dev": "nuxt dev",
"api:generate": "specjet generate",
"api:watch": "specjet generate --watch",
"api:mock": "specjet mock",
"dev:full": "concurrently \"npm run api:mock\" \"npm run api:watch\" \"npm run dev\"",
"build": "npm run api:generate && nuxt build"
}
}
Auto-imports Configuration
// nuxt.config.ts
export default defineNuxtConfig({
imports: {
dirs: [
'composables/**',
'types/**',
'stores/**'
]
}
});
Testing
Component Testing with Vitest
// tests/components/UserList.test.ts
import { mount } from '@vue/test-utils';
import { describe, test, expect, vi } from 'vitest';
import UserList from '~/components/UserList.vue';
// Mock the API composable
vi.mock('~/composables/useApi', () => ({
useUsers: () => ({
users: ref([
{ id: 1, name: 'John Doe', email: 'john@example.com', isActive: true },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', isActive: false }
]),
loading: ref(false),
error: ref(null),
fetchUsers: vi.fn(),
createUser: vi.fn(),
deleteUser: vi.fn()
})
}));
describe('UserList', () => {
test('renders users correctly', () => {
const wrapper = mount(UserList);
expect(wrapper.text()).toContain('John Doe');
expect(wrapper.text()).toContain('Jane Smith');
expect(wrapper.findAll('.user-card')).toHaveLength(2);
});
});
E2E Testing with Playwright
// tests/e2e/users.spec.ts
import { test, expect } from '@playwright/test';
test('user management flow', async ({ page }) => {
// Start with mock server running on port 3001
await page.goto('/users');
// Wait for users to load
await expect(page.locator('h2')).toContainText('Users');
// Create new user
await page.click('button:text("Add User")');
await page.fill('[data-testid="name-input"]', 'Test User');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.click('button:text("Create User")');
// Verify user was created
await expect(page.locator('.user-card')).toContainText('Test User');
});
Best Practices
- Use composables for API logic: Keep components focused on presentation
- Leverage Nuxt’s auto-imports: No need to manually import composables
- Use
useStatefor shared state: Better than reactive() for SSR - Implement proper error handling: Always handle API errors gracefully
- Use TypeScript strictly: Leverage SpecJet’s generated types fully
- Test with mock server: Develop against consistent mock data
- Configure CORS properly: Ensure mock server allows your dev server origin
Troubleshooting
Common Issues
1. Auto-import not working
// nuxt.config.ts
export default defineNuxtConfig({
imports: {
dirs: ['composables', 'types'] // Ensure these are included
}
});
2. CORS errors in development
// specjet.config.js
export default {
mock: {
cors: {
origin: ['http://localhost:3000'], // Match your Nuxt dev server
credentials: true
}
}
};
3. TypeScript errors with generated types
# Regenerate types
npm run api:generate
# Check Nuxt TypeScript config
npx nuxi typecheck
Next Steps
- React Integration: Compare with React patterns
- Node.js Integration: Backend integration patterns
- Best Practices: OpenAPI design recommendations
- Configuration: Advanced configuration options