Skip to content
TutorialForms

VeeValidate or FormKit? Two form libraries compared

VeeValidate gives you validation state and leaves the HTML to you. FormKit renders the whole field from one tag. Build the same form with each and the choice becomes obvious.

vuejs.school team14 Oct 20258 min readVeeValidate 4.15 · FormKit 1.6 · Zod 4

A form library earns its place when you have more than a couple of forms, or when one form has enough rules that tracking values, errors and touched state by hand starts to hurt. Two well-known Vue form libraries take opposite approaches. VeeValidate 4 manages the state and lets you write every input yourself. FormKit 1 generates the label, the input, the help text and the error from a single <FormKit> tag.

The short answer: VeeValidate suits a project that owns its markup, and FormKit suits one that wants its forms generated. Below, the same sign-up form is built with each so you can see why. The code is plain Vue 3.5 in a Vite project, with VeeValidate 4.15.1, FormKit 1.6.9 and Zod 4.1.

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 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, 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 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 stuck3 questions from the Form libraries: UForm, TanStack Form and VeeValidate quiz.
Practice now
Was this helpful?

Keep reading