Skip to main content

TypeScript Vue.js Basic

A comprehensive Vue.js 3 application archetype built with TypeScript, leveraging the Composition API, Pinia for state management, and modern development tooling for building scalable, maintainable web applications.

Overview

This archetype generates a complete Vue.js 3 application using TypeScript for type safety, the Composition API for better code organization, and a modern development environment. It includes Vue Router for navigation, Pinia for state management, and comprehensive testing frameworks.

Technology Stack

  • Vue.js 3: Progressive JavaScript framework with Composition API
  • TypeScript 5+: Strong typing and modern JavaScript features
  • Vite: Ultra-fast build tool and development server
  • Vue Router 4: Official routing library for Vue.js
  • Pinia: Modern state management for Vue.js
  • Vitest: Unit testing framework optimized for Vite
  • Cypress: End-to-end testing framework
  • ESLint: Code linting with Vue-specific rules
  • Prettier: Code formatting and consistency
  • Tailwind CSS: Utility-first CSS framework

Key Features

Core Functionality

  • Composition API: Modern Vue.js development patterns
  • Reactive System: Vue's reactive system with TypeScript
  • Component Library: Reusable component architecture
  • Single File Components: Template, script, and style in one file
  • Scoped CSS: Component-scoped styling

Development Features

  • Hot Module Replacement: Fast development with live reloading
  • TypeScript Integration: Full TypeScript support with Vue
  • Vue DevTools: Browser extension for debugging
  • Auto-imports: Automatic imports for Vue APIs
  • Script Setup: Simplified component syntax

Production Features

  • Tree Shaking: Dead code elimination for smaller bundles
  • Code Splitting: Route-based code splitting
  • PWA Support: Progressive Web App capabilities
  • SSR Ready: Server-side rendering compatibility
  • Build Optimizations: Production-ready build configurations

Project Structure

{{ project-name }}/
├── src/
│ ├── components/ # Reusable components
│ │ ├── __tests__/
│ │ │ └── YborWelcome.spec.ts # Component tests
│ │ └── YborWelcome.vue # Welcome component
│ ├── views/ # Page components (routes)
│ │ ├── AboutView.vue # About page
│ │ └── HomeView.vue # Home page
│ ├── assets/ # Static assets
│ │ └── base.css # Base styles
│ ├── router/ # Vue Router configuration
│ │ └── index.ts # Router setup
│ ├── App.vue # Root component
│ └── main.ts # Application entry point
├── public/ # Public static files
│ ├── app.svg # App logo
│ ├── favicon.ico # Favicon
│ ├── globe.svg # Icon assets
│ ├── learn.svg
│ ├── rocket.svg
│ └── ybor-logo.svg
├── e2e/ # End-to-end tests
│ ├── tsconfig.json # E2E TypeScript config
│ └── vue.spec.ts # E2E test specs
├── Dockerfile # Container configuration
├── env.d.ts # Environment type declarations
├── eslint.config.ts # ESLint configuration
├── index.html # Main HTML file
├── nginx.conf # Production server config
├── package.json # Dependencies and scripts
├── playwright.config.ts # Playwright configuration
├── pnpm-lock.yaml # Lock file
├── README.md # Project documentation
├── tsconfig.app.json # App TypeScript config
├── tsconfig.json # Base TypeScript config
├── tsconfig.node.json # Node TypeScript config
├── tsconfig.vitest.json # Vitest TypeScript config
├── vite.config.ts # Vite configuration
└── vitest.config.ts # Vitest configuration

Component Architecture

Composition API Component

<!-- UserCard.vue -->
<template>
<div class="user-card">
<div class="user-card__header">
<img
v-if="user.avatarUrl"
:src="user.avatarUrl"
:alt="`${displayName} avatar`"
class="user-card__avatar"
/>
<div v-else class="user-card__avatar user-card__avatar--placeholder">
{{ initials }}
</div>

<div class="user-card__info">
<h3 class="user-card__name">{{ displayName }}</h3>
<p class="user-card__email">{{ user.email }}</p>
</div>
</div>

<p v-if="user.bio" class="user-card__bio">{{ user.bio }}</p>

