Form libraries: UForm, TanStack Form and VeeValidate
Compare Nuxt UI's UForm, TanStack Form, VeeValidate and FormKit on the same sign-up form, see which ones take a Zod 4 schema, map a 422 onto fields, and build a field array.
What you'll learn
When you finish, you can do these.- 1Check which form libraries take a Zod 4 schema as it is
- 2Build a UForm sign-up form that shows the server's errors
- 3Let people add and remove rows of inputs with TanStack Form
- 4Choose a form library for the forms your app has
Picture a sign-up form with an email and a password. Written by hand in Vue, it needs a flag set when the person leaves a field, a message under each field, a guard against sending twice, and the server's answer under the right field. A form library does all this the same way in every form, and adds harder parts, such as rows the person adds and removes.
Four libraries are worth knowing: UForm from Nuxt UI, TanStack Form, VeeValidate and FormKit. The Code tab holds the sign-up form built with each in a Nuxt app, a Vue app with its own server. By the end you can pick the right one. You need one idea from Schema validation with Zod: a schema, one description of a form's fields and rules, which Zod checks in the browser and on the server.
Standard Schema decides what works with Zod 4
One fact sorts the four libraries: whether they take a schema written with Zod 4, the current version of the schema library most Vue projects use.
Standard Schema is a small spec that Zod 4, Valibot and ArkType follow: each schema carries a ~standard property with a validate function that any library can call. A form library that does not accept it needs an adapter, an extra package that connects it to Zod and works only with the Zod version it was written for, its peer.
UForm or TanStack FormAccept Standard Schema. The starter's Zod 4 schema, signupSchema in shared/utils/signup.ts, goes straight in: :schema="signupSchema" or validators: { onChange: signupSchema }. No adapter, no version to match.
VeeValidate 4 or FormKitNeed an adapter. @vee-validate/zod 4.15 declares a peer of zod ^3.24 and @formkit/zod 2.1 a peer of zod ^3, so with Zod 4 the install fails. VeeValidate 5, in beta since March 2026, takes Standard Schema.
UForm and TanStack Form
Both take Standard Schema, but UForm draws the fields and TanStack Form leaves the markup to you.
UForm, from the Nuxt UI 4 component library, takes the schema and a reactive state, the object the inputs write to. Each UFormField wraps one input, has a name matching a schema key, and shows that field's message. The template of SignupUForm.vue, with its email field:
<UForm ref="form" :schema="signupSchema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" />
</UFormField>
<UButton type="submit">Sign up</UButton>
</UForm>
Press Sign up with both fields empty: Enter a valid email and At least 8 characters appear under their inputs. While it submits, the form is disabled, so it cannot send twice.
When the email is taken, the route server/api/signup.post.ts, the server code that checks each sign-up with the same schema, refuses it with status 422. Its answer holds one { name, message } entry per field.
$fetch, Nuxt's request helper, throws a FetchError on that answer, and the form's setErrors() takes its list as it is:
try {
await $fetch('/api/signup', { method: 'POST', body: event.data })
await navigateTo('/welcome')
} catch (error) {
if (error instanceof FetchError && error.status === 422) form.value?.setErrors(error.data.errors)
else throw error
}
That email already has an account appears under Email.
TanStack Form is headless: it keeps values and errors, and renders nothing. In SignupTanstack.vue, useForm() takes defaultValues, the starting values, and validators: { onChange: signupSchema }, which runs the schema on every change.
You write the markup inside form.Field, which hands that markup field, with the value and the input's handlers, and state, its touched flag and errors:
<form.Field name="email">
<template #default="{ field, state }">
<label :for="field.name">Email</label>
<input :id="field.name" :value="field.state.value" type="email" @input="field.handleChange(($event.target as HTMLInputElement).value)" @blur="field.handleBlur" />
<p v-if="state.meta.isTouched" class="error">{{ state.meta.errors.map(e => (typeof e === 'string' ? e : e?.message)).join(', ') }}</p>
</template>
</form.Field>
state.meta.isTouched keeps the message hidden until the person has typed in the field, left it or pressed Sign up.
Field arrays, rows the person adds and removes, are where a library earns its place. A sign-up form has fixed fields, so a second form in the Code tab shows them: an invite form with one email per row, InviteList.vue.
Its outer form.Field takes mode="array", which gives its field pushValue() to add a row and removeValue() to remove one. Each row is a nested field, invites[0].email, invites[1].email and so on:
<form.Field name="invites" mode="array">
<template #default="{ field }">
<div v-for="(_, i) in field.state.value" :key="i" class="row">
<form.Field :name="`invites[${i}].email`">
<!-- the row's email input and its error, like the sign-up form's -->
</form.Field>
<button type="button" @click="field.removeValue(i)">Remove</button>
</div>
<button type="button" @click="field.pushValue({ email: '' })">Add person</button>
</template>
</form.Field>
Press Add person twice and Remove on the middle row: two inputs remain.
VeeValidate, FormKit and how to choose
The other two do not take Standard Schema; after them comes the choice.
VeeValidate 4 is the most installed of the four. It reaches Zod through its adapter's toTypedSchema(), which needs Zod 3, so SignupVee.vue declares its own schema in the Zod 3 form, z.string().email(). VeeValidate's own useForm() takes the wrapped schema, defineField('email') gives a ref for v-model, and handleSubmit() calls your function only when every field is valid:
const { errors, defineField, handleSubmit, isSubmitting, setErrors } = useForm({ validationSchema: toTypedSchema(schema) })
const [email, emailAttrs] = defineField('email')
const onSubmit = handleSubmit(async (values) => { /* post, map a 422 with setErrors() */ })
The template prints errors.email under the email input, and on a 422 setErrors() fills it by field name.
FormKit is the opposite of headless: one <FormKit> tag renders the label, the input, the help text and the errors. SignupFormKit.vue uses no Zod schema; its rules are a string such as required|length:8:
<FormKit type="form" submit-label="Sign up" @submit="submit">
<FormKit type="email" name="email" label="Email" validation="required|email" />
<FormKit type="password" name="password" label="Password" validation="required|length:8" help="At least 8 characters" />
</FormKit>
The page shows both inputs, labelled, with At least 8 characters under Password. The submit handler also gets node, FormKit's object for the form; on a 422, node.setErrors() puts the message under Email.
<FormKitSchema> builds a whole form from JSON, for forms that come from a content management system.
Pick by situation:
- One or two forms: by hand, with a schema.
- A Nuxt UI app:
UForm. - Your own components, or field arrays: TanStack Form.
- An app already on VeeValidate: Zod 3 until VeeValidate 5 is stable.
- Forms built from data: FormKit.
What goes wrong
The first mistake is installing VeeValidate 4's Zod adapter in a project on Zod 4. In package.json, the file that lists a project's packages, it looks harmless:
"dependencies": {
"@vee-validate/zod": "^4.15.1",
"vee-validate": "^4.15.1",
"zod": "^4.6.6"
}
npm install stops with ERESOLVE unable to resolve dependency tree and names the conflict: peer zod@"^3.24.0" from @vee-validate/zod@4.15.1. Stay on Zod 3 with the adapter, or drop it for a library that takes Zod 4:
"dependencies": {
"@nuxt/ui": "^4.11.3",
"zod": "^4.6.6"
}
The install finishes, and UForm takes the Zod 4 schema as it is.
The second mistake is posting the values in SignupVee.vue with the browser's own fetch and a JSON string:
const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) })
Submit a valid form: the handler goes on to navigateTo('/welcome'), yet no account was made. The string goes out labelled text/plain, not JSON, so the route's readValidatedBody() finds no object and answers 400. fetch does not throw on a 400, so the catch never runs.
$fetch sends an object as JSON and throws on any error status:
await $fetch('/api/signup', { method: 'POST', body: values })
The route reads an object, and a taken email shows its message under Email.
What to remember
- Standard Schema is the line:
UFormand TanStack Form take Zod 4 as it is; the Zod adapters of VeeValidate 4 and FormKit need Zod 3. UFormwants a schema, astateand anameper field, and disables the form while it submits.$fetchthrows on a 422,fetchdoes not;setErrors()then puts the route's{ name, message }entries under their fields.- TanStack Form is headless, and
mode="array"gives a fieldpushValue()andremoveValue()for rows. - Few forms: by hand. A Nuxt UI app:
UForm. Own markup or arrays: TanStack Form. Forms from data: FormKit.
In your project
Do these in a real project. Each one says how to confirm it worked.- In a Nuxt app with
@nuxt/uiinstalled and<UApp>around the pages, addshared/utils/signup.tsandSignupUForm.vueto a page. Press Sign up with both fields empty: Enter a valid email and At least 8 characters appear under their inputs. - Add
server/api/signup.post.tsand submittaken@example.com: That email already has an account appears under Email. Runcurl -i -X POST localhost:3000/api/signup -H 'Content-Type: application/json' -d '{"email":"taken@example.com","password":"hunter2hunter"}': the first line readsHTTP/1.1 422. Remove the-Hflag: it readsHTTP/1.1 400. - Install
@tanstack/vue-form, addInviteList.vueto a page, press Add person twice and Remove on the middle row: two inputs remain. Press Send invites with one row empty: Enter a valid email appears under that row only.