Skip to main content
Vue is a progressive JavaScript framework for building user interfaces. This guide shows you how to build a Vue 3 application with Mizu as the backend.

Why Vue?

Vue 3 is known for its gentle learning curve, excellent documentation, and progressive framework approach. You can adopt it incrementally - from a simple script tag to a full-featured SPA. The Composition API brings powerful reactivity and composability, while maintaining the simplicity of the Options API for smaller projects. Key strengths:
  • Progressive framework: Start small, scale as needed
  • Excellent documentation: Clear, comprehensive, well-organized
  • Gentle learning curve: Intuitive API, easy to get started
  • Performance: Fast virtual DOM with compiler optimizations
  • Single File Components: Template, script, and styles in one file
  • Flexible: Works with any backend, easy to integrate
  • TypeScript support: First-class TypeScript integration

Vue vs Other Frameworks

FeatureVue 3ReactSvelteAngular
Bundle Size~34 KB~44 KB~3 KB~167 KB
Learning CurveGentleModerateGentleSteep
ReactivityFine-grained (Proxy)Virtual DOMCompiledRxJS + Zones
TypeScriptExcellentExcellentGoodExcellent
EcosystemLargeLargestGrowingLarge
Component StyleSFC (.vue)JSX/TSXSFC (.svelte)TS + Templates
State ManagementPiniaRedux/ZustandStoresNgRx/Services
CLI/ToolingVite (fast)CRA/ViteVite/SvelteKitAngular CLI
Best ForProgressive appsLarge appsSmall/fast appsEnterprise

Vue + Mizu vs Standalone Vue

