---
title: "VeeValidate or FormKit? Two form libraries compared · vuejs.school"
description: "The same sign-up form built twice, once with VeeValidate 4 and once with FormKit 1, then compared on control, typed schemas, styling and size."
canonical_url: "https://vuejs.school/community/blog/veevalidate-or-formkit"
meta:
  "og:description": "The same sign-up form built twice, once with VeeValidate 4 and once with FormKit 1, then compared on control, typed schemas, styling and size."
  "og:image": "https://vuejs.school/_og/d/a_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVkIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVk%2Cdescription_~VGhlIHNhbWUgc2lnbi11cCBmb3JtIGJ1aWx0IHR3aWNlLCBvbmNlIHdpdGggVmVlVmFsaWRhdGUgNCBhbmQgb25jZSB3aXRoIEZvcm1LaXQgMSwgdGhlbiBjb21wYXJlZCBvbiBjb250cm9sLCB0eXBlZCBzY2hlbWFzLCBzdHlsaW5nIGFuZCBzaXplLg%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92ZWV2YWxpZGF0ZS1vci1mb3Jta2l0Ig%2Cs_lCo-za5nDOeAU9k5.png"
  "og:image:alt": "VeeValidate or FormKit? Two form libraries compared · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "VeeValidate or FormKit? Two form libraries compared · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVkIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVk%2Cdescription_~VGhlIHNhbWUgc2lnbi11cCBmb3JtIGJ1aWx0IHR3aWNlLCBvbmNlIHdpdGggVmVlVmFsaWRhdGUgNCBhbmQgb25jZSB3aXRoIEZvcm1LaXQgMSwgdGhlbiBjb21wYXJlZCBvbiBjb250cm9sLCB0eXBlZCBzY2hlbWFzLCBzdHlsaW5nIGFuZCBzaXplLg%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92ZWV2YWxpZGF0ZS1vci1mb3Jta2l0Ig%2Cs_lCo-za5nDOeAU9k5.png"
  "twitter:image:alt": "VeeValidate or FormKit? Two form libraries compared · vuejs.school"
---

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

## The sign-up form we build twice

Four fields and five rules:

- The name has at least two characters.
- The email is a valid address.
- The password has at least eight characters.
- The terms checkbox is ticked.
- When `POST /api/signup` answers `409 Conflict`, the email field shows "This email is already registered".

That last rule matters: a library that cannot show a server error next to the right field is not finished.

## Build the form with VeeValidate and Zod

Install the library, its Zod adapter and Zod:

```
npm install vee-validate @vee-validate/zod zod --legacy-peer-deps
```

The flag is there because `@vee-validate/zod` 4.15.1 declares Zod 3 (`^3.24.0`) as its peer dependency, and npm refuses to install Zod 4 next to it without being told to behave like npm 6 did. pnpm installs the pair with a warning instead. The adapter does work with Zod 4 schemas, with two gaps explained in the next section.

The whole form lives in `useForm()`. You hand it the schema, and it hands back the pieces the template needs:

*src/components/SignUpForm.vue*

```
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(2, 'Enter at least 2 characters'),
  email: z.email('Enter a valid email address'),
  password: z.string().min(8, 'Use at least 8 characters'),
  terms: z.literal(true, { error: 'You must accept the terms' }),
})

const { errors, defineField, handleSubmit, isSubmitting } = useForm({
  validationSchema: toTypedSchema(schema),
  initialValues: { name: '', email: '', password: '' },
})

const [name, nameAttrs] = defineField('name')
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
const [terms, termsAttrs] = defineField('terms')

const onSubmit = handleSubmit(async (values, { setErrors }) => {
  const response = await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(values),
  })
  if (response.status === 409) {
    setErrors({ email: 'This email is already registered' })
  }
})
</script>

<template>
  <form novalidate @submit="onSubmit">
    <label for="name">Name</label>
    <input id="name" v-model="name" v-bind="nameAttrs" type="text" autocomplete="name" />
    <p v-if="errors.name" role="alert">{{ errors.name }}</p>

    <label for="email">Email</label>
    <input id="email" v-model="email" v-bind="emailAttrs" type="email" autocomplete="email" />
    <p v-if="errors.email" role="alert">{{ errors.email }}</p>

    <label for="password">Password</label>
    <input id="password" v-model="password" v-bind="passwordAttrs" type="password" autocomplete="new-password" />
    <p v-if="errors.password" role="alert">{{ errors.password }}</p>

    <label><input v-model="terms" v-bind="termsAttrs" type="checkbox" /> I accept the terms</label>
    <p v-if="errors.terms" role="alert">{{ errors.terms }}</p>

    <button type="submit" :disabled="isSubmitting">Create account</button>
  </form>
</template>
```

