Form validation is one of those things every developer has built far too many times. Whether it’s checking if an email looks right, preventing mismatched passwords, or making sure the user doesn’t try to sign up as “x”, validation is essential for great UX and secure data handling. In this post, we’ll break down of how build Shadcn Vue Form Validation:
- What form validation really is
- How to build a complete validated forms using shadcn-vue
- A complete working example (code included!)
Table of Contents
What is Form Validation?
Form validation is the process of checking whether the data entered into a form to ensure it meets specific criteria and requirements before submitting to the server. A good form validation improves user experience, data consistency & security of your web application.
This rules can be simple like, Email must be valid, Name can’t be empty, password should be at least 8 characters.
Before we start, please make sure that you have setup the simple shadcn-vue project so that you can follow along the article. Here is the guide on how you can setup a shadcn-vue project.
In shadcn-vue, we have two options for validation of forms.
- With Veevalidate
- With TanStack Forms
You can refer the official guide as well.
Shadcn Vue Form Validation with VeeValidate
VeeValidate has been the go-to validation library for Vue developers for years. It provides excellent composition API support.
Once you have setup the basic shadcn-vue project, lets install the required dependencies.
pnpm install vee-validate @vee-validate/zod zod vue-sonner
lets take a look at the dependencies we have installed:
- vee-validate: A validation library for forms.
- Zod: Zod is typescript first schema validation library.
- vue-sonner: A beautiful toast components.
In this article, we will create a signup form with the complete validation.
Our signup form will have below fields and the validation rules:
| Field | Rules |
|---|---|
full_name | Minimum 3 characters |
email | Valid email format |
password | min. 8 chars, 1 uppercase, 1 number, 1 special character |
confirm_password | Must match password |
Step 1: Define the Zod Schema
let’s start by defining all validation logic inside a Zod schema.
This keeps validation consistent, maintainable, and type-safe.
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const signupFormSchema = toTypedSchema(
z.object({
full_name: z
.string()
.min(3, 'Name must be at least 3 characters long'),
email: z
.string()
.min(3, 'Email must be at least 3 characters long')
.email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters long')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[!@#$%^&*]/, 'Password must contain at least one special character'),
confirm_password: z.string(),
}).refine((data) => data.password === data.confirm_password, {
message: 'Passwords do not match',
path: ['confirm_password'],
}),
);
Password complexity
We enforce:
- Minimum length
- One uppercase letter
- One digit
- One special character
Password confirmation
.refine() is used to validate conditions involving multiple fields—like matching passwords.
Step 2: Initialize the Form With VeeValidate
VeeValidate takes the Zod schema and automatically handles all validation.
import { useForm, Field as VeeField } from 'vee-validate';
const { handleSubmit } = useForm({
validationSchema: signupFormSchema,
initialValues: {
full_name: '',
email: '',
password: '',
confirm_password: '',
},
})
Let’s take a look at some important terms:
useForm: Creates the form instancevalidationSchema: Connects our Zod schema to the forminitialValues: Sets default values (empty strings for all fields)handleSubmit: A function that validates and submits the formresetForm: Clears all fields back to initial values
Step 3: Get basics of form validation in VeeValidate.
Let’s take a look at the important components.
shadcn-vue provides composable components like:
<Field>for structured form control<FieldLabel>for accessible labels<FieldError>for displaying validation messages<Input>for user inputs<Card>for UI<Button>for submission
VeeField is the Field component provided by VeeValidate. It provides scoped slots for controlled inputs with validation. We will wrap our Field in this component.
Here’s how a single field is wired up:
<VeeField v-slot="{ field, errors }" name="email">
<Field :data-invalid="!!errors.length">
<FieldLabel>Email</FieldLabel>
<Input v-bind="field" placeholder="john.doe@example.com" :aria-invalid="!!errors.length" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
Note: add data-invalid prop in Field and aria-invalid prop in Input, Checkbox, etc., field for better styling and accessibility of errors. Now that we have the basics about form validation, let’s build the UI.
Step 4: Build the UI for the form.
Let’s build the UI for the complete signup form.
Below is the complete code:
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod';
import { useForm, Field as VeeField } from 'vee-validate';
import { toast } from 'vue-sonner';
import { z } from 'zod';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from '@/components/ui/field';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
const signupFormSchema = toTypedSchema(
z.object({
full_name: z
.string()
.min(3, 'Name must be at least 3 characters long'),
email: z
.string()
.min(3, 'Email must be at least 3 characters long')
.email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters long')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[!@#$%^&*]/, 'Password must contain at least one special character'),
confirm_password: z
.string(),
}).refine((data) => data.password === data.confirm_password, {
message: 'Passwords do not match',
path: ['confirm_password'],
}),
)
const { handleSubmit, resetForm } = useForm({
validationSchema: signupFormSchema,
initialValues: {
full_name: '',
email: '',
password: '',
confirm_password: '',
},
})
const onSubmit = handleSubmit((values) => {
console.log('Form submitted:', values)
toast.success('Form submitted successfully!')
resetForm()
})
</script>
<template>
<div class="flex justify-center items-center min-h-screen p-4">
<Card class="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Create your account</CardTitle>
<CardDescription>
Enter your details to create your account
</CardDescription>
</CardHeader>
<CardContent>
<form @submit="onSubmit" id="signup-form">
<FieldGroup>
<VeeField v-slot="{ field, errors }" name="full_name">
<Field :data-invalid="!!errors.length">
<FieldLabel for="signup-form-full_name">
Full Name
</FieldLabel>
<Input id="signup-form-full_name" v-bind="field" placeholder="John Doe" autocomplete="off"
:aria-invalid="!!errors.length" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="email">
<Field :data-invalid="!!errors.length">
<FieldLabel for="signup-form-email">
Email
</FieldLabel>
<Input id="signup-form-email" v-bind="field" placeholder="john.doe@example.com" autocomplete="off"
:aria-invalid="!!errors.length" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="password">
<Field :data-invalid="!!errors.length">
<FieldLabel for="signup-form-password">
Password
</FieldLabel>
<Input id="signup-form-password" v-bind="field" type="password" placeholder="********"
autocomplete="off" :aria-invalid="!!errors.length" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="confirm_password">
<Field :data-invalid="!!errors.length">
<FieldLabel for="signup-form-confirm-password">
Confirm Password
</FieldLabel>
<Input id="signup-form-confirm-password" v-bind="field" type="password" placeholder="********"
autocomplete="off" :aria-invalid="!!errors.length" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
</FieldGroup>
</form>
</CardContent>
<CardFooter class="flex flex-col">
<Button type="submit" form="signup-form" class="w-full">
Create Account
</Button>
<p class="text-center mt-4">Already have an account? <a href="/login">Login</a></p>
</CardFooter>
</Card>
</div>
</template>
If you don’t want to start from scratch, consider using form builders like Formcn for a faster workflow.
Step 5: Handle the form submission
handle the submission of the form when the user submits the form.
const { handleSubmit, resetForm } = useForm({
validationSchema: signupFormSchema,
initialValues: {
full_name: '',
email: '',
password: '',
confirm_password: '',
},
})
const onSubmit = handleSubmit((values) => {
console.log('Form submitted:', values)
toast.success('Form submitted successfully!')
resetForm()
})
On submit, we are printing the form values to the console and showing a beautiful toast displaying the message “Form Submitted Successfully” and then resetting the form.
Step 6: Result
Let’s check the output of the signup form