AspectVue + MizuStandalone Vue (SPA)
BackendGo (embedded FS)Separate API server
DeploymentSingle binaryFrontend + backend separate
DevelopmentMizu proxy + Vite HMRVite dev server only
API CallsSame origin (/api/*)CORS required
Buildmake build (Go + Vue)npm run build only
Production./bin/servernginx/CDN + API server
Type SafetyGo backend + TS frontendTS frontend only

Quick Start

Create a new Vue project with the CLI:
mizu new ./my-vue-app --template frontend/vue
cd my-vue-app
make dev
Visit http://localhost:3000 to see your app!

Architecture

Development Mode

┌─────────────────────────────────────────────────────────────┐
│                         Browser                              │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Vue App (HMR enabled)                                 │  │
│  │  ┌─────────┐  ┌─────────┐  ┌──────────┐               │  │
│  │  │ Router  │→ │ Pages   │→ │ API Call │               │  │
│  │  └─────────┘  └─────────┘  └──────────┘               │  │
│  └───────────────────────────────────────────────────────┘  │
│         ↑ HMR WebSocket           ↓ /api/users              │
└─────────┼──────────────────────────┼────────────────────────┘
          │                          │
┌─────────┼──────────────────────────┼────────────────────────┐
│  Mizu Server (:3000)               │                         │
│         │                          ↓                         │
│    ┌────┴─────────┐        ┌────────────┐                   │
│    │   Proxy to   │        │ API Routes │                   │
│    │ Vite (:5173) │        │ (Go)       │                   │
│    └──────────────┘        └────────────┘                   │
│         ↑                                                    │
└─────────┼────────────────────────────────────────────────────┘

┌─────────┼────────────────────────────────────────────────────┐
│  Vite Dev Server (:5173)                                     │
│    ┌──────────────┐  ┌─────────────┐                        │
│    │ Vue Compiler │  │ HMR Engine  │                        │
│    └──────────────┘  └─────────────┘                        │
└──────────────────────────────────────────────────────────────┘

Production Mode

┌─────────────────────────────────────────────────────────────┐
│                         Browser                              │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Vue App (compiled, minified)                          │  │
│  │  ┌─────────┐  ┌─────────┐  ┌──────────┐               │  │
│  │  │ Router  │→ │ Pages   │→ │ API Call │               │  │
│  │  └─────────┘  └─────────┘  └──────────┘               │  │
│  └───────────────────────────────────────────────────────┘  │
│         ↑ index.html + assets     ↓ /api/users              │
└─────────┼──────────────────────────┼────────────────────────┘
          │                          │
┌─────────┼──────────────────────────┼────────────────────────┐
│  Mizu Server (single binary)       │                         │
│         │                          ↓                         │
│    ┌────┴─────────┐        ┌────────────┐                   │
│    │ Embedded FS  │        │ API Routes │                   │
│    │ (dist/...)   │        │ (Go)       │                   │
│    └──────────────┘        └────────────┘                   │
│  ←────────────────────────────────────────────────────────  │
│  Static files served from Go binary (//go:embed all:dist)   │
└──────────────────────────────────────────────────────────────┘

Project Structure

my-vue-app/
├── cmd/
│   └── server/
│       └── main.go              # Entry point
├── app/
│   └── server/
│       ├── app.go               # Mizu app setup
│       ├── config.go            # Configuration
│       └── routes.go            # API routes
├── frontend/                      # Vue application
│   ├── src/
│   │   ├── main.ts              # Vue entry point
│   │   ├── App.vue              # Root component
│   │   ├── router/
│   │   │   └── index.ts         # Vue Router
│   │   ├── components/
│   │   │   └── Layout.vue       # Layout component
│   │   ├── pages/
│   │   │   ├── Home.vue         # Home page
│   │   │   └── About.vue        # About page
│   │   ├── stores/              # Pinia stores
│   │   ├── composables/         # Custom composables
│   │   └── styles/
│   │       └── index.css        # Global styles
│   ├── public/
│   │   └── vite.svg             # Public assets
│   ├── index.html               # HTML template
│   ├── package.json             # npm dependencies
│   ├── vite.config.ts           # Vite configuration
│   ├── tsconfig.json            # TypeScript config
│   └── env.d.ts                 # TypeScript declarations
├── dist/                        # Built files (after build)
├── go.mod
└── Makefile

Backend Setup

The backend serves your Vue app and provides API endpoints. See app/server/app.go:
package server

import (
    "embed"
    "io/fs"

    "github.com/go-mizu/mizu"
    "github.com/go-mizu/mizu/frontend"
)

//go:embed all:../../dist
var distFS embed.FS

func New(cfg *Config) *mizu.App {
    app := mizu.New()

    // API routes
    setupRoutes(app)

    // Frontend middleware
    dist, _ := fs.Sub(distFS, "dist")
    app.Use(frontend.WithOptions(frontend.Options{
        Mode:        frontend.ModeAuto,
        FS:          dist,
        DevServer:   "http://localhost:" + cfg.DevPort,
        IgnorePaths: []string{"/api"},
    }))

    return app
}
How it works:
  • //go:embed all:../../dist embeds the built Vue app into the Go binary
  • frontend.ModeAuto automatically switches between dev (proxy) and production (embedded FS)
  • DevServer proxies to Vite during development
  • IgnorePaths: []string{"/api"} ensures API routes aren’t proxied

API Routes Example

// app/server/routes.go
package server

import "github.com/go-mizu/mizu"

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func setupRoutes(app *mizu.App) {
    // GET /api/users
    app.GET("/api/users", func(c *mizu.Ctx) error {
        users := []User{
            {ID: 1, Name: "Alice", Email: "[email protected]"},
            {ID: 2, Name: "Bob", Email: "[email protected]"},
        }
        return c.JSON(users)
    })

    // POST /api/users
    app.POST("/api/users", func(c *mizu.Ctx) error {
        var user User
        if err := c.BodyParser(&user); err != nil {
            return err
        }
        user.ID = 3 // In real app, generate from DB
        return c.Status(201).JSON(user)
    })

    // GET /api/users/:id
    app.GET("/api/users/:id", func(c *mizu.Ctx) error {
        id := c.Param("id")
        user := User{ID: 1, Name: "Alice", Email: "[email protected]"}
        return c.JSON(user)
    })

    // DELETE /api/users/:id
    app.DELETE("/api/users/:id", func(c *mizu.Ctx) error {
        return c.Status(204).Send(nil)
    })
}

Frontend Setup

frontend/src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'

createApp(App)
  .use(router)
  .mount('#app')

frontend/src/App.vue

<template>
  <router-view />
</template>

<script setup lang="ts">
// No script needed for basic setup
</script>

frontend/src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/components/Layout.vue'
import Home from '@/pages/Home.vue'
import About from '@/pages/About.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: Layout,
      children: [
        { path: '', component: Home },
        { path: 'about', component: About },
      ],
    },
  ],
})

export default router

frontend/src/components/Layout.vue

<template>
  <div class="app">
    <nav>
      <router-link to="/">Home</router-link>
      <router-link to="/about">About</router-link>
    </nav>

    <main>
      <router-view />
    </main>

    <footer>
      <p>Built with Mizu and Vue</p>
    </footer>
  </div>
</template>

<style scoped>
nav {
  display: flex;
  gap: 1rem;
  padding: 1rem;
  background: #f5f5f5;
}

nav a {
  color: #42b983;
  text-decoration: none;
}

nav a.router-link-active {
  font-weight: bold;
}
</style>

frontend/src/pages/Home.vue

<template>
  <div>
    <h1>Users</h1>

    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }} ({{ user.email }})
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const users = ref<User[]>([])
const loading = ref(true)
const error = ref<string | null>(null)

onMounted(async () => {
  try {
    const response = await fetch('/api/users')
    if (!response.ok) throw new Error('Failed to fetch users')
    users.value = await response.json()
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error'
  } finally {
    loading.value = false
  }
})
</script>

Composition API vs Options API

Vue 3 supports both the Composition API (recommended) and the Options API (Vue 2 style). The Composition API provides better TypeScript support, code organization, and reusability.

Options API

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  computed: {
    doubled() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    console.log('Component mounted')
  }
})
</script>
Characteristics:
  • Properties organized by options (data, computed, methods)
  • Uses this to access properties
  • Familiar to Vue 2 developers
  • Works well for simple components

Composition API

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('Component mounted')
})
</script>
Characteristics:
  • Logic organized by feature (not by type)
  • No this keyword
  • Better TypeScript inference
  • Code is more reusable (extractable to composables)
  • <script setup> reduces boilerplate

Vue Reactivity System

Vue 3’s reactivity system is built on ES6 Proxies, providing fine-grained reactive tracking.

ref() - Reactive Primitives

Use ref() for primitives (strings, numbers, booleans). Access/modify with .value in JavaScript, but not in templates.
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const name = ref('Alice')
const isActive = ref(true)

// Access with .value in script
console.log(count.value) // 0
count.value++

// Update
function increment() {
  count.value++
}
</script>

<template>
  <!-- No .value in template -->
  <p>Count: {{ count }}</p>
  <p>Name: {{ name }}</p>
  <button @click="increment">+</button>
</template>

reactive() - Reactive Objects

Use reactive() for objects and arrays. No .value needed.
<script setup lang="ts">
import { reactive } from 'vue'

interface User {
  name: string
  age: number
  hobbies: string[]
}

const user = reactive<User>({
  name: 'Alice',
  age: 30,
  hobbies: ['reading', 'coding']
})

// Access directly (no .value)
console.log(user.name) // 'Alice'
user.age++
user.hobbies.push('gaming')
</script>

<template>
  <div>
    <p>{{ user.name }}, {{ user.age }}</p>
    <ul>
      <li v-for="hobby in user.hobbies" :key="hobby">{{ hobby }}</li>
    </ul>
  </div>
</template>
When to use ref() vs reactive():
  • Use ref() for primitives and when you need to reassign the entire value
  • Use reactive() for objects that you’ll mutate properties of
  • ref() can hold any value type, reactive() only works with objects

computed() - Derived State

Computed values are cached and only re-compute when dependencies change.
<script setup lang="ts">
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// Cached, only recomputes when firstName or lastName change
const fullName = computed(() => {
  console.log('Computing full name')
  return `${firstName.value} ${lastName.value}`
})

// Writable computed
const fullNameWritable = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value: string) {
    const parts = value.split(' ')
    firstName.value = parts[0]
    lastName.value = parts[1] || ''
  }
})
</script>

<template>
  <div>
    <p>{{ fullName }}</p>
    <input v-model="firstName" />
    <input v-model="lastName" />
  </div>
</template>

watch() - Side Effects

Watch reactive sources and perform side effects when they change.
<script setup lang="ts">
import { ref, watch } from 'vue'

const count = ref(0)
const user = reactive({ name: 'Alice', age: 30 })

// Watch a single ref
watch(count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`)
})

// Watch a getter function
watch(
  () => user.name,
  (newName) => {
    console.log(`Name changed to ${newName}`)
  }
)

// Watch multiple sources
watch([count, () => user.name], ([newCount, newName]) => {
  console.log(`Count: ${newCount}, Name: ${newName}`)
})

// Deep watch for objects
watch(
  user,
  (newUser) => {
    console.log('User changed:', newUser)
  },
  { deep: true }
)

// Immediate execution
watch(count, (val) => {
  console.log('Count:', val)
}, { immediate: true })
</script>

watchEffect() - Automatic Dependency Tracking

Runs immediately and automatically tracks dependencies.
<script setup lang="ts">
import { ref, watchEffect } from 'vue'

const count = ref(0)
const doubled = ref(0)

// Automatically tracks count, runs immediately
watchEffect(() => {
  doubled.value = count.value * 2
  console.log('Doubled:', doubled.value)
})

// With cleanup
watchEffect((onCleanup) => {
  const timer = setTimeout(() => {
    console.log('Count:', count.value)
  }, 1000)

  onCleanup(() => {
    clearTimeout(timer)
  })
})
</script>

Lifecycle Hooks

Vue 3 Composition API lifecycle hooks:
<script setup lang="ts">
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  ref
} from 'vue'

const count = ref(0)

onBeforeMount(() => {
  console.log('Before mount: DOM not created yet')
})

onMounted(() => {
  console.log('Mounted: Component is in DOM')
  // Good for: API calls, DOM manipulation, timers
  fetchData()
})

onBeforeUpdate(() => {
  console.log('Before update: Before DOM re-render')
})

onUpdated(() => {
  console.log('Updated: After DOM re-render')
  // Be careful: can cause infinite loops
})

onBeforeUnmount(() => {
  console.log('Before unmount: Cleanup starting')
})

onUnmounted(() => {
  console.log('Unmounted: Component removed from DOM')
  // Good for: Cleanup timers, subscriptions, listeners
})

async function fetchData() {
  const res = await fetch('/api/data')
  const data = await res.json()
  console.log(data)
}
</script>
Lifecycle order:
  1. setup() / <script setup> - Component created
  2. onBeforeMount() - Before inserting into DOM
  3. onMounted() - After inserting into DOM
  4. onBeforeUpdate() - Before reactive data change causes re-render
  5. onUpdated() - After re-render
  6. onBeforeUnmount() - Before removing from DOM
  7. onUnmounted() - After removing from DOM

Vue Router

Basic Routing

We’ve already seen basic routing. Let’s expand with more patterns.

Dynamic Routes

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/users/:id',
      component: () => import('@/pages/UserDetail.vue'),
      props: true  // Pass :id as prop to component
    },
    {
      path: '/posts/:id(\\d+)',  // Only match numeric IDs
      component: () => import('@/pages/PostDetail.vue')
    },
    {
      // Catch-all route for 404
      path: '/:pathMatch(.*)*',
      component: () => import('@/pages/NotFound.vue')
    }
  ]
})

export default router
<!-- pages/UserDetail.vue -->
<template>
  <div>
    <h1>User {{ id }}</h1>
    <div v-if="loading">Loading...</div>
    <div v-else-if="user">
      <p>Name: {{ user.name }}</p>
      <p>Email: {{ user.email }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'

// Option 1: Use props (requires props: true in route)
const props = defineProps<{ id: string }>()

// Option 2: Use useRoute()
const route = useRoute()

interface User {
  name: string
  email: string
}

const user = ref<User | null>(null)
const loading = ref(true)

async function fetchUser(id: string) {
  loading.value = true
  const res = await fetch(`/api/users/${id}`)
  user.value = await res.json()
  loading.value = false
}

onMounted(() => {
  fetchUser(props.id)
})

// Re-fetch when ID changes (same component, different route)
watch(() => props.id, (newId) => {
  fetchUser(newId)
})
</script>

Nested Routes

// router/index.ts
const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/dashboard',
      component: () => import('@/layouts/DashboardLayout.vue'),
      children: [
        {
          path: '',  // /dashboard
          component: () => import('@/pages/DashboardHome.vue')
        },
        {
          path: 'settings',  // /dashboard/settings
          component: () => import('@/pages/Settings.vue')
        },
        {
          path: 'users',  // /dashboard/users
          component: () => import('@/pages/Users.vue')
        }
      ]
    }
  ]
})
<!-- layouts/DashboardLayout.vue -->
<template>
  <div class="dashboard">
    <aside>
      <nav>
        <router-link to="/dashboard">Home</router-link>
        <router-link to="/dashboard/settings">Settings</router-link>
        <router-link to="/dashboard/users">Users</router-link>
      </nav>
    </aside>

    <main>
      <!-- Child routes render here -->
      <router-view />
    </main>
  </div>
</template>

Programmatic Navigation

<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

function goToUser(id: number) {
  // Navigate to route
  router.push(`/users/${id}`)

  // Or with object
  router.push({ path: `/users/${id}` })

  // Or with named route
  router.push({ name: 'user', params: { id } })
}

function goBack() {
  router.back()
}

function replaceRoute() {
  // Replace current history entry (no back button)
  router.replace('/home')
}

// Access current route
console.log(route.params.id)
console.log(route.query.search)
console.log(route.path)
</script>

<template>
  <button @click="goToUser(123)">View User 123</button>
  <button @click="goBack">Go Back</button>
</template>

Route Guards

// router/index.ts
const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/admin',
      component: () => import('@/pages/Admin.vue'),
      meta: { requiresAuth: true }
    }
  ]
})

// Global before guard
router.beforeEach((to, from, next) => {
  const isAuthenticated = localStorage.getItem('token')

  if (to.meta.requiresAuth && !isAuthenticated) {
    // Redirect to login
    next('/login')
  } else {
    next()
  }
})

// Global after guard
router.afterEach((to, from) => {
  // Update document title
  document.title = to.meta.title as string || 'My App'
})

export default router
// Per-route guard
const routes = [
  {
    path: '/users/:id',
    component: UserDetail,
    beforeEnter: (to, from) => {
      const id = parseInt(to.params.id as string)
      if (isNaN(id)) {
        return { path: '/404' }
      }
    }
  }
]
<!-- In-component guard -->
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const hasUnsavedChanges = ref(false)

onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('You have unsaved changes. Leave anyway?')
    if (!answer) return false
  }
})

onBeforeRouteUpdate((to, from) => {
  // Called when route changes but component is reused
  console.log('Route updated:', to.params)
})
</script>

Vite Configuration

frontend/vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@components': path.resolve(__dirname, './src/components'),
      '@pages': path.resolve(__dirname, './src/pages'),
      '@stores': path.resolve(__dirname, './src/stores'),
      '@composables': path.resolve(__dirname, './src/composables'),
    },
  },

  server: {
    port: 5173,
    strictPort: true,
    hmr: {
      clientPort: 3000,  // Mizu's port for HMR WebSocket
    },
  },

  build: {
    outDir: '../dist',
    emptyOutDir: true,
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router'],
          'pinia': ['pinia'],
        },
      },
    },
  },
})
Update tsconfig.json to match aliases:
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@pages/*": ["./src/pages/*"],
      "@stores/*": ["./src/stores/*"],
      "@composables/*": ["./src/composables/*"]
    }
  }
}

Composables (Custom Hooks)

Composables are reusable pieces of stateful logic. They follow the naming convention use*.

Data Fetching Composable

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

interface UseApiOptions {
  immediate?: boolean
}

export function useApi<T>(url: string | Ref<string>, options: UseApiOptions = {}) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const execute = async () => {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(unref(url))
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      error.value = err instanceof Error ? err : new Error('Unknown error')
    } finally {
      loading.value = false
    }
  }

  if (options.immediate) {
    execute()
  }

  return { data, loading, error, execute, refetch: execute }
}
<!-- Usage -->
<script setup lang="ts">
import { useApi } from '@/composables/useApi'

interface User {
  id: number
  name: string
}

const { data: users, loading, error, refetch } = useApi<User[]>('/api/users', {
  immediate: true
})
</script>

<template>
  <div>
    <button @click="refetch">Refresh</button>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

Debounce Composable

// src/composables/useDebounce.ts
import { ref, watch, unref, type Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debouncedValue = ref(value.value) as Ref<T>

  let timeout: ReturnType<typeof setTimeout> | null = null

  watch(value, (newVal) => {
    if (timeout) clearTimeout(timeout)

    timeout = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })

  return debouncedValue
}
<!-- Usage: Search with debouncing -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDebounce } from '@/composables/useDebounce'

const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
const results = ref([])

watch(debouncedQuery, async (query) => {
  if (!query) {
    results.value = []
    return
  }

  const res = await fetch(`/api/search?q=${query}`)
  results.value = await res.json()
})
</script>

<template>
  <input v-model="searchQuery" placeholder="Search..." />
  <!-- Results update 500ms after user stops typing -->
  <ul>
    <li v-for="result in results" :key="result.id">{{ result.name }}</li>
  </ul>
</template>

LocalStorage Composable

// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
  const storedValue = localStorage.getItem(key)
  const data = ref<T>(
    storedValue ? JSON.parse(storedValue) : defaultValue
  ) as Ref<T>

  watch(
    data,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue))
    },
    { deep: true }
  )

  return data
}
<!-- Usage -->
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'

// Automatically synced with localStorage
const preferences = useLocalStorage('user-preferences', {
  theme: 'light',
  fontSize: 14
})
</script>

<template>
  <div>
    <select v-model="preferences.theme">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
    <input v-model.number="preferences.fontSize" type="number" />
  </div>
</template>

State Management with Pinia

Pinia is the official state management library for Vue 3, replacing Vuex.

Setup

cd frontend
npm install pinia
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()

createApp(App)
  .use(pinia)
  .mount('#app')

Creating a Store

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

// Setup syntax (recommended - like Composition API)
export const useUserStore = defineStore('user', () => {
  // State
  const users = ref<User[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  // Getters (computed)
  const userCount = computed(() => users.value.length)
  const userNames = computed(() => users.value.map(u => u.name))

  // Actions
  async function fetchUsers() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch('/api/users')
      if (!response.ok) throw new Error('Failed to fetch users')
      users.value = await response.json()
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }

  async function addUser(user: Omit<User, 'id'>) {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user)
    })
    const newUser = await response.json()
    users.value.push(newUser)
  }

  async function deleteUser(id: number) {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
    users.value = users.value.filter(u => u.id !== id)
  }

  function $reset() {
    users.value = []
    loading.value = false
    error.value = null
  }

  return {
    // State
    users,
    loading,
    error,
    // Getters
    userCount,
    userNames,
    // Actions
    fetchUsers,
    addUser,
    deleteUser,
    $reset
  }
})

Options API Style Store

// src/stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),

  getters: {
    doubleCount: (state) => state.count * 2,

    // Access other getters
    displayText(): string {
      return `${this.name}: ${this.doubleCount}`
    }
  },

  actions: {
    increment() {
      this.count++
    },

    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})

Using Stores in Components

<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// Extract reactive state (use storeToRefs to maintain reactivity)
const { users, loading, error, userCount } = storeToRefs(userStore)

// Actions don't need storeToRefs
const { fetchUsers, addUser, deleteUser } = userStore

onMounted(() => {
  fetchUsers()
})

async function handleAddUser() {
  await addUser({ name: 'New User', email: '[email protected]' })
}
</script>

<template>
  <div>
    <h1>Users ({{ userCount }})</h1>

    <button @click="handleAddUser">Add User</button>

    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
        <button @click="deleteUser(user.id)">Delete</button>
      </li>
    </ul>
  </div>
</template>

Store Composition

Stores can use other stores:
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useUserStore } from './user'

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(localStorage.getItem('token'))
  const isAuthenticated = computed(() => !!token.value)

  // Use another store
  const userStore = useUserStore()

  async function login(email: string, password: string) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })
    const data = await res.json()
    token.value = data.token
    localStorage.setItem('token', data.token)

    // Fetch user data after login
    await userStore.fetchUsers()
  }

  function logout() {
    token.value = null
    localStorage.removeItem('token')
    userStore.$reset()
  }

  return { token, isAuthenticated, login, logout }
})

Component Patterns

Props and Emits with TypeScript

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <button @click="handleEdit">Edit</button>
    <button @click="handleDelete">Delete</button>
  </div>
</template>

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

// Define props with TypeScript
interface Props {
  user: User
  editable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  editable: true
})

// Define emits with TypeScript
const emit = defineEmits<{
  edit: [user: User]
  delete: [id: number]
}>()

function handleEdit() {
  if (props.editable) {
    emit('edit', props.user)
  }
}

function handleDelete() {
  emit('delete', props.user.id)
}
</script>
<!-- Parent component -->
<template>
  <UserCard
    :user="user"
    :editable="true"
    @edit="handleEdit"
    @delete="handleDelete"
  />
</template>

<script setup lang="ts">
import UserCard from '@/components/UserCard.vue'

function handleEdit(user: User) {
  console.log('Editing user:', user)
}

function handleDelete(id: number) {
  console.log('Deleting user:', id)
}
</script>

Slots

<!-- Card.vue - Reusable card component -->
<template>
  <div class="card">
    <header v-if="$slots.header" class="card-header">
      <slot name="header" />
    </header>

    <div class="card-body">
      <slot />  <!-- Default slot -->
    </div>

    <footer v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </footer>
  </div>
</template>
<!-- Usage -->
<template>
  <Card>
    <template #header>
      <h2>User Profile</h2>
    </template>

    <!-- Default slot content -->
    <p>Name: {{ user.name }}</p>
    <p>Email: {{ user.email }}</p>

    <template #footer>
      <button>Edit</button>
      <button>Delete</button>
    </template>
  </Card>
</template>

Scoped Slots

<!-- List.vue - Generic list component -->
<template>
  <ul>
    <li v-for="(item, index) in items" :key="index">
      <!-- Expose item data to parent -->
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

<script setup lang="ts">
interface Props<T> {
  items: T[]
}

defineProps<Props<any>>()
</script>
<!-- Usage -->
<template>
  <List :items="users">
    <template #default="{ item, index }">
      <strong>{{ index + 1 }}.</strong> {{ item.name }} ({{ item.email }})
    </template>
  </List>
</template>

v-model

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>

<script setup lang="ts">
defineProps<{
  modelValue: string
}>()

defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>
<!-- Usage (syntactic sugar for :modelValue + @update:modelValue) -->
<template>
  <CustomInput v-model="text" />
  <p>{{ text }}</p>
</template>

<script setup lang="ts">
const text = ref('')
</script>

Multiple v-models

<!-- UserForm.vue -->
<template>
  <div>
    <input :value="name" @input="$emit('update:name', $event.target.value)" />
    <input :value="email" @input="$emit('update:email', $event.target.value)" />
  </div>
</template>

<script setup lang="ts">
defineProps<{
  name: string
  email: string
}>()

defineEmits<{
  'update:name': [value: string]
  'update:email': [value: string]
}>()
</script>
<!-- Usage -->
<template>
  <UserForm v-model:name="userName" v-model:email="userEmail" />
</template>

Directives

v-model Advanced

<template>
  <!-- Text input -->
  <input v-model="text" />

  <!-- Checkbox -->
  <input type="checkbox" v-model="checked" />

  <!-- Multiple checkboxes (array) -->
  <input type="checkbox" value="vue" v-model="frameworks" />
  <input type="checkbox" value="react" v-model="frameworks" />

  <!-- Radio -->
  <input type="radio" value="yes" v-model="answer" />
  <input type="radio" value="no" v-model="answer" />

  <!-- Select -->
  <select v-model="selected">
    <option value="a">A</option>
    <option value="b">B</option>
  </select>

  <!-- Modifiers -->
  <input v-model.lazy="text" />       <!-- Update on change, not input -->
  <input v-model.number="age" />      <!-- Cast to number -->
  <input v-model.trim="name" />       <!-- Trim whitespace -->
</template>

<script setup lang="ts">
const text = ref('')
const checked = ref(false)
const frameworks = ref<string[]>([])
const answer = ref('')
const selected = ref('')
const age = ref(0)
const name = ref('')
</script>

Custom Directives

// src/directives/focus.ts
import type { Directive } from 'vue'

export const vFocus: Directive = {
  mounted(el) {
    el.focus()
  }
}
// src/main.ts
import { vFocus } from './directives/focus'

const app = createApp(App)
app.directive('focus', vFocus)
app.mount('#app')
<!-- Usage -->
<template>
  <input v-focus />
</template>
More complex directive:
// src/directives/clickOutside.ts
import type { Directive } from 'vue'

export const vClickOutside: Directive<HTMLElement, () => void> = {
  mounted(el, binding) {
    el._clickOutside = (event: Event) => {
      if (!(el === event.target || el.contains(event.target as Node))) {
        binding.value()
      }
    }
    document.addEventListener('click', el._clickOutside)
  },

  unmounted(el) {
    document.removeEventListener('click', el._clickOutside!)
    delete el._clickOutside
  }
}

declare module 'vue' {
  interface HTMLElement {
    _clickOutside?: (event: Event) => void
  }
}
<!-- Usage: Close dropdown when clicking outside -->
<template>
  <div v-click-outside="closeDropdown" class="dropdown">
    <button @click="isOpen = !isOpen">Toggle</button>
    <div v-if="isOpen" class="menu">
      <a href="#">Item 1</a>
      <a href="#">Item 2</a>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { vClickOutside } from '@/directives/clickOutside'

const isOpen = ref(false)

function closeDropdown() {
  isOpen.value = false
}
</script>

Advanced Features

Teleport

Render content outside the component’s DOM hierarchy (useful for modals, notifications).
<template>
  <button @click="showModal = true">Open Modal</button>

  <!-- Renders to <body>, not here -->
  <Teleport to="body">
    <div v-if="showModal" class="modal-overlay" @click="showModal = false">
      <div class="modal" @click.stop>
        <h2>Modal Title</h2>
        <p>Modal content goes here</p>
        <button @click="showModal = false">Close</button>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const showModal = ref(false)
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal {
  background: white;
  padding: 2rem;
  border-radius: 8px;
}
</style>

Suspense

Handle async components with loading states.
<template>
  <Suspense>
    <!-- Component with async setup() -->
    <template #default>
      <AsyncUserList />
    </template>

    <!-- Loading fallback -->
    <template #fallback>
      <div>Loading users...</div>
    </template>
  </Suspense>
</template>

<script setup lang="ts">
import AsyncUserList from '@/components/AsyncUserList.vue'
</script>
<!-- AsyncUserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

<script setup lang="ts">
// Top-level await in setup - makes component async
const response = await fetch('/api/users')
const users = await response.json()
</script>

KeepAlive

Cache component instances to preserve state when switching routes/components.
<template>
  <KeepAlive :max="10">
    <component :is="currentTab" />
  </KeepAlive>
</template>
<!-- In router -->
<template>
  <router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component" />
    </KeepAlive>
  </router-view>
</template>

Transitions

<template>
  <button @click="show = !show">Toggle</button>

  <Transition name="fade">
    <p v-if="show">Hello Vue!</p>
  </Transition>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>
List transitions:
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </TransitionGroup>
</template>

<style>
.list-enter-active, .list-leave-active {
  transition: all 0.5s;
}
.list-enter-from {
  opacity: 0;
  transform: translateX(30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}
</style>

Form Handling

Basic Forms

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="name" placeholder="Name" required />
    <input v-model="email" type="email" placeholder="Email" required />
    <button type="submit" :disabled="submitting">
      {{ submitting ? 'Creating...' : 'Create User' }}
    </button>
  </form>

  <p v-if="success" class="success">User created successfully!</p>
  <p v-if="error" class="error">{{ error }}</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const name = ref('')
const email = ref('')
const submitting = ref(false)
const success = ref(false)
const error = ref<string | null>(null)

const handleSubmit = async () => {
  submitting.value = true
  success.value = false
  error.value = null

  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: name.value,
        email: email.value,
      }),
    })

    if (!response.ok) throw new Error('Failed to create user')

    success.value = true
    name.value = ''
    email.value = ''
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error'
  } finally {
    submitting.value = false
  }
}
</script>

Form Validation with VeeValidate

cd frontend
npm install vee-validate yup
<template>
  <form @submit="onSubmit">
    <div>
      <input v-model="name" name="name" placeholder="Name" />
      <span class="error">{{ errors.name }}</span>
    </div>

    <div>
      <input v-model="email" name="email" type="email" placeholder="Email" />
      <span class="error">{{ errors.email }}</span>
    </div>

    <div>
      <input v-model="age" name="age" type="number" placeholder="Age" />
      <span class="error">{{ errors.age }}</span>
    </div>

    <button type="submit" :disabled="!meta.valid">Submit</button>
  </form>
</template>

<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  name: yup.string().required('Name is required').min(2, 'Name must be at least 2 characters'),
  email: yup.string().required('Email is required').email('Must be a valid email'),
  age: yup.number().required('Age is required').min(18, 'Must be 18 or older').max(120)
})

const { errors, meta, handleSubmit } = useForm({
  validationSchema: schema
})

const { value: name } = useField<string>('name')
const { value: email } = useField<string>('email')
const { value: age } = useField<number>('age')

const onSubmit = handleSubmit(async (values) => {
  console.log('Form submitted:', values)
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(values)
  })
  const data = await res.json()
  console.log('Created:', data)
})
</script>

<style scoped>
.error {
  color: red;
  font-size: 0.875rem;
}
</style>

Styling Options

Scoped Styles

Vue’s scoped styles are built-in:
<template>
  <button class="btn">Click me</button>
</template>

<style scoped>
.btn {
  background: #42b983;
  color: white;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
}

/* Deep selector - style child components */
:deep(.child-class) {
  color: red;
}

/* Slotted selector - style slotted content */
:slotted(p) {
  margin: 0;
}

/* Global selector from scoped style */
:global(.global-class) {
  font-weight: bold;
}
</style>

CSS Modules

<template>
  <button :class="$style.button">Click me</button>
  <p :class="[$style.text, $style.bold]">Multiple classes</p>
</template>

<style module>
.button {
  background: #42b983;
  color: white;
}

.text {
  color: #333;
}

.bold {
  font-weight: bold;
}
</style>

Tailwind CSS

Install Tailwind:
cd frontend
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Configure tailwind.config.js:
export default {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        primary: '#42b983',
      },
    },
  },
  plugins: [],
}
Add to src/styles/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn-primary {
    @apply bg-primary text-white px-4 py-2 rounded hover:bg-green-600 transition;
  }
}
Use in components:
<template>
  <div class="container mx-auto px-4">
    <h1 class="text-4xl font-bold text-primary">Hello Vue!</h1>
    <button class="btn-primary">
      Click me
    </button>
  </div>
