Validation rules written twice drift apart. The form says a username needs three characters, the API says four, and the bug report arrives a month later. The fix is to write the rules once, as a schema: a description of what valid data looks like, which a library can check any value against.
You will build a sign-up form in a Nuxt 4.3 app, with Zod 4.3 for the schema and VeeValidate 4.15 for the form state, then reuse the exact same schema in a server route. VeeValidate's official Zod adapter still expects Zod 3, so you will also write a short adapter of your own. Install both libraries with npm install zod vee-validate.
Write the schema in shared/
Nuxt's shared/ folder, at the project root next to app/ and server/, holds code that both the Vue app and the server can import through the #shared alias. That is where the schema lives:
import * as z from 'zod'
export const signUpSchema = z
.object({
username: z
.string()
.trim()
.min(3, 'Use at least 3 characters')
.max(20, 'Use at most 20 characters')
.regex(/^[a-z0-9_]+$/i, 'Use letters, numbers and underscores only')
.transform((name) => name.toLowerCase()),
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword'],
when: (payload) =>
payload.issues.every((issue) => {
const field = issue.path?.[0]
return field !== 'password' && field !== 'confirmPassword'
}),
})
export type SignUpInput = z.input<typeof signUpSchema>
export type SignUpData = z.output<typeof signUpSchema>
Read it from the top:
.trim()removes spaces before the length checks run, so" ab "counts as two characters..min(),.max()and.regex()take their error message as the second argument..transform()changes the value after it has passed every check: the username is stored in lower case whatever the person typed.z.email()is Zod 4's top-level email format. The olderz.string().email()still works but is deprecated..refine()adds a check that involves more than one field. Its function returnstruewhen the data is fine,errorsets the message (Zod 4's replacement for the deprecatedmessageoption), andpathattaches the error toconfirmPasswordso the form can show it under the right input.whendecides whether the refinement runs at all. The section "Control when the password check runs" shows why a form needs it.
Infer the form's types from the schema
Zod infers TypeScript types from the schema, so you never write the form's interface by hand. The last two lines of the file export them, and because of the transform there are two. SignUpInput is what a person types into the form. SignUpData is what you get after validation, with the username trimmed and lower-cased. Here both happen to have the same shape, because the transform returns a string. A transform that returns a number or a Date would make the two types differ. Code that handles submitted data should use the output type.
Check values with safeParse() and group errors by field
A schema checks a value in one of two ways. parse() returns the clean data or throws a ZodError. safeParse() never throws; it returns an object with success, and then either data or error. For forms you almost always want the second, because invalid input is normal there, not exceptional.
A ZodError holds a list of issues, each with a path and a message. z.flattenError() groups them by field, which is the shape a form wants:
import * as z from 'zod'
import { signUpSchema } from '#shared/schemas/sign-up'
const result = signUpSchema.safeParse({
username: 'ada',
email: 'ada@',
password: 'analytical',
confirmPassword: 'analytic',
})
if (!result.success) {
console.log(z.flattenError(result.error).fieldErrors)
// { email: ['Enter a valid email address'], confirmPassword: ['Passwords do not match'] }
}
Both errors appear at once. A malformed email is a failed check on a value of the right type, which Zod treats as continuable: it records the issue and keeps going, so the password refinement still runs.
Control when the password check runs
Not every issue lets Zod keep going. By default, Zod skips a refinement as soon as the object has an issue it cannot continue past, such as a field with the wrong type or no value at all. In a form, a field nobody has reached yet can be missing, so without when the password check would wait until every other field is filled in:
import * as z from 'zod'
const passwords = z
.object({ email: z.email(), password: z.string().min(8), confirmPassword: z.string() })
.refine((d) => d.password === d.confirmPassword, { path: ['confirmPassword'] })
passwords.safeParse({ password: 'hunter22', confirmPassword: 'hunter23' })
// one issue: email, "Invalid input: expected string, received undefined"
import * as z from 'zod'
const passwords = z
.object({ email: z.email(), password: z.string().min(8), confirmPassword: z.string() })
.refine((d) => d.password === d.confirmPassword, {
path: ['confirmPassword'],
when: (payload) => payload.issues.every((i) => i.path?.[0] !== 'password' && i.path?.[0] !== 'confirmPassword'),
})
passwords.safeParse({ password: 'hunter22', confirmPassword: 'hunter23' })
// two issues: email, and confirmPassword
With when, the check runs whenever the two password fields themselves are valid, whatever happens elsewhere. It also keeps "Passwords do not match" hidden while the password fails its own length check. The Zod docs show a variant that calls .pick() inside when, but Zod 4.3 made .pick() and .omit() throw on object schemas that contain refinements, so check the issues list as above instead.
Connect Zod 4 to VeeValidate with a small adapter
VeeValidate keeps the form state: values, errors, touched fields and submission. For validation it accepts a typed schema, an object with a parse() method that reports errors per field path.
The official adapter, toTypedSchema from @vee-validate/zod, wraps a Zod schema in that shape. Its current release, 4.15.1, still lists Zod ^3.24.0 as its peer dependency and reads Zod 3 internals, so do not pair it with Zod 4.
The typed schema contract is small and exported from vee-validate, so writing the adapter yourself takes about twenty lines:
import type { TypedSchema, TypedSchemaError } from 'vee-validate'
import type * as z from 'zod'
export function zodTypedSchema<T extends z.ZodType>(schema: T): TypedSchema<z.input<T>, z.output<T>> {
return {
__type: 'VVTypedSchema',
async parse(values) {
const result = await schema.safeParseAsync(values)
if (result.success) return { value: result.data, errors: [] }
const byPath = new Map<string, string[]>()
for (const issue of result.error.issues) {
const path = issue.path.map(String).join('.')
byPath.set(path, [...(byPath.get(path) ?? []), issue.message])
}
const errors: TypedSchemaError[] = [...byPath].map(([path, messages]) => ({ path, errors: messages }))
return { errors }
},
}
}
It runs the schema, and on failure it groups the messages by field path, which is what VeeValidate expects. Because it calls safeParseAsync, a schema with async refinements (a .refine() whose function returns a promise) works too. The types carry through: VeeValidate learns the input type for the form values and the output type for what it submits.
Build the form with useForm() and useField()
Files in app/utils/ are auto-imported in Nuxt, so the component can call zodTypedSchema directly:
<script setup lang="ts">
import { useField, useForm } from 'vee-validate'
import { signUpSchema } from '#shared/schemas/sign-up'
const { handleSubmit, isSubmitting } = useForm({
validationSchema: zodTypedSchema(signUpSchema),
initialValues: { username: '', email: '', password: '', confirmPassword: '' },
})
const { value: username, errorMessage: usernameError } = useField<string>('username')
const { value: email, errorMessage: emailError } = useField<string>('email')
const { value: password, errorMessage: passwordError } = useField<string>('password')
const { value: confirmPassword, errorMessage: confirmError } = useField<string>('confirmPassword')
const failed = ref(false)
const onSubmit = handleSubmit(async (values) => {
failed.value = false
try {
await $fetch('/api/sign-up', { method: 'POST', body: values })
await navigateTo('/welcome')
} catch {
failed.value = true
}
})
</script>
What the script sets up:
initialValuesgives every field an empty string from the start, so no key is ever missing. The schema does not rely on this: thanks towhen, the password check also runs in a form withoutinitialValues.useFieldconnects one value and its first error message to an input.handleSubmitvalidates the whole form and only calls your function when everything passes, withvaluestyped as the schema's output: the username already trimmed and lower-cased.
<template>
<form novalidate @submit="onSubmit">
<label for="username">Username</label>
<input id="username" v-model="username" autocomplete="username">
<p v-if="usernameError">{{ usernameError }}</p>
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="email">
<p v-if="emailError">{{ emailError }}</p>
<label for="password">Password</label>
<input id="password" v-model="password" type="password" autocomplete="new-password">
<p v-if="passwordError">{{ passwordError }}</p>
<label for="confirm">Repeat password</label>
<input id="confirm" v-model="confirmPassword" type="password" autocomplete="new-password">
<p v-if="confirmError">{{ confirmError }}</p>
<button type="submit" :disabled="isSubmitting">Create account</button>
<p v-if="failed" role="alert">We could not create your account. Please try again.</p>
</form>
</template>
novalidate switches off the browser's own email bubble, so only the schema's messages appear. The @submit listener needs no .prevent: onSubmit, the function that handleSubmit returned, calls preventDefault() itself.
Each change runs the whole schema, but VeeValidate shows messages only for fields the person has already changed, and for every field after a submit attempt. Nobody sees errors for inputs they have not reached yet, and "Passwords do not match" clears as soon as the two passwords agree, whichever one was edited.
Some checks only the server can make, such as a username that is already taken. Return a 409 from the route for that case, and in the catch block call VeeValidate's setErrors({ username: 'That name is taken' }), which useForm also returns. The message then appears under the input like any other.
Reuse the schema in a Nuxt server route
The browser check is for the person filling in the form. The server check is the one that protects your data, because anyone can send a request without your form. In a Nuxt server route, h3's readValidatedBody() reads the JSON body and passes it to a validation function:
import { signUpSchema } from '#shared/schemas/sign-up'
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(event, signUpSchema.parse)
// data is SignUpData: checked, trimmed and lower-cased
return { username: data.username }
})
If parse throws, h3 turns the error into a 400 response with the status message Validation Error, and your handler never runs.
Why one schema on both sides
With one schema, a change to the username length is a one-line edit that reaches the form, the server and both types in the same commit. The messages match too: the error a person sees in the browser is the same text the API would return, so support has one wording to look up.
The shared file has one constraint. Code in shared/ cannot import Vue or Nitro code, so the schema stays plain Zod. That is also the right boundary: a database lookup belongs in the server route, after readValidatedBody() has done the checks both sides can run.