Validation Patterns
Client-side validation gives users immediate feedback before data reaches the server. The goal is not to replace server-side validation (which must always exist) but to catch errors early, reduce failed submissions, and guide users toward correct input. The standard pattern is: validate on blur, revalidate on input, submit only when valid.
Manual Validation with Computed Properties
For simple forms, computed properties can derive validation state from the reactive form data without any library:
<script setup lang="ts">
const form = reactive({
email: '',
password: '',
confirmPassword: ''
})
const touched = reactive({
email: false,
password: false,
confirmPassword: false
})
const errors = computed(() => ({
email: !form.email
? 'Email is required'
: !form.email.includes('@')
? 'Enter a valid email address'
: '',
password: !form.password
? 'Password is required'
: form.password.length < 8
? 'Password must be at least 8 characters'
: '',
confirmPassword: form.confirmPassword !== form.password
? 'Passwords do not match'
: ''
}))
const isValid = computed(() =>
Object.values(errors.value).every((e) => e === '')
)
function handleSubmit() {
// Mark all fields as touched to show errors
Object.keys(touched).forEach((key) => {
touched[key as keyof typeof touched] = true
})
if (!isValid.value) return
// Submit the form
console.log('Submitting:', { ...form })
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label>Email</label>
<input
v-model="form.email"
type="email"
@blur="touched.email = true"
/>
<p v-if="touched.email && errors.email" class="field-error">
{{ errors.email }}
</p>
</div>
<div>
<label>Password</label>
<input
v-model="form.password"
type="password"
@blur="touched.password = true"
/>
<p v-if="touched.password && errors.password" class="field-error">
{{ errors.password }}
</p>
</div>
<div>
<label>Confirm Password</label>
<input
v-model="form.confirmPassword"
type="password"
@blur="touched.confirmPassword = true"
/>
<p v-if="touched.confirmPassword && errors.confirmPassword" class="field-error">
{{ errors.confirmPassword }}
</p>
</div>
<button type="submit" :disabled="!isValid">Sign Up</button>
</form>
</template>
<style scoped>
.field-error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
This approach works for small forms but becomes unwieldy as the number of fields and rules grows. Libraries solve this scaling problem.
Schema-Based Validation with Zod
Zod defines validation schemas in TypeScript. The schema acts as both a type definition and a runtime validator:
// schemas/registration.ts
import { z } from 'zod'
export const registrationSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
email: z
.string()
.email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[0-9]/, 'Must contain at least one number'),
confirmPassword: z.string(),
age: z
.number({ invalid_type_error: 'Age must be a number' })
.min(13, 'You must be at least 13 years old')
.max(120, 'Enter a valid age')
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
export type RegistrationData = z.infer<typeof registrationSchema>
Using the schema in a component:
<script setup lang="ts">
import { registrationSchema, type RegistrationData } from '~/schemas/registration'
const form = reactive<RegistrationData>({
username: '',
email: '',
password: '',
confirmPassword: '',
age: 0
})
const fieldErrors = ref<Record<string, string>>({})
function validateField(field: keyof RegistrationData) {
const result = registrationSchema.safeParse(form)
if (result.success) {
delete fieldErrors.value[field]
} else {
const fieldError = result.error.issues.find((i) => i.path[0] === field)
if (fieldError) {
fieldErrors.value[field] = fieldError.message
} else {
delete fieldErrors.value[field]
}
}
}
function handleSubmit() {
const result = registrationSchema.safeParse(form)
if (!result.success) {
fieldErrors.value = {}
for (const issue of result.error.issues) {
const field = issue.path[0] as string
if (!fieldErrors.value[field]) {
fieldErrors.value[field] = issue.message
}
}
return
}
// result.data is typed as RegistrationData
submitRegistration(result.data)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label>Username</label>
<input
v-model="form.username"
type="text"
@blur="validateField('username')"
@input="fieldErrors.username && validateField('username')"
/>
<p v-if="fieldErrors.username" class="field-error">{{ fieldErrors.username }}</p>
</div>
<!-- Repeat pattern for other fields -->
<button type="submit">Register</button>
</form>
</template>
VeeValidate with Zod Integration
VeeValidate provides Vue-native form handling with built-in support for Zod schemas via the @vee-validate/zod adapter:
<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
role: z.enum(['developer', 'designer', 'manager'], {
errorMap: () => ({ message: 'Select a valid role' })
})
})
)
const { handleSubmit, errors, isSubmitting } = useForm({
validationSchema: schema,
initialValues: {
name: '',
email: '',
role: undefined
}
})
const { value: name } = useField<string>('name')
const { value: email } = useField<string>('email')
const { value: role } = useField<string>('role')
const onSubmit = handleSubmit(async (values) => {
// values is typed based on the Zod schema
await $fetch('/api/team/invite', {
method: 'POST',
body: values
})
})
</script>
<template>
<form @submit="onSubmit">
<div>
<label>Name</label>
<input v-model="name" type="text" />
<p v-if="errors.name" class="field-error">{{ errors.name }}</p>
</div>
<div>
<label>Email</label>
<input v-model="email" type="email" />
<p v-if="errors.email" class="field-error">{{ errors.email }}</p>
</div>
<div>
<label>Role</label>
<select v-model="role">
<option value="" disabled>Select role</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
<p v-if="errors.role" class="field-error">{{ errors.role }}</p>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Sending...' : 'Send Invite' }}
</button>
</form>
</template>
Real-Time vs On-Submit Validation
There are two strategies for when validation runs, and the best approach combines both.
On-Submit Only
All validation runs when the user clicks submit. Simple to implement but frustrating -- users fill out the entire form before learning the first field was wrong.
Real-Time (As You Type)
Every keystroke triggers validation. Responsive but noisy -- showing "Email is required" while the user is still typing the first character is unhelpful.
The Recommended Pattern: Validate on Blur, Revalidate on Input
1. User focuses a field and types -> no validation yet
2. User leaves the field (blur) -> validate, show error if invalid
3. User returns to fix the field (input) -> revalidate on every keystroke
4. Error clears the moment input becomes valid
5. User submits -> validate all fields, show remaining errors
Implementation:
<script setup lang="ts">
const form = reactive({ email: '' })
const touched = reactive({ email: false })
const errors = reactive({ email: '' })
function validate(field: 'email') {
if (field === 'email') {
if (!form.email) errors.email = 'Email is required'
else if (!form.email.includes('@')) errors.email = 'Enter a valid email'
else errors.email = ''
}
}
function onBlur(field: 'email') {
touched[field] = true
validate(field)
}
function onInput(field: 'email') {
// Only revalidate if the field was already touched (showed an error)
if (touched[field]) {
validate(field)
}
}
</script>
<template>
<div>
<input
v-model="form.email"
type="email"
@blur="onBlur('email')"
@input="onInput('email')"
/>
<p v-if="errors.email" class="field-error">{{ errors.email }}</p>
</div>
</template>
Field-Level Error Display
Errors should appear directly below or beside the field they relate to. A summary at the top of the form is useful for accessibility but should not replace inline errors.
<script setup lang="ts">
interface FormErrors {
[key: string]: string
}
const errors = ref<FormErrors>({})
// After a failed submit
function showFormSummary(errors: FormErrors) {
const count = Object.keys(errors).length
return `${count} field${count === 1 ? '' : 's'} need${count === 1 ? 's' : ''} attention`
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div v-if="Object.keys(errors).length" class="error-summary" role="alert">
{{ showFormSummary(errors) }}
</div>
<div :class="{ 'has-error': errors.name }">
<label for="name">Name</label>
<input id="name" v-model="form.name" :aria-invalid="!!errors.name" />
<p v-if="errors.name" class="field-error" role="alert">{{ errors.name }}</p>
</div>
</form>
</template>
<style scoped>
.has-error input {
border-color: #dc2626;
}
.field-error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.error-summary {
background: #fef2f2;
border: 1px solid #fecaca;
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
}
</style>
Sharing Schemas Between Client & Server
Zod schemas work identically on both sides. Define once, validate everywhere:
// shared/schemas/contact.ts
import { z } from 'zod'
export const contactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be at least 10 characters').max(5000)
})
// server/api/contact.post.ts
import { contactSchema } from '~/shared/schemas/contact'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = contactSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
data: result.error.flatten()
})
}
await sendEmail(result.data)
return { success: true }
})
Common Pitfalls
- Validating only on the client. Client-side validation is a UX feature, not a security measure. Any user can bypass it. Always validate on the server.
- Showing errors before the user interacts. Displaying "Email is required" on page load before the user touches the field is hostile. Track touched state.
- Validating on every keystroke from the start. Real-time validation is useful after a field has been blurred, not before. Premature validation shows errors while the user is still typing.
- Generic error messages. "Invalid input" tells the user nothing. Specify what is wrong and how to fix it: "Password must be at least 8 characters."
- Not clearing errors when the field becomes valid. If the user fixes a field and the error persists until they submit again, the form feels broken.
- Forgetting cross-field validation. Password confirmation, date ranges (start before end), and conditional requirements need
.refine()in Zod or custom computed logic.
Key Takeaways
- Simple forms can use computed properties for validation state. Libraries like VeeValidate and Zod scale better for complex forms.
- Zod provides type-safe schemas that work on both client and server.
safeParsereturns a typed result without throwing exceptions. - The recommended UX pattern is validate on blur, revalidate on input, and validate all fields on submit.
- Field-level errors should appear inline, directly below the relevant input. Include ARIA attributes for accessibility.
- VeeValidate integrates with Zod via
@vee-validate/zod, providing Vue-native form handling with schema-based validation. - Client-side validation is for user experience. Server-side validation is for data integrity. Both are required.