</template>

Performance Optimization

v-once and v-memo

<template>
  <!-- Render once, never update -->
  <p v-once>{{ initialMessage }}</p>

  <!-- Memoize based on dependencies (like React.memo) -->
  <div v-memo="[user.id, user.name]">
    <p>{{ user.name }}</p>
    <p>{{ user.email }}</p>
  </div>
</template>

Async Components

// Lazy load components
const AdminPanel = defineAsyncComponent(() => import('./AdminPanel.vue'))
<template>
  <Suspense>
    <AdminPanel v-if="isAdmin" />
    <template #fallback>
      <div>Loading admin panel...</div>
    </template>
  </Suspense>
</template>

Virtual Scrolling

cd frontend
npm install vue-virtual-scroller
<template>
  <RecycleScroller
    :items="items"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item">{{ item.name }}</div>
  </RecycleScroller>
</template>

<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const items = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
})))
</script>

Error Handling

Error Boundary (Composition)

<!-- ErrorBoundary.vue -->
<template>
  <div v-if="error" class="error-boundary">
    <h2>Something went wrong</h2>
    <p>{{ error.message }}</p>
    <button @click="reset">Try again</button>
  </div>
  <slot v-else />
</template>

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)

onErrorCaptured((err) => {
  error.value = err
  return false  // Prevent error from propagating
})