What each part does:

- `defineField('name')` returns a pair: a ref for `v-model` and an object of attributes for `v-bind`. By default the field validates every time its value changes, so the email error appears at the first keystroke and clears as soon as the address is valid. The attributes add listeners that validate the field again on `blur` and `change`. To wait until the user leaves the field, pass `{ validateOnModelUpdate: false }` as the second argument.
- `initialValues` starts the three text fields as empty strings. Without it, a field nobody touched is `undefined`, and submitting shows Zod's own "Invalid input: expected string, received undefined" under the name and password instead of your messages. `terms` stays out: its type is the literal `true`, so `false` would be a type error, and an unticked box already gets your message.
- `errors` is an object with one message per invalid field and nothing for valid ones.
- `handleSubmit()` wraps your function so it only runs when every rule passes. Because the schema went through `toTypedSchema()`, `values` is typed: `values.email` is a `string`, `values.terms` is the literal `true`, and the TypeScript compiler will tell you if you misspell a field.
- The handler's second argument carries `setErrors()`, which is how the 409 response lands on the email field.

If you would rather write markup than wire refs, the same library offers components. `<Form>` takes the schema, `<Field>` renders an `<input>` and registers it, and `<ErrorMessage>` prints the error:

*src/components/SubscribeForm.vue*

```
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const schema = toTypedSchema(z.object({ email: z.email('Enter a valid email address') }))

function onSubmit(values: Record<string, any>) {
  console.log(values.email)
}
</script>

<template>
  <Form :validation-schema="schema" @submit="onSubmit">
    <Field name="email" type="email" />
    <ErrorMessage name="email" />
    <button type="submit">Subscribe</button>
  </Form>
</template>
```

The composable version is the one to learn. It types the submitted values, it works with any input component you already have, and it is what the components use underneath.

## Use VeeValidate 4 with Zod 4

Zod 4 was released on 9 July and most new projects will install it. VeeValidate's Zod adapter was written for Zod 3, and the maintainer has said in the [Zod 4 issue](https://github.com/logaretm/vee-validate/issues/5027) that Zod 4 support ships with version 5.

With Zod 4.1, the form above works as written: each message in the schema lands on its field, the submitted values are typed, and `meta.required` is right for every field. The adapter still reads the schema the Zod 3 way, though, and two things break:

- A `.default()` in the schema can crash the form. When the starting values do not pass the schema, as with any empty sign-up form, `useForm()` throws "value.\_def.defaultValue is not a function". Put default values in `initialValues` instead, as the form above does.
- A field inside an array, such as `items[0].name`, logs "Failed to describe path" and counts as optional. That only matters if you render a required marker from `meta.required`; if you do, mark those fields yourself.

## Build the same form with FormKit

FormKit is installed once as a Vue plugin, with a stylesheet, and from then on every form uses the global `<FormKit>` component:

```
npm install @formkit/vue @formkit/themes
```

*src/main.ts*

```
import { createApp } from 'vue'
import { plugin, defaultConfig } from '@formkit/vue'
import '@formkit/themes/genesis'
import App from './App.vue'

createApp(App).use(plugin, defaultConfig).mount('#app')
```

`defaultConfig` registers the inputs, the validation rules and the English messages. The CSS import is Genesis, FormKit's default theme, from the `@formkit/themes` package. The sign-up form becomes the component below, with one extra field, a password confirmation, to show one of FormKit's built-in rules:

*src/components/SignUpForm.vue*

```
<script setup lang="ts">
import { setErrors } from '@formkit/vue'

type SignUp = {
  name: string
  email: string
  password: string
  password_confirm: string
  terms: boolean
}

async function submit(data: SignUp) {
  const response = await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })
  if (response.status === 409) {
    setErrors('signup', [], { email: 'This email is already registered' })
  }
}
</script>

<template>
  <FormKit id="signup" type="form" submit-label="Create account" @submit="submit">
    <FormKit type="text" name="name" label="Name" validation="required|length:2" />
    <FormKit type="email" name="email" label="Email" validation="required|email" />
    <FormKit type="password" name="password" label="Password" help="At least 8 characters" validation="required|length:8" />
    <FormKit type="password" name="password_confirm" label="Confirm password" validation="required|confirm" />
    <FormKit type="checkbox" name="terms" label="I accept the terms" validation="accepted" validation-label="terms" />
  </FormKit>
</template>
```

