VeeValidate with Zod: Keeping Vue Form Rules Out of Components
VeeValidate 4 lets you declare form rules once in a Zod schema and route failures to fields. Here is a worked example, the checks that prove it works, and the trade-offs.
04 Sept 2025, 23:05 UTC

The symptom: rules duplicated across template, script, and API
A typical Vue registration form starts small. A required attribute here, a regex check in a watcher there, and a server-side check that returns "username taken". Within a few sprints the same rule exists in three places: the template, the component's script block, and the backend. When the password policy changes, you edit all three and hope you found them all.
VeeValidate 4 (the Composition API rewrite; v3 used mixins and a different API) offers a way out: declare the rules once as a schema, and let the library translate schema failures into per-field error messages. This post covers that path, the version assumptions behind it, and where it stops being a good idea.
What VeeValidate actually does with a schema
Two pieces matter:
useForm()creates the form state — values, errors, submission handling.- An adapter such as
toTypedSchema()converts a Zod or Yup schema into the shape VeeValidate expects, then maps each schema issue to the field path it belongs to.
The important consequence: VeeValidate does not re-implement your rules. It runs the schema and routes the resulting issues to the right field. Your validation logic stays in a plain module with no Vue imports, which means it can be unit-tested without mounting a component.
Version and package assumptions
The example below assumes Vue 3, VeeValidate v4, Zod v3, and the official adapter package @vee-validate/zod. Package names and adapter exports have changed between majors, so confirm the adapter name against the registry entry for the version you install before copying the import. If you are still on VeeValidate v3, none of this applies — v3 used a mixin-based API and a different provider model.
A worked example: registration schema
Run this from the project root, with the Vue project already created. It modifies package.json and the lockfile, so commit or stash first if that matters to you.
npm install vee-validate zod @vee-validate/zod
Define the rules in a framework-free module:
// src/validation.ts
import { z } from 'zod';
export const registrationSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export type RegistrationInput = z.infer<typeof registrationSchema>;
Then bind it in the component:
<script setup lang="ts">
import { useForm, defineField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { registrationSchema } from './validation';
const { handleSubmit, errors } = useForm({
validationSchema: toTypedSchema(registrationSchema),
});
const [username, usernameAttrs] = defineField('username');
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const onSubmit = handleSubmit((values) => {
// Runs only after the schema passes.
return saveRegistration(values);
});
</script>
<template>
<form @submit="onSubmit">
<input v-model="username" v-bind="usernameAttrs" type="text" />
<span v-if="errors.username">{{ errors.username }}</span>
<input v-model="email" v-bind="emailAttrs" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input v-model="password" v-bind="passwordAttrs" type="password" />
<span v-if="errors.password">{{ errors.password }}</span>
<button type="submit">Register</button>
</form>
</template>
Note the split of responsibilities: defineField returns the bound value plus the event handlers VeeValidate needs, while errors is a computed object keyed by field path. The exact attribute set returned by defineField has shifted between minor releases, so inspect the rendered input in devtools rather than assuming a particular attribute is present.
Checks that tell you it is wired correctly
- Type a two-character username and move focus away.
errors.usernameshould show the Zod message text you wrote, not a generic one. - Submit with an empty email. The
onSubmitcallback should not run —handleSubmitstops the handler when the schema fails. Put a temporary log inside the callback to confirm. - Hover
valuesin your editor. It should resolve to the type inferred from the schema. If it isany, the adapter import is wrong. - Run the schema module through a plain Node test (no Vue) with an invalid object and assert the issue paths. This is the payoff of keeping rules framework-free.
Where this approach costs you
| Concern | Schema-based | Inline rules |
|---|---|---|
| Bundle size | Adds Zod or Yup plus the adapter | No extra dependency |
| Large forms | Full-schema validation on every keystroke can lag | Per-field rules validate independently |
| Server-side checks | Needs an escape hatch (below) | Same problem |
| Testability | Rules testable without mounting | Often requires component tests |
Two limitations are worth planning for. First, validation timing: if the default triggers validation on every input event, a form with dozens of fields can feel sluggish. VeeValidate lets you change when validation runs, but the option names differ across versions — check the current configuration docs rather than copying a snippet. Second, asynchronous checks such as "is this username taken" do not fit cleanly inside a synchronous schema. The practical pattern is to keep the schema synchronous, run the availability request in your submit handler or on blur, and attach the failure with setFieldError('username', 'Username is already taken') from useForm.
What to do next
Pick one existing form and move only its rules into a schema module — leave the template untouched at first. If the error messages appear in the right places and the submit callback stays blocked on invalid input, you have the decoupling you wanted. If the form is small and unlikely to grow, the extra dependency is probably not worth it; inline rules are a legitimate choice, not a mistake.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.