<div class="user-card__stats">
<span class="stat">
<strong>{{ user.postsCount }}</strong> posts
</span>
<span class="stat">
<strong>{{ user.followersCount }}</strong> {{ followerText }}
</span>
</div>

<div v-if="showActions" class="user-card__actions">
<BaseButton
:variant="isFollowing ? 'outline' : 'primary'"
@click="handleFollow"
>
{{ followButtonText }}
</BaseButton>

<BaseButton variant="outline" @click="handleMessage">
Message
</BaseButton>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { User } from '@/types/user'
import BaseButton from '@/components/ui/BaseButton.vue'

// Props
interface Props {
user: User
isFollowing?: boolean
showActions?: boolean
}

const props = withDefaults(defineProps<Props>(), {
isFollowing: false,
showActions: true
})

// Emits
interface Emits {
follow: [user: User]
unfollow: [user: User]
message: [user: User]
}

const emit = defineEmits<Emits>()

// Computed properties
const displayName = computed(() => `${props.user.firstName} ${props.user.lastName}`)

const initials = computed(() =>
`${props.user.firstName[0]}${props.user.lastName[0]}`
)

const followerText = computed(() =>
props.user.followersCount === 1 ? 'follower' : 'followers'
)

const followButtonText = computed(() =>
props.isFollowing ? 'Unfollow' : 'Follow'
)

// Methods
const handleFollow = () => {
if (props.isFollowing) {
emit('unfollow', props.user)
} else {
emit('follow', props.user)
}
}

const handleMessage = () => {
emit('message', props.user)
}
</script>

<style scoped>
.user-card {
@apply bg-white rounded-lg shadow-md p-6 max-w-sm;
}

.user-card__header {
@apply flex items-center space-x-4 mb-4;
}

.user-card__avatar {
@apply w-12 h-12 rounded-full object-cover;
}

.user-card__avatar--placeholder {
@apply bg-gray-300 flex items-center justify-center text-gray-600 font-medium;
}

.user-card__name {
@apply text-lg font-semibold text-gray-900;
}

.user-card__email {
@apply text-sm text-gray-600;
}

.user-card__bio {
@apply text-gray-700 mb-4;
}

.user-card__stats {
@apply flex space-x-4 mb-4 text-sm;
}

.stat {
@apply text-gray-600;
}

.user-card__actions {
@apply flex space-x-2;
}
</style>

Pinia Store

// stores/users.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, CreateUserRequest, UpdateUserRequest } from '@/types/user'
import { userService } from '@/services/userService'

export const useUsersStore = defineStore('users', () => {
// State
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const currentPage = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)

// Getters
const activeUsers = computed(() =>
users.value.filter(user => user.isActive)
)

const userCount = computed(() => users.value.length)

const getUserById = computed(() => (id: string) =>
users.value.find(user => user.id === id)
)

// Actions
const fetchUsers = async (page = 1, limit = 10) => {
loading.value = true
error.value = null

try {
const response = await userService.getUsers({ page, limit })
users.value = response.data
currentPage.value = response.currentPage
totalPages.value = response.totalPages
totalCount.value = response.totalCount
} catch (err) {
error.value = 'Failed to fetch users'
console.error('Error fetching users:', err)
} finally {
loading.value = false
}
}

const fetchUserById = async (id: string): Promise<User | null> => {
loading.value = true
error.value = null

try {
const user = await userService.getUserById(id)

// Update user in the list if it exists
const index = users.value.findIndex(u => u.id === id)
if (index !== -1) {
users.value[index] = user
} else {
users.value.push(user)
}

return user
} catch (err) {
error.value = 'Failed to fetch user'
console.error('Error fetching user:', err)
return null
} finally {
loading.value = false
}
}

const createUser = async (userData: CreateUserRequest): Promise<User | null> => {
loading.value = true
error.value = null

try {
const newUser = await userService.createUser(userData)
users.value.push(newUser)
totalCount.value += 1
return newUser
} catch (err) {
error.value = 'Failed to create user'
console.error('Error creating user:', err)
return null
} finally {
loading.value = false
}
}

const updateUser = async (id: string, userData: UpdateUserRequest): Promise<User | null> => {
loading.value = true
error.value = null

try {
const updatedUser = await userService.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 = 'Failed to update user'
console.error('Error updating user:', err)
return null
} finally {
loading.value = false
}
}

const deleteUser = async (id: string): Promise<boolean> => {
loading.value = true
error.value = null

try {
await userService.deleteUser(id)
users.value = users.value.filter(user => user.id !== id)
totalCount.value -= 1
return true
} catch (err) {
error.value = 'Failed to delete user'
console.error('Error deleting user:', err)
return false
} finally {
loading.value = false
}
}

const clearError = () => {
error.value = null
}

const reset = () => {
users.value = []
loading.value = false
error.value = null
currentPage.value = 1
totalPages.value = 1
totalCount.value = 0
}

return {
// State
users,
loading,
error,
currentPage,
totalPages,
totalCount,

// Getters
activeUsers,
userCount,
getUserById,

// Actions
fetchUsers,
fetchUserById,
createUser,
updateUser,
deleteUser,
clearError,
reset
}
})

