Vue 3 forms with Zod: one schema, client and server
How to validate forms in Vue 3 and Nuxt with a single Zod schema shared between browser and API — no form library, and no duplicated rules that quietly drift apart.
Vue 3 forms with Zod: one schema, client and server
There is a bug in almost every form I review, and hardly anyone has it on their radar. The user fills the form in, everything looks green, they hit send — and get back a generic server error.
The cause lives in two pieces of code nobody ever reads side by side. In the component:
// ❌ Anti-pattern: the client's rules
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function isValidName() { return nameVal.value.trim().length > 0 }
function isValidEmail() { return emailRe.test(emailVal.value.trim()) }
function isValidMsg() { return msgVal.value.trim().length >= 8 }
And in the API, months later, someone added limits:
// server/api/contact.post.ts
name: z.string().min(1).max(100),
email: z.string().email().max(254),
message: z.string().min(1).max(5000),
Read them carefully. The client never checks a maximum length for the name; the server caps it at 100. The client demands 8 characters of message; the server accepts 1. And each one decides what counts as a valid email by different rules.
A user with a long name sails through client validation and crashes into the server. It is not that a check is missing: there are two sources of truth, and they diverge the moment either one is touched.
One schema, two environments
The fix is not keeping two lists in sync by hand. It is having only one.
// utils/contactSchema.ts
import { z } from 'zod'
export const contactSchema = z.object({
name: z.string().trim().min(1, 'Missing name').max(100, 'Name too long'),
email: z.string().trim().email('Invalid email address').max(254, 'Email too long'),
message: z.string().trim().min(8, 'Message too short').max(5000, 'Message too long'),
url: z.string().default(''),
})
export type ContactInput = z.infer<typeof contactSchema>
Both sides import that file. The component for immediate feedback, the API because it trusts nobody. When the name limit becomes 120 tomorrow, you change it in one place and both sides agree by construction.
.trim() belongs in the schema, not in the component. If you clean up on the client and validate on the server without cleaning, a message of pure whitespace passes the browser's min(8) and arrives at the server as an empty string. Putting trim in the schema means both sides normalise identically.
z.infer closes the loop: ContactInput is the payload type, derived from the schema. There is no separate interface to keep updated.
Errors per field, not an array
safeParse returns every problem in error.issues, but a form needs to know which error goes under which input. The conversion is one line:
// composables/useContactForm.ts
import { contactSchema, type ContactInput } from '~/utils/contactSchema'
type Errors = Partial<Record<keyof ContactInput, string>>
export function useContactForm() {
const form = reactive<ContactInput>({ name: '', email: '', message: '', url: '' })
const errors = ref<Errors>({})
const touched = ref<Set<string>>(new Set())
function validate(): boolean {
const result = contactSchema.safeParse(form)
errors.value = result.success
? {}
: Object.fromEntries(
result.error.issues.map(issue => [issue.path[0], issue.message]),
)
return result.success
}
return { form, errors, touched, validate }
}
Partial<Record<keyof ContactInput, string>> is not decoration: rename message to body in the schema tomorrow and TypeScript flags every template still reading errors.message. The form's type and its error type move together.
When to show the error
This is the part no library decides for you, because it is a product decision: validating on every keystroke is hostile. The user types the first letter of their email and you are already telling them it is wrong.
The rule I use: validate on blur, and only after that on every keystroke.
function validateField(field: keyof ContactInput) {
const result = contactSchema.safeParse(form)
const issue = result.success
? undefined
: result.error.issues.find(i => i.path[0] === field)
errors.value = { ...errors.value, [field]: issue?.message }
}
function onBlur(field: keyof ContactInput) {
touched.value.add(field)
validateField(field)
}
function onInput(field: keyof ContactInput) {
// Only revalidate what the user has already visited: fixing an error
// should clear it instantly, but an untouched field must stay quiet.
if (touched.value.has(field)) validateField(field)
}
That asymmetry is the whole trick. A field you never touched stays silent. One that already gave you an error confirms instantly that you have fixed it — which is exactly when immediate feedback helps rather than nags.
In the template, errors are wired to the input for accessibility:
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
:aria-invalid="Boolean(errors.email)"
:aria-describedby="errors.email ? 'email-error' : undefined"
@blur="onBlur('email')"
@input="onInput('email')"
>
<p v-if="errors.email" id="email-error" role="alert">{{ errors.email }}</p>
aria-describedby is what makes a screen reader announce the error when it reaches the field. Without it, the message exists visually and does not exist for anyone navigating by keyboard and voice.
The server trusts nothing
All client-side validation is a courtesy. Anyone can curl your endpoint directly. The same schema, on the server, is what actually protects you:
// server/api/contact.post.ts
import { contactSchema } from '~/utils/contactSchema'
export default defineEventHandler(async (event) => {
const parsed = contactSchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: parsed.error.issues[0]?.message ?? 'Invalid input',
})
}
const { name, email, message, url } = parsed.data
// …
})
After safeParse, parsed.data is typed as ContactInput. No as, no any, and no extra fields: Zod strips unknown keys by default, so nobody slips an isAdmin: true into your body.
The honeypot: a field that validates as empty
The url field in the schema is not an oversight. It is a honeypot: a hidden input a human never sees and bots fill in by reflex.
<!-- Hidden from humans, visible to bots -->
<input v-model="form.url" name="url" tabindex="-1" autocomplete="off" aria-hidden="true">
if (parsed.data.url) {
// Respond 200: a bot that receives an error retries with another strategy
return { ok: true }
}
The detail that matters is the response. Returning 400 teaches the bot it has been caught and to try something else. Returning 200 lets it believe it worked, and it does not come back.
Be careful how you hide it: modern bots detect display: none and the hidden attribute. Move it off-screen with absolute positioning instead.
So when do you need a library?
| Scenario | Solution |
|---|---|
| 3–6 fields, single step | Zod plus your own composable |
| Dynamic arrays of fields | Zod plus a composable, careful with keys |
| Multi-step forms with state | VeeValidate or FormKit |
| Forms generated from a schema | FormKit |
The real cost of a form library is not its kilobytes: it is that it makes you express validation in its format, and then you are back to two sources of truth — the backend schema and the form's rules. If you already use Zod to validate the API, keeping that same schema on the client is less work than integrating it with the library.
Conclusion
- One schema, imported by client and server. Everything else derives from it.
triminside the schema, so both sides normalise the same way.- Errors as an object typed per field, not an array of issues.
- Validate on blur; revalidate on input only for fields already touched.
aria-invalidandaria-describedby, or the error does not exist for a good share of your users.- The server always validates, however thorough the client was.
A contact form does not need a library. It needs the rule to exist exactly once.
Questions, or want to see a specific case? Write to me at hola@miguel-jimenez.dev.