function reset() {
  error.value = null
}
</script>

Global Error Handler

// src/main.ts
const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  console.error('Global error:', err)
  console.error('Component:', instance)
  console.error('Info:', info)

  // Send to error tracking service
  // trackError(err)
}

app.mount('#app')

Complete Real-World Example: Task Manager

Let’s build a complete task management application using Vue, Pinia, Vue Router, and Mizu backend.

Backend (Go)

// app/server/routes.go
package server

import (
	"sync"

	"github.com/go-mizu/mizu"
)

type Task struct {
	ID          int    `json:"id"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Status      string `json:"status"` // "pending", "in-progress", "completed"
	Priority    string `json:"priority"` // "low", "medium", "high"
}

var (
	tasks   = []Task{}
	nextID  = 1
	tasksMu sync.RWMutex
)

func setupRoutes(app *mizu.App) {
	// GET /api/tasks
	app.GET("/api/tasks", func(c *mizu.Ctx) error {
		tasksMu.RLock()
		defer tasksMu.RUnlock()
		return c.JSON(tasks)
	})

	// POST /api/tasks
	app.POST("/api/tasks", func(c *mizu.Ctx) error {
		var task Task
		if err := c.BodyParser(&task); err != nil {
			return c.Status(400).JSON(map[string]string{"error": "Invalid request"})
		}

		tasksMu.Lock()
		task.ID = nextID
		nextID++
		tasks = append(tasks, task)
		tasksMu.Unlock()

		return c.Status(201).JSON(task)
	})

	// PUT /api/tasks/:id
	app.PUT("/api/tasks/:id", func(c *mizu.Ctx) error {
		id := c.ParamInt("id")
		var updates Task
		if err := c.BodyParser(&updates); err != nil {
			return c.Status(400).JSON(map[string]string{"error": "Invalid request"})
		}

		tasksMu.Lock()
		defer tasksMu.Unlock()

		for i, task := range tasks {
			if task.ID == id {
				updates.ID = id
				tasks[i] = updates
				return c.JSON(updates)
			}
		}

		return c.Status(404).JSON(map[string]string{"error": "Task not found"})
	})

	// DELETE /api/tasks/:id
	app.DELETE("/api/tasks/:id", func(c *mizu.Ctx) error {
		id := c.ParamInt("id")

		tasksMu.Lock()
		defer tasksMu.Unlock()

		for i, task := range tasks {
			if task.ID == id {
				tasks = append(tasks[:i], tasks[i+1:]...)
				return c.Status(204).Send(nil)
			}
		}

		return c.Status(404).JSON(map[string]string{"error": "Task not found"})
	})
}

Frontend - Store

// src/stores/tasks.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface Task {
  id: number
  title: string
  description: string
  status: 'pending' | 'in-progress' | 'completed'
  priority: 'low' | 'medium' | 'high'
}

export type TaskFilter = 'all' | 'pending' | 'in-progress' | 'completed'

export const useTaskStore = defineStore('tasks', () => {
  const tasks = ref<Task[]>([])
  const loading = ref(false)
  const filter = ref<TaskFilter>('all')

  const filteredTasks = computed(() => {
    if (filter.value === 'all') return tasks.value
    return tasks.value.filter(t => t.status === filter.value)
  })

  const tasksByPriority = computed(() => {
    const high = tasks.value.filter(t => t.priority === 'high' && t.status !== 'completed')
    const medium = tasks.value.filter(t => t.priority === 'medium' && t.status !== 'completed')
    const low = tasks.value.filter(t => t.priority === 'low' && t.status !== 'completed')
    return { high, medium, low }
  })

  async function fetchTasks() {
    loading.value = true
    try {
      const res = await fetch('/api/tasks')
      tasks.value = await res.json()
    } finally {
      loading.value = false
    }
  }

  async function addTask(task: Omit<Task, 'id'>) {
    const res = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(task)
    })
    const newTask = await res.json()
    tasks.value.push(newTask)
  }

  async function updateTask(id: number, updates: Partial<Task>) {
    const task = tasks.value.find(t => t.id === id)
    if (!task) return

    const updated = { ...task, ...updates }
    const res = await fetch(`/api/tasks/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updated)
    })
    const newTask = await res.json()

    const index = tasks.value.findIndex(t => t.id === id)
    tasks.value[index] = newTask
  }

  async function deleteTask(id: number) {
    await fetch(`/api/tasks/${id}`, { method: 'DELETE' })
    tasks.value = tasks.value.filter(t => t.id !== id)
  }

  return {
    tasks,
    loading,
    filter,
    filteredTasks,
    tasksByPriority,
    fetchTasks,
    addTask,
    updateTask,
    deleteTask
  }
})