Composables

// composables/useApi.ts
import { ref, type Ref } from 'vue'

interface UseApiOptions {
immediate?: boolean
onError?: (error: Error) => void
onSuccess?: (data: any) => void
}

export function useApi<T = any>(
apiFunction: (...args: any[]) => Promise<T>,
options: UseApiOptions = {}
) {
const { immediate = false, onError, onSuccess } = options

const data: Ref<T | null> = ref(null)
const loading = ref(false)
const error = ref<Error | null>(null)

const execute = async (...args: any[]): Promise<T | null> => {
loading.value = true
error.value = null

try {
const result = await apiFunction(...args)
data.value = result
onSuccess?.(result)
return result
} catch (err) {
const apiError = err instanceof Error ? err : new Error('Unknown error')
error.value = apiError
onError?.(apiError)
return null
} finally {
loading.value = false
}
}

const reset = () => {
data.value = null
error.value = null
loading.value = false
}

if (immediate) {
execute()
}

return {
data,
loading,
error,
execute,
reset
}
}

// composables/useValidation.ts
import { ref, computed, type Ref } from 'vue'

type ValidationRule<T = any> = (value: T) => string | true

interface UseValidationOptions<T> {
value: Ref<T>
rules: ValidationRule<T>[]
immediate?: boolean
}

export function useValidation<T>({ value, rules, immediate = false }: UseValidationOptions<T>) {
const touched = ref(immediate)
const error = ref<string | null>(null)

const validate = () => {
touched.value = true

for (const rule of rules) {
const result = rule(value.value)
if (result !== true) {
error.value = result
return false
}
}

error.value = null
return true
}

const isValid = computed(() => {
if (!touched.value) return true
return validate()
})

const touch = () => {
touched.value = true
}

const reset = () => {
touched.value = false
error.value = null
}

return {
error,
isValid,
touched,
validate,
touch,
reset
}
}

// Validation rules
export const validationRules = {
required: (message = 'This field is required') => (value: any) => {
if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) {
return message
}
return true
},

minLength: (min: number, message?: string) => (value: string) => {
if (value && value.length < min) {
return message || `Must be at least ${min} characters`
}
return true
},

maxLength: (max: number, message?: string) => (value: string) => {
if (value && value.length > max) {
return message || `Must be no more than ${max} characters`
}
return true
},

email: (message = 'Must be a valid email address') => (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (value && !emailRegex.test(value)) {
return message
}
return true
},

pattern: (regex: RegExp, message = 'Invalid format') => (value: string) => {
if (value && !regex.test(value)) {
return message
}
return true
}
}

Form Component with Validation

<!-- components/forms/UserForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-row">
<BaseInput
v-model="formData.firstName"
label="First Name"
placeholder="Enter first name"
:error="firstNameValidation.error.value"
required
@blur="firstNameValidation.touch"
/>

<BaseInput
v-model="formData.lastName"
label="Last Name"
placeholder="Enter last name"
:error="lastNameValidation.error.value"
required
@blur="lastNameValidation.touch"
/>
</div>

