Build forms that validate themselves — v-model with modifiers, Zod schemas that check data at runtime, and VeeValidate wired to Zod for typed, validated forms.
Why: v-model keeps an input and a ref in sync in both directions — type and the ref updates, set the ref and the input updates. Modifiers tweak the sync: .trim strips whitespace, .number converts to a number, .lazy waits until the field loses focus. Note: @submit.prevent stops the browser's full-page reload.
<!-- app/pages/signup.vue — lives at /signup -->
<script setup lang="ts">
import { ref } from 'vue'
const email = ref('')
const submitted = ref(false)
</script>
<template>
<!-- After submit, greet the user by the part before the @ -->
<p v-if="submitted">Welcome, {{ email.split('@')[0] }}</p>
<!-- .prevent stops the browser's full-page reload -->
<form v-else @submit.prevent="submitted = true">
<!-- .trim strips accidental spaces while typing -->
<input v-model.trim="email" type="email" placeholder="you@example.com" />
<button :disabled="!email.includes('@')">Sign up</button>
</form>
</template>Why: TypeScript types vanish at runtime — fetch responses and form input are typed as whatever you claim. A Zod schema actually checks the data, and z.infer derives the static type from it so you never write the shape twice.
$ pnpm add zodimport { z } from 'zod'
// The schema both describes the shape and checks real data against it
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member']).default('member'),
})
type User = z.infer<typeof UserSchema> // static type, derived for free
async function loadUser(): Promise<User | null> {
const raw = await fetch('/api/me').then((r) => r.json())
// .safeParse returns a result object; .parse throws instead
const result = UserSchema.safeParse(raw)
if (!result.success) {
console.log(result.error.issues)
return null
}
return result.data // fully typed as User
}Why: VeeValidate manages field state, errors, and submit state for you, and toTypedSchema plugs your Zod schema in as the validation source — one schema drives the types, the rules, and the error messages.
$ pnpm add vee-validate @vee-validate/zod zod<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
})
const { handleSubmit, errors, defineField, isSubmitting } = useForm({
validationSchema: toTypedSchema(schema), // schema = the rules
})
// defineField wires an input to the form by its name
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
// Only runs when the data passes the schema
const onSubmit = handleSubmit((data) => console.log(data))
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" v-bind="emailAttrs" placeholder="Email" />
<!-- The message comes straight from the schema above -->
<p v-if="errors.email">{{ errors.email }}</p>
<input
v-model="password"
v-bind="passwordAttrs"
type="password"
placeholder="Password"
/>
<p v-if="errors.password">{{ errors.password }}</p>
<button :disabled="isSubmitting">Log in</button>
</form>
</template>