Frontend - Components

<!-- src/components/TaskForm.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="task-form">
    <input v-model="title" placeholder="Task title" required />

    <textarea v-model="description" placeholder="Description" />

    <select v-model="priority" required>
      <option value="low">Low Priority</option>
      <option value="medium">Medium Priority</option>
      <option value="high">High Priority</option>
    </select>

    <button type="submit">Add Task</button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useTaskStore } from '@/stores/tasks'

const taskStore = useTaskStore()

const title = ref('')
const description = ref('')
const priority = ref<'low' | 'medium' | 'high'>('medium')

async function handleSubmit() {
  await taskStore.addTask({
    title: title.value,
    description: description.value,
    status: 'pending',
    priority: priority.value
  })

  title.value = ''
  description.value = ''
  priority.value = 'medium'
}
</script>

<style scoped>
.task-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1rem;
  background: #f5f5f5;
  border-radius: 8px;
}

input, textarea, select {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}
</style>
<!-- src/components/TaskItem.vue -->
<template>
  <div class="task-item" :class="[task.priority, task.status]">
    <div class="task-content">
      <h3>{{ task.title }}</h3>
      <p>{{ task.description }}</p>
      <span class="priority-badge">{{ task.priority }}</span>
    </div>

    <div class="task-actions">
      <select :value="task.status" @change="updateStatus">
        <option value="pending">Pending</option>
        <option value="in-progress">In Progress</option>
        <option value="completed">Completed</option>
      </select>

      <button @click="deleteTask" class="delete-btn">Delete</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useTaskStore, type Task } from '@/stores/tasks'