There is no `v-model`, no error element and no submit button in your markup. The outer `<FormKit type="form">` does that work:

- It collects the values of its children by `name`.
- It renders the submit button with the label you gave it.
- It disables the whole form while your handler's promise is pending.
- It shows "Sorry, not all fields are filled out correctly." if someone submits while errors remain.

Rules are a string, `required|length:8`, and each one has a ready-made message built from the input's label: an empty email gets "Email is required." and a short password gets "Password must be greater than or equal to 8 characters." without you writing either.

`confirm` checks that `password_confirm` matches `password` by naming convention. `accepted` is the rule for a checkbox that must be ticked; with the checkbox's label its message reads "Please accept the I accept the terms.", so `validation-label="terms"` gives the rule a shorter name: "Please accept the terms."

Messages appear when the user leaves a field, or for a checkbox once it has been changed. To change that, set the `validation-visibility` prop on an input, or give the form `:config="{ validationVisibility: 'submit' }"` to change it for every input inside.

The server error goes through `setErrors()`, which FormKit re-exports from its core. The first argument is the form's `id`, the second holds form-level messages, the third maps messages to fields. The handler receives the data typed as whatever you declare; FormKit does not infer the type from the inputs, so the `SignUp` type is yours to keep in sync.

## Generate a FormKit form from data

FormKit's second trick is the schema. The same inputs can be described as plain objects and rendered by `<FormKitSchema>`, which means a form can come from a JSON file, a CMS or an API:

*src/components/SchemaForm.vue*

```
<script setup lang="ts">
import { FormKitSchema } from '@formkit/vue'

const schema = [
  { $formkit: 'text', name: 'name', label: 'Name', validation: 'required|length:2' },
  { $formkit: 'email', name: 'email', label: 'Email', validation: 'required|email' },
  { $formkit: 'checkbox', id: 'newsletter', name: 'newsletter', label: 'Send me the newsletter' },
  {
    $formkit: 'select',
    name: 'frequency',
    label: 'How often?',
    options: ['Weekly', 'Monthly'],
    if: '$get(newsletter).value',
  },
]

function submit(data: Record<string, unknown>) {
  console.log(data)
}
</script>

<template>
  <FormKit type="form" @submit="submit">
    <FormKitSchema :schema="schema" />
  </FormKit>
</template>
```

`$formkit` names the input type and the rest of the object becomes its props. The `if` key is a small expression language: `$get(newsletter).value` reads the checkbox by its `id`, so the frequency select only renders once the box is ticked. Nothing in VeeValidate does this; it has no opinion about how inputs are rendered, so it cannot render them from data.

## Extend FormKit with plugins

A FormKit plugin is a function that receives every input node as it is created. The `@formkit/addons` package ships ready-made ones: multi-step forms, floating labels, saving a half-finished form to `localStorage`, auto-animating messages.

There is also `@formkit/zod`, whose `createZodPlugin()` validates a whole form against a Zod schema and types the submit payload from it. Its peer dependency is Zod 3, so check it against your Zod version before you rely on it.

## Compare control, types, styling, size and learning curve

**Control of the markup.** VeeValidate renders nothing; every element, class and `aria` attribute is yours, which is exactly what you want when a design system already defines what an input looks like. FormKit renders a fixed structure of wrapper, label, inner, input, help and messages, and you restyle it through its section classes or a theme. You can reshape it, but you are working inside its structure.

**Typed schemas.** VeeValidate's `toTypedSchema()` gives you typed values for free from the schema you already wrote. FormKit types nothing by itself; you declare the payload type, and `@formkit/zod` is the add-on that closes the gap for projects on Zod 3.