<BaseInput
v-model="formData.email"
type="email"
label="Email"
placeholder="Enter email address"
:error="emailValidation.error.value"
required
@blur="emailValidation.touch"
/>

<BaseInput
v-model="formData.bio"
type="textarea"
label="Bio"
placeholder="Tell us about yourself (optional)"
:rows="3"
/>

<div class="form-actions">
<BaseButton variant="outline" @click="handleCancel">
Cancel
</BaseButton>

<BaseButton variant="outline" @click="handleReset">
Reset
</BaseButton>

<BaseButton
type="submit"
variant="primary"
:disabled="!isFormValid || loading"
:loading="loading"
>
{{ isEditMode ? 'Update' : 'Create' }} User
</BaseButton>
</div>
</form>
</template>

<script setup lang="ts">
import { reactive, computed } from 'vue'
import { useValidation, validationRules } from '@/composables/useValidation'
import type { CreateUserRequest, User } from '@/types/user'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseButton from '@/components/ui/BaseButton.vue'

// Props
interface Props {
initialData?: Partial<CreateUserRequest>
loading?: boolean
isEditMode?: boolean
}

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

// Emits
interface Emits {
submit: [data: CreateUserRequest]
cancel: []
}

const emit = defineEmits<Emits>()

// Form data
const formData = reactive<CreateUserRequest>({
firstName: props.initialData?.firstName || '',
lastName: props.initialData?.lastName || '',
email: props.initialData?.email || '',
bio: props.initialData?.bio || ''
})

// Validation
const firstNameValidation = useValidation({
value: computed(() => formData.firstName),
rules: [
validationRules.required('First name is required'),
validationRules.minLength(2, 'First name must be at least 2 characters')
]
})

const lastNameValidation = useValidation({
value: computed(() => formData.lastName),
rules: [
validationRules.required('Last name is required'),
validationRules.minLength(2, 'Last name must be at least 2 characters')
]
})

const emailValidation = useValidation({
value: computed(() => formData.email),
rules: [
validationRules.required('Email is required'),
validationRules.email('Please enter a valid email address')
]
})

// Computed
const isFormValid = computed(() =>
firstNameValidation.isValid.value &&
lastNameValidation.isValid.value &&
emailValidation.isValid.value
)

// Methods
const handleSubmit = () => {
// Touch all fields to show validation
firstNameValidation.touch()
lastNameValidation.touch()
emailValidation.touch()

if (isFormValid.value && !props.loading) {
emit('submit', { ...formData })
}
}

const handleCancel = () => {
emit('cancel')
}

const handleReset = () => {
Object.assign(formData, {
firstName: '',
lastName: '',
email: '',
bio: ''
})

// Reset validation
firstNameValidation.reset()
lastNameValidation.reset()
emailValidation.reset()
}
</script>

<style scoped>
.user-form {
@apply space-y-4 max-w-md;
}

.form-row {
@apply grid grid-cols-2 gap-4;
}

.form-actions {
@apply flex justify-end space-x-2 pt-4;
}
</style>

Testing Strategy

Unit Testing with Vitest

// tests/unit/UserCard.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import UserCard from '@/components/UserCard.vue'
import type { User } from '@/types/user'

const mockUser: User = {
id: '1',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
bio: 'Software developer',
postsCount: 5,
followersCount: 10,
isActive: true
}

describe('UserCard', () => {
it('renders user information correctly', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})

expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john.doe@example.com')
expect(wrapper.text()).toContain('Software developer')
expect(wrapper.text()).toContain('5 posts')
expect(wrapper.text()).toContain('10 followers')
})

it('emits follow event when follow button is clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser, isFollowing: false }
})

const followButton = wrapper.find('button')
await followButton.trigger('click')

expect(wrapper.emitted('follow')).toBeTruthy()
expect(wrapper.emitted('follow')![0]).toEqual([mockUser])
})

it('shows unfollow button when user is being followed', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser, isFollowing: true }
})

expect(wrapper.text()).toContain('Unfollow')
})

it('hides actions when showActions is false', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser, showActions: false }
})