const props = defineProps<{ task: Task }>()
const taskStore = useTaskStore()

function updateStatus(e: Event) {
  const status = (e.target as HTMLSelectElement).value
  taskStore.updateTask(props.task.id, { status })
}

function deleteTask() {
  if (confirm('Delete this task?')) {
    taskStore.deleteTask(props.task.id)
  }
}
</script>

<style scoped>
.task-item {
  display: flex;
  justify-content: space-between;
  padding: 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  margin-bottom: 1rem;
}

.task-item.high {
  border-left: 4px solid #ef4444;
}

.task-item.medium {
  border-left: 4px solid #f59e0b;
}

.task-item.low {
  border-left: 4px solid #10b981;
}

.task-item.completed {
  opacity: 0.6;
}

.task-item.completed h3 {
  text-decoration: line-through;
}

.priority-badge {
  display: inline-block;
  padding: 0.25rem 0.5rem;
  background: #e5e7eb;
  border-radius: 4px;
  font-size: 0.75rem;
  text-transform: uppercase;
}

.task-actions {
  display: flex;
  gap: 0.5rem;
  align-items: flex-start;
}

.delete-btn {
  background: #ef4444;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
}
</style>
<!-- src/pages/Tasks.vue -->
<template>
  <div class="tasks-page">
    <h1>Task Manager</h1>

    <TaskForm />

    <div class="filters">
      <button
        v-for="f in filters"
        :key="f"
        :class="{ active: filter === f }"
        @click="filter = f"
      >
        {{ f }}
      </button>
    </div>

    <div v-if="loading">Loading tasks...</div>

    <div v-else-if="filteredTasks.length === 0" class="empty">
      No tasks found
    </div>

    <div v-else class="task-list">
      <TaskItem
        v-for="task in filteredTasks"
        :key="task.id"
        :task="task"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/tasks'