**Styling.** FormKit ships Genesis as a complete stylesheet and offers Tailwind themes at [themes.formkit.com](https://themes.formkit.com), so a first form looks finished in minutes. VeeValidate ships no CSS at all, which is a feature when you have your own and a chore when you do not.

**Size.** VeeValidate 4.15.1 has two runtime dependencies, and its devtools hook is loaded lazily so it stays out of production builds. `@formkit/vue` 1.6.9 pulls in nine FormKit packages, because inputs, rules, messages in many languages and a theme all come in the box. FormKit's own [production guide](https://formkit.com/guides/optimizing-for-production) shows how to replace `defaultConfig` with only the inputs, rules and locales you use.

**Learning curve.** FormKit gets a working, validated, accessible form on screen faster, and its rule strings read like English. VeeValidate asks you to wire four refs and four error elements before anything validates, but what you wire is ordinary HTML and ordinary Vue, so there is less to learn about the library itself.

## Which one to pick

Pick VeeValidate when you own the markup: a design system, a component library such as Nuxt UI or PrimeVue, or forms whose layout is part of the design. You keep every element, you get typed values from one Zod schema that your server can share, and the composable fits any input you can `v-model`.

Pick FormKit when you want forms generated for you: an admin area with dozens of forms that should all look and behave the same, a multi-step wizard, or forms defined by data that someone else edits. One tag per field and a schema renderer are hard to beat for that work, and the theme means you never start from an unstyled input.

**Check it stuck**3 questions from the Form libraries: UForm, TanStack Form and VeeValidate quiz.

[**Practice now**](https://vuejs.school/learn/forms/form-libraries-veevalidate-and-formkit?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/veevalidate-or-formkit#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-14","description":"The same sign-up form built twice, once with VeeValidate 4 and once with FormKit 1, then compared on control, typed schemas, styling and size.","name":"VeeValidate or FormKit? Two form libraries compared","url":"https://vuejs.school/community/blog/veevalidate-or-formkit","breadcrumb":{"@id":"https://vuejs.school/community/blog/veevalidate-or-formkit#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/veevalidate-or-formkit"]}],"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/veevalidate-or-formkit#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-14","description":"The same sign-up form built twice, once with VeeValidate 4 and once with FormKit 1, then compared on control, typed schemas, styling and size.","headline":"VeeValidate or FormKit? Two form libraries compared","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVkIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVk%2Cdescription_~VGhlIHNhbWUgc2lnbi11cCBmb3JtIGJ1aWx0IHR3aWNlLCBvbmNlIHdpdGggVmVlVmFsaWRhdGUgNCBhbmQgb25jZSB3aXRoIEZvcm1LaXQgMSwgdGhlbiBjb21wYXJlZCBvbiBjb250cm9sLCB0eXBlZCBzY2hlbWFzLCBzdHlsaW5nIGFuZCBzaXplLg%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92ZWV2YWxpZGF0ZS1vci1mb3Jta2l0Ig%2Cs_lCo-za5nDOeAU9k5.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/veevalidate-or-formkit#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/veevalidate-or-formkit#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/veevalidate-or-formkit#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"VeeValidate or FormKit? Two form libraries compared","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_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVkIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVk%2Cdescription_~VGhlIHNhbWUgc2lnbi11cCBmb3JtIGJ1aWx0IHR3aWNlLCBvbmNlIHdpdGggVmVlVmFsaWRhdGUgNCBhbmQgb25jZSB3aXRoIEZvcm1LaXQgMSwgdGhlbiBjb21wYXJlZCBvbiBjb250cm9sLCB0eXBlZCBzY2hlbWFzLCBzdHlsaW5nIGFuZCBzaXplLg%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92ZWV2YWxpZGF0ZS1vci1mb3Jta2l0Ig%2Cs_lCo-za5nDOeAU9k5.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVkIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VmVlVmFsaWRhdGUgb3IgRm9ybUtpdD8gVHdvIGZvcm0gbGlicmFyaWVzIGNvbXBhcmVk%2Cdescription_~VGhlIHNhbWUgc2lnbi11cCBmb3JtIGJ1aWx0IHR3aWNlLCBvbmNlIHdpdGggVmVlVmFsaWRhdGUgNCBhbmQgb25jZSB3aXRoIEZvcm1LaXQgMSwgdGhlbiBjb21wYXJlZCBvbiBjb250cm9sLCB0eXBlZCBzY2hlbWFzLCBzdHlsaW5nIGFuZCBzaXplLg%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92ZWV2YWxpZGF0ZS1vci1mb3Jta2l0Ig%2Cs_lCo-za5nDOeAU9k5.png"}]}
```