expect(wrapper.find('.user-card__actions').exists()).toBe(false)
})
})

Store Testing

// tests/unit/stores/users.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUsersStore } from '@/stores/users'
import { userService } from '@/services/userService'
import type { User } from '@/types/user'

// Mock the user service
vi.mock('@/services/userService')

const mockUser: User = {
id: '1',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
postsCount: 5,
followersCount: 10,
isActive: true
}

describe('Users Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

it('should fetch users successfully', async () => {
const store = useUsersStore()

vi.mocked(userService.getUsers).mockResolvedValue({
data: [mockUser],
currentPage: 1,
totalPages: 1,
totalCount: 1
})

await store.fetchUsers()

expect(store.users).toHaveLength(1)
expect(store.users[0]).toEqual(mockUser)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})

it('should handle fetch users error', async () => {
const store = useUsersStore()

vi.mocked(userService.getUsers).mockRejectedValue(new Error('API Error'))

await store.fetchUsers()

expect(store.users).toHaveLength(0)
expect(store.loading).toBe(false)
expect(store.error).toBe('Failed to fetch users')
})

it('should create user successfully', async () => {
const store = useUsersStore()

vi.mocked(userService.createUser).mockResolvedValue(mockUser)

const createRequest = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com'
}

const result = await store.createUser(createRequest)

expect(result).toEqual(mockUser)
expect(store.users).toHaveLength(1)
expect(store.totalCount).toBe(1)
})
})

E2E Testing with Cypress

// tests/e2e/user-management.cy.ts
describe('User Management', () => {
beforeEach(() => {
cy.visit('/users')
})

it('should display list of users', () => {
cy.get('[data-cy="user-list"]').should('be.visible')
cy.get('[data-cy="user-card"]').should('have.length.greaterThan', 0)
})

it('should create a new user', () => {
cy.get('[data-cy="add-user-button"]').click()

cy.get('[data-cy="firstName-input"]').type('John')
cy.get('[data-cy="lastName-input"]').type('Doe')
cy.get('[data-cy="email-input"]').type('john.doe@example.com')
cy.get('[data-cy="bio-textarea"]').type('Software developer')

cy.get('[data-cy="submit-button"]').click()

cy.get('[data-cy="success-message"]').should('contain', 'User created successfully')
cy.get('[data-cy="user-list"]').should('contain', 'John Doe')
})

it('should validate form fields', () => {
cy.get('[data-cy="add-user-button"]').click()
cy.get('[data-cy="submit-button"]').click()

cy.get('[data-cy="firstName-error"]').should('contain', 'First name is required')
cy.get('[data-cy="lastName-error"]').should('contain', 'Last name is required')
cy.get('[data-cy="email-error"]').should('contain', 'Email is required')
})

it('should filter users', () => {
cy.get('[data-cy="search-input"]').type('John')
cy.get('[data-cy="user-card"]').should('contain', 'John')
cy.get('[data-cy="user-card"]').should('not.contain', 'Jane')
})
})

Quick Start

  1. Generate the application:

    archetect render git@github.com:p6m-archetypes/typescript-vuejs-basic.archetype.git
  2. Install dependencies:

    npm install
  3. Start development server:

    npm run dev
  4. Access the application:

    http://localhost:5173
  5. Run tests:

    # Unit tests
    npm run test:unit

    # E2E tests
    npm run test:e2e

    # Test coverage
    npm run test:coverage
  6. Build for production:

    npm run build

Best Practices

Component Design

  • Use Composition API for better code organization
  • Implement proper TypeScript typing
  • Use script setup syntax for cleaner code
  • Leverage Vue's reactivity system effectively

State Management

  • Use Pinia for global state management
  • Keep local state in components when appropriate
  • Implement proper store organization
  • Use composables for reusable logic

Performance

  • Implement proper component lazy loading
  • Use v-memo for expensive renders
  • Optimize watchers and computed properties
  • Implement virtual scrolling for large lists

Testing

  • Write comprehensive unit tests
  • Use component testing for complex components
  • Implement E2E tests for critical user flows
  • Test composables independently