import TaskForm from '@/components/TaskForm.vue'
import TaskItem from '@/components/TaskItem.vue'

const taskStore = useTaskStore()
const { filteredTasks, loading, filter } = storeToRefs(taskStore)

const filters = ['all', 'pending', 'in-progress', 'completed'] as const

onMounted(() => {
  taskStore.fetchTasks()
})
</script>

<style scoped>
.tasks-page {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.filters {
  display: flex;
  gap: 0.5rem;
  margin: 2rem 0;
}

.filters button {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  text-transform: capitalize;
}

.filters button.active {
  background: #42b983;
  color: white;
  border-color: #42b983;
}

.empty {
  text-align: center;
  padding: 2rem;
  color: #666;
}
</style>

Building for Production

make build
This runs:
  1. cd frontend && npm run build - Builds Vue app to dist/
  2. go build -o bin/server cmd/server/main.go - Builds Go binary with embedded frontend
Run in production:
MIZU_ENV=production ./bin/server

Troubleshooting

HMR Not Working

Problem: Changes in Vue files don’t hot-reload. Solution:
  • Check Vite dev server is running on port 5173
  • Verify vite.config.ts has correct HMR config:
    server: {
      hmr: {
        clientPort: 3000  // Must match Mizu port
      }
    }
    
  • Check browser console for WebSocket errors

Router 404 in Production

Problem: Refreshing on /about gives 404 in production. Solution: The frontend middleware handles this automatically with SPA fallback. Verify frontend.Options:
app.Use(frontend.WithOptions(frontend.Options{
    Mode: frontend.ModeAuto,
    FS:   dist,
    // ...
}))

State Not Persisting

Problem: Pinia state resets on page refresh. Solution: Use pinia-plugin-persistedstate:
npm install pinia-plugin-persistedstate
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// In store
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true  // Enable persistence
})