Let’s check the form if it is working with validation properly:

Special Recommendation: shadcn/studio

This isn’t a traditional component library or a replacement for Shadcn. Instead, it’s a unique collection that offers customizable variants of components, blocks, and templates. Preview, customize, and copy-paste them into your apps with ease.
Building on the solid foundation of the Shadcn components & blocks, we’ve enhanced it with custom-designed components & blocks to give you a head start. This allows you to craft, customize, and ship your projects faster and more efficiently.
Features:
- Open-source: Dive into a growing, community-driven collection of copy-and-paste shadcn ui components, Shadcn blocks, and templates.
- Component & Blocks variants: Access a diverse collection of customizable shadcn blocks and component variants to quickly build and style your UI with ease.
- Animated variants with Motion: Add smooth, modern animations to your components, enhancing user experiences with minimal effort.
- Landing pages & Dashboards: Explore 20+ premium & free Shadcn templates for dashboards, landing pages & more. Fully customizable & easy to use.
- shadcn/ui for Figma: Speed up your workflow with Shadcn Figma UI components, blocks & templates – a full design library inspired by shadcn/ui.
- Powerful theme generator: Customize your UI instantly with Shadcn Theme Generator. Preview changes in real time and create consistent, on-brand designs faster.
- shadcn/studio MCP: Integrate shadcn/studio MCP Server directly into your favorite IDE and craft stunning shadcn/ui Components, Blocks, and Pages inspired by shadcn/studio.
- Shadcn Figma To Code Plugin: Convert your Figma designs into production-ready code instantly with the Shadcn Figma Plugin.
Checkout the All Shadcn Collection that offers awesome Shadcn resources. If you have a unique & helpful Shadcn product to promote, submit your product and get featured.
Shadcn Vue Form Validation with TanStack Forms
In the previous section, we explored how to build a fully validated signup form using VeeValidate. Now, let’s take a look at TanStack Forms.
TanStack Form is a lightweight, headless form library designed for high performance and full control over form state. It works beautifully with Vue and gives you unmatched flexibility in how you handle validation and field interactions.
Let’s install the TanStack form in our project:
pnpm add @tanstack/vue-form
With TanStack forms, we will build a fully validated Newsletter Subscription Form. Our form will have the following fields and rules.
| Field | Rules |
|---|---|
full_name | Minimum 3 characters |
email | Must be a valid email format |
frequency | Must select one option: daily, weekly, or monthly |
topics | Must select at least one interest (checkbox array) |
Step 1: Define the Zod Schema
Let’s define the Zod schema that defines all the validation rules for our form.
const formSchema = z.object({
full_name: z
.string()
.min(3, 'Full name must be at least 3 characters long'),
email: z
.string()
.email('Enter a valid email address'),
frequency: z.enum(['daily', 'weekly', 'monthly'], {
message: 'Please choose a frequency.',
}),
topics: z
.array(z.string())
.min(1, 'Select at least one Interest.'),
})
We have built a fully validated Newsletter subscription form with Shadcn-vue, TanStack Form, and Zod.
Key Points:
- Frequency: The user must pick one of daily, weekly, or monthly.
- Topics: Validates that the user selects at least one checkbox.
Step 2: Initialize the Form with TanStack Form
Next, we create a form instance using useForm.
const form = useForm({
defaultValues: {
full_name: '',
email: '',
frequency: 'weekly',
topics: [] as string[],
},
validators: {
onChange: formSchema,
},
onSubmit: async () => {
toast('Thanks for subscribing!')
form.reset()
},
})
Important terms:
defaultValues– Sets the initial data of the form.validators.onChange– Validates fields as the user types.onSubmit– Submit handler.form.reset()– Resets all fields to their initial state.
Step 3: Understanding TanStack Form Field API
Let’s understand some basics before we build the form.
shadcn-vue provides composable components like:
<Field>for structured form control<FieldLabel>for accessible labels<FieldError>for displaying validation messages<Input>for user inputs<Card>for UI<Button>for submission
form.Field components work properly with TanStack forms for controlled inputs. It provides scoped slots with field data that we can use in the Input Field. For input fields, use field.state.value & field.handleChange on the Input component.
To show errors, add the :aria-invalid prop to the Input component and the :data-invalid prop to the Field component.
<script setup>
function isInvalid(field: any) {
return field.state.meta.isTouched && !field.state.meta.isValid
}
</script>
<template>
<form.Field v-slot="{ field }" name="full_name">
<Field :data-invalid="isInvalid(field)">
<FieldLabel>Full name</FieldLabel>
<Input type="text" :id="field.name" :name="field.name" :model-value="field.state.value"
placeholder="John Doe" autocomplete="name" :aria-invalid="isInvalid(field)" @blur="field.handleBlur"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
<FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
</Field>
</form.Field>
</template>
Step 4: Build the UI for the form.
Let’s build the UI for the complete Newsletter form. Below is the complete code:
<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
import { z } from 'zod'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Field,
FieldError,
FieldGroup,
FieldLabel
} from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
const formSchema = z.object({
full_name: z
.string()
.min(3, 'Full name must be at least 3 characters long'),
email: z
.string()
.email('Enter a valid email address'),
frequency: z.enum(['daily', 'weekly', 'monthly'], {
message: 'Please choose a frequency.',
}),
topics: z
.array(z.string())
.min(1, 'Select at least one Interest.'),
})
const form = useForm({
defaultValues: {
full_name: '',
email: '',
frequency: 'weekly',
topics: [] as string[],
},
validators: {
onChange: formSchema,
onSubmit: formSchema,
},
onSubmit: async () => {
toast('Thanks for subscribing!')
form.reset()
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isInvalid(field: any) {
return field.state.meta.isTouched && !field.state.meta.isValid
}
const topics = [
{ id: 'frontend', label: 'Frontend' },
{ id: 'backend', label: 'Backend' },
{ id: 'ai', label: 'AI' },
]
</script>
<template>
<div class="flex justify-center items-center min-h-screen p-4">
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle>Subscribe to Our Newsletter</CardTitle>
<CardDescription>
Stay up to date with the latest articles and updates. Choose your preferred frequency and topics of interest.
</CardDescription>
</CardHeader>
<CardContent>
<form id="subscription-form" @submit.prevent="form.handleSubmit" class="space-y-4">
<FieldGroup>
<form.Field v-slot="{ field }" name="full_name">
<Field :data-invalid="isInvalid(field)">
<FieldLabel>Full name</FieldLabel>
<Input type="text" :id="field.name" :name="field.name" :model-value="field.state.value"
placeholder="John Doe" autocomplete="name" :aria-invalid="isInvalid(field)" @blur="field.handleBlur"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
<FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
</Field>
</form.Field>
<form.Field v-slot="{ field }" name="email">
<Field :data-invalid="isInvalid(field)">
<FieldLabel>Email</FieldLabel>
<Input type="email" :id="field.name" :name="field.name" :model-value="field.state.value"
placeholder="you@example.com" autocomplete="email" :aria-invalid="isInvalid(field)"
@blur="field.handleBlur" @input="field.handleChange(($event.target as HTMLInputElement).value)" />
<FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
</Field>
</form.Field>
<form.Field #default="{ field }" name="frequency">
<Field :data-invalid="isInvalid(field)" orientation="responsive">
<FieldLabel>Frequency</FieldLabel>
<Select :name="field.name" :model-value="field.state.value" @update:model-value="field.handleChange">
<SelectTrigger id="form-tanstack-select-frequency" :aria-invalid="isInvalid(field)">
<SelectValue placeholder="How often?" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
</SelectContent>
</Select>
<FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
</Field>
</form.Field>
<form.Field v-slot="{ field }" name="topics" mode="array">
<FieldGroup data-slot="checkbox-group">
<FieldLabel>Your Interests</FieldLabel>
<Field v-for="topic in topics" :key="topic.id" orientation="horizontal"
:data-invalid="isInvalid(field)">
<Checkbox :id="`form-tanstack-checkbox-${topic.id}`" :name="field.name"
:aria-invalid="isInvalid(field)" :model-value="field.state.value.includes(topic.id)"
@update:model-value="(checked) => {
if (checked) {
field.pushValue(topic.id)
}
else {
const index = field.state.value.indexOf(topic.id)
if (index > -1) {
field.removeValue(index)
}
}
}" />
<FieldLabel :for="`form-tanstack-checkbox-${topic.id}`" class="font-normal">
{{ topic.label }}
</FieldLabel>
</Field>
<FieldError v-if="isInvalid(field)" :errors="field.state.meta.errors" />
</FieldGroup>
</form.Field>
</FieldGroup>
<Button type="submit" form="subscription-form">Subscribe</Button>
</form>
</CardContent>
</Card>
</div>
</template>
You can also consider using shadcn based Form Builder Tool instead of designing the UI from scratch.
Step 5: Result
Let’s check the result of the newsletter form

Let’s check the form if it is working with validation properly

We have built a fully validated Newsletter subscription form with Shadcn-vue, TanStack Form, and Zod.
Summary:
We just built two production-ready, fully validated forms with shadcn-vue:
- A signup form using VeeValidate + Zod.
- A newsletter subscription form using TanStack Vue Form + Zod.
Conclusion:
Form validation is essential for a great user experience & secure web application, but it doesn’t have to be messy. With shadcn-vue, Zod, and modern form libraries, you can build fully validated forms with minimal effort.
- VeeValidate is perfect when you want convenience, simplicity, and want to stay in Vue’s ecosystem.
- TanStack Form is the go-to choice when you need great control & performance for the forms.