---
title: "Schema validation with Zod 4 and VeeValidate · vuejs.school"
description: "Write one Zod 4 schema for a sign-up form, connect it to VeeValidate in the browser, and run the same schema again in a Nuxt server route."
canonical_url: "https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate"
meta:
  "og:description": "Write one Zod 4 schema for a sign-up form, connect it to VeeValidate in the browser, and run the same schema again in a Nuxt server route."
  "og:image": "https://vuejs.school/_og/d/a_~U2NoZW1hIHZhbGlkYXRpb24gd2l0aCBab2QgNCBhbmQgVmVlVmFsaWRhdGUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Schema%2Bvalidation%2Bwith%2BZod%2B4%2Band%2BVeeValidate%2Cdescription_~V3JpdGUgb25lIFpvZCA0IHNjaGVtYSBmb3IgYSBzaWduLXVwIGZvcm0sIGNvbm5lY3QgaXQgdG8gVmVlVmFsaWRhdGUgaW4gdGhlIGJyb3dzZXIsIGFuZCBydW4gdGhlIHNhbWUgc2NoZW1hIGFnYWluIGluIGEgTnV4dCBzZXJ2ZXIgcm91dGUu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zY2hlbWEtdmFsaWRhdGlvbi13aXRoLXpvZC00LWFuZC12ZWV2YWxpZGF0ZSI%2Cs_LHK4UF9extHBV9iZ.png"
  "og:image:alt": "Schema validation with Zod 4 and VeeValidate · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Schema validation with Zod 4 and VeeValidate · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~U2NoZW1hIHZhbGlkYXRpb24gd2l0aCBab2QgNCBhbmQgVmVlVmFsaWRhdGUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Schema%2Bvalidation%2Bwith%2BZod%2B4%2Band%2BVeeValidate%2Cdescription_~V3JpdGUgb25lIFpvZCA0IHNjaGVtYSBmb3IgYSBzaWduLXVwIGZvcm0sIGNvbm5lY3QgaXQgdG8gVmVlVmFsaWRhdGUgaW4gdGhlIGJyb3dzZXIsIGFuZCBydW4gdGhlIHNhbWUgc2NoZW1hIGFnYWluIGluIGEgTnV4dCBzZXJ2ZXIgcm91dGUu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zY2hlbWEtdmFsaWRhdGlvbi13aXRoLXpvZC00LWFuZC12ZWV2YWxpZGF0ZSI%2Cs_LHK4UF9extHBV9iZ.png"
  "twitter:image:alt": "Schema validation with Zod 4 and VeeValidate · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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:

*shared/schemas/sign-up.ts*

```
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 older `z.string().email()` still works but is deprecated.
- `.refine()` adds a check that involves more than one field. Its function returns `true` when the data is fine, `error` sets the message (Zod 4's replacement for the deprecated `message` option), and `path` attaches the error to `confirmPassword` so the form can show it under the right input.
- `when` decides 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:

*passwords.ts**Breaks***

```
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"
```

*passwords.ts**Works***

```
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:

*app/utils/zodTypedSchema.ts*

```
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:

*app/components/SignUpForm.vue*

```
<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:

- `initialValues` gives every field an empty string from the start, so no key is ever missing. The schema does not rely on this: thanks to `when`, the password check also runs in a form without `initialValues`.
- `useField` connects one value and its first error message to an input.
- `handleSubmit` validates the whole form and only calls your function when everything passes, with `values` typed as the schema's output: the username already trimmed and lower-cased.

*app/components/SignUpForm.vue*

```
<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:

*server/api/sign-up.post.ts*

```
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.

**Check it stuck**3 questions from the Schema validation with Zod quiz.

[**Practice now**](https://vuejs.school/learn/forms/schema-validation-with-zod?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Accessible form errors: aria-describedby, live regions and focus**](https://vuejs.school/community/blog/accessible-form-errors) [Tutorial · 6 min read**Multi-step forms with a Pinia store**](https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-19","description":"Write one Zod 4 schema for a sign-up form, connect it to VeeValidate in the browser, and run the same schema again in a Nuxt server route.","name":"Schema validation with Zod 4 and VeeValidate","url":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate","breadcrumb":{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-19","description":"Write one Zod 4 schema for a sign-up form, connect it to VeeValidate in the browser, and run the same schema again in a Nuxt server route.","headline":"Schema validation with Zod 4 and VeeValidate","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~U2NoZW1hIHZhbGlkYXRpb24gd2l0aCBab2QgNCBhbmQgVmVlVmFsaWRhdGUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Schema%2Bvalidation%2Bwith%2BZod%2B4%2Band%2BVeeValidate%2Cdescription_~V3JpdGUgb25lIFpvZCA0IHNjaGVtYSBmb3IgYSBzaWduLXVwIGZvcm0sIGNvbm5lY3QgaXQgdG8gVmVlVmFsaWRhdGUgaW4gdGhlIGJyb3dzZXIsIGFuZCBydW4gdGhlIHNhbWUgc2NoZW1hIGFnYWluIGluIGEgTnV4dCBzZXJ2ZXIgcm91dGUu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zY2hlbWEtdmFsaWRhdGlvbi13aXRoLXpvZC00LWFuZC12ZWV2YWxpZGF0ZSI%2Cs_LHK4UF9extHBV9iZ.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Schema validation with Zod 4 and VeeValidate","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~U2NoZW1hIHZhbGlkYXRpb24gd2l0aCBab2QgNCBhbmQgVmVlVmFsaWRhdGUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Schema%2Bvalidation%2Bwith%2BZod%2B4%2Band%2BVeeValidate%2Cdescription_~V3JpdGUgb25lIFpvZCA0IHNjaGVtYSBmb3IgYSBzaWduLXVwIGZvcm0sIGNvbm5lY3QgaXQgdG8gVmVlVmFsaWRhdGUgaW4gdGhlIGJyb3dzZXIsIGFuZCBydW4gdGhlIHNhbWUgc2NoZW1hIGFnYWluIGluIGEgTnV4dCBzZXJ2ZXIgcm91dGUu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zY2hlbWEtdmFsaWRhdGlvbi13aXRoLXpvZC00LWFuZC12ZWV2YWxpZGF0ZSI%2Cs_LHK4UF9extHBV9iZ.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~U2NoZW1hIHZhbGlkYXRpb24gd2l0aCBab2QgNCBhbmQgVmVlVmFsaWRhdGUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Schema%2Bvalidation%2Bwith%2BZod%2B4%2Band%2BVeeValidate%2Cdescription_~V3JpdGUgb25lIFpvZCA0IHNjaGVtYSBmb3IgYSBzaWduLXVwIGZvcm0sIGNvbm5lY3QgaXQgdG8gVmVlVmFsaWRhdGUgaW4gdGhlIGJyb3dzZXIsIGFuZCBydW4gdGhlIHNhbWUgc2NoZW1hIGFnYWluIGluIGEgTnV4dCBzZXJ2ZXIgcm91dGUu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9zY2hlbWEtdmFsaWRhdGlvbi13aXRoLXpvZC00LWFuZC12ZWV2YWxpZGF0ZSI%2Cs_LHK4UF9extHBV9iZ.png"}]}
```