TypeScript Errors with Refs

Problem: TypeScript complains about .value. Solution: Ensure you’re using .value in <script> but NOT in <template>:
<script setup lang="ts">
const count = ref(0)
count.value++  // ✅ Correct
</script>

<template>
  {{ count }}  <!-- ✅ Correct, no .value -->
  {{ count.value }}  <!-- ❌ Wrong in template -->
</template>

Props Not Reactive

Problem: Mutating props doesn’t update child component. Solution: Props are one-way. Use emit to notify parent, or use v-model:
<!-- Child -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{ 'update:count': [value: number] }>()

function increment() {
  emit('update:count', props.count + 1)
}
</script>

<!-- Parent -->
<template>
  <Child v-model:count="count" />
</template>

When to Choose Vue

Choose Vue if:
  • You want a gentle learning curve with great documentation
  • You prefer Single File Components (.vue files)
  • You need a progressive framework (start small, scale up)
  • You want official state management (Pinia) and routing (Vue Router)
  • You value developer experience and tooling (Vite, DevTools)
  • You’re building apps of any size (Vue scales from simple to complex)
Consider alternatives if:
  • React: You need the largest ecosystem or you’re integrating with existing React code
  • Svelte: You want the smallest bundle size and compile-time optimizations
  • Angular: You need a full framework with everything included (batteries-included approach)
  • HTMX/Alpine: You want to enhance server-rendered HTML with minimal JavaScript

Next Steps