1. Introduction
Most tutorials validate forms either on submit or on every keystroke.
In real-world apps, both approaches can create poor user experiences.
In this article, we’ll build a UX friendly client-side validation system in SvelteKit using Zod that:
- Validates on blur
- Switches to real-time validation once the user starts editing
- Validates everything on submit
- Handles cross-field validation (Password match confirmation)
The goal is to create predictable, maintainable, schema-based validation logic without messy conditionals.
2. Project Setup
The project is single route sveltekit application that uses components from shadcn-svelte.
npx sv create svelte-form-validation --add tailwindcss
cd svelte-form-validation
npx shadcn-svelte@latest init
Then install zod & component additions. Refer to the project readme for exact commands.
3. Designing the Validation Strategy
Our Validation Rules
- Validate on blur
- Once a user starts typing after blur -> validate on input
- On submit -> mark all fields touched and validate everything
- Handle cross-field validation separately
The choice for this strategy is intentional :
We begin with validation on blur to avoid premature error messages and allow the user to complete typing before showing feedback.
Once the field has been blurred, it is marked as touched.From that point onward, validation switches to real-time (on keystroke change). This provides instant feedback after the first interaction.
Upon submission, all inputs are marked as touched and validated before submission proceeds. This ensures that:
- All fields are validated
- The user sees which inputs were filled incorrectly
- The user sees which required inputs were not filled
Additionally, there are scenarios where one input can depend on data from another input eg "Confirm password" field depending on data from the Password field. Zod allows us to configure such cross-field validation rules.
4. Defining the Zod Schema
Zod exposes a z object(namespace-like object) that provides schema constructors and utilites.
With this object you can define rules that are to be applied on each of the input fields that you'll have in your form.
import { z } from 'zod';
export const formBaseSchema = z.object({
Email: z
.string()
.min(1, 'Email is required')
.max(64, 'Email must be less than 64 characters')
.email('Email must be a valid email address'),
Age: z
.coerce.number()
.int()
.min(18, 'You must be at least 18 years old')
.max(100),
AcceptTerms: z
.boolean()
.refine(val => val === true, {
message: 'You must accept the terms and conditions'
}),
Password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(32)
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
});
You'll notice that for the Age field, we've used z.coerce.number().
This is because HTML inputs always return strings, even when input type is number. Using coerce ensures the value is converted before applying numeric rules.
For cross-field validation, first ensure that the fields that depend on one another are defined in the base schema :
export const formBaseSchema = z.object({
Password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(32)
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
PasswordConfirm: z
.string(),
});
You then will need to "extend" from this schema by calling the superRefine() method on the base schema and pass in a callback function as argument where you define the cross-validation rule :
export const formSchema = formBaseSchema.superRefine(
({ Password, PasswordConfirm }, ctx) => {
if (Password !== PasswordConfirm) {
ctx.addIssue({
code: 'custom',
message: 'Password and Confirm Password must match',
path: ['PasswordConfirm'],
});
}
}
);
5. Managing Form State
In the form component, there are 3 core pieces of state :
i. formData
- State used to manage actual form values
let formData = $state({
Username: '',
Email: '',
Age:"",
Password: '',
PasswordConfirm: '',
Phone : '',
AcceptTerms : false
});
ii. errors
- Holds validation errors
let errors: Record<string, string> = $state({});
iii. touched
- Controls when to show validation
let touched: Record<string, boolean> = $state({});
Validation logic is straightforward — controlling when errors appear is where most UX complexity lies. The touched state gives us that control.
6. Field-Level Validation
The validateField function which is called on either blur or input events is used to handle field level validation.
type FieldKeys = keyof typeof formData
function validateField(field: FieldKeys) {
const partial = formBaseSchema.pick({ [field]: true } as object);
const result = partial.safeParse({ [field]: formData[field] });
if (!result.success) {
errors[field] = result.error.issues[0].message;
} else {
delete errors[field];
}
}
formBaseSchema.pick(...) is used to get the schema definition segment for the target field and this returns a partial schema.
With this partial schema, we can now validate the single field against the rules in the partial schema using partial.safeParse(...)
The result is an object that contains information on whether input is valid or not(result.success) and the error message if it wasn't successful (result.error.issues[0].message)
By picking this approach of picking specific segments of the schema definition, the validation logic stays performant as we only validate the field being edited instead of the whole form.
7. Cross-Field Validation (Password Confirmation)
The "Confirm password" field's validation will require full schema validation.
function validatePasswordConfirm() {
const result = formSchema.safeParse(formData);
const issue = result.success
? null
: result.error.issues.find((i) => i.path[0] === 'PasswordConfirm');
if (issue) {
errors.PasswordConfirm = issue.message;
} else {
delete errors.PasswordConfirm;
}
}
Its custom rule was defined in a separate schema that "extended" from the base schema.
If we try to use formBaseSchema.pick, we'll lack access to the custom rule defined in formSchema.
Thus, field-level validation works for independent fields, but dependent fields require full schema validation.
- Blur vs Input Strategy Blur events get fired when input component loses focus after interaction while input events get fired on key stroke changes in the input.
Each input is generally defined as follows, with the onblur and oninput props getting assigned to function onBlur and validateField functions respectively.
The errors are displayed using a custom component - HelperText whose contents are displayed based on the presence of an error for the current input key.
<Field>
<Label for="Username">Username</Label>
<Input
id="Username"
type="text"
class={`${errors?.['Username'] ? 'border-destructive' : ''}`}
bind:value={formData.Username}
onblur={() => onBlur('Username')}
oninput={() => touched.Username && validateField('Username')}
placeholder="John Doe"
/>
<HelperText variant="error" message={errors?.['Username'] || ''} show={!!errors?.['Username']}
></HelperText>
</Field>
9. Handling Submit
On submit, all fields get marked as touched so that user gets immediate feedback on subsequent input value changes.
The entire form also gets validated(using formSchema as it also contains rules for PasswordConfirm field) & the error states get populated if form had invalid fields othewise proceed to next code.
🔗 Try It Yourself
If you’d like to see the full implementation in action:
📦 GitHub: https://github.com/KOKUMUbooker/svelte-form-validation.git
🌐 Live Demo: https://svelte-vld.netlify.app/
Play around with the inputs and observe how the validation switches from blur-based to real-time after interaction.
10. Key Takeaways
- Separate schema logic from UI
- Control error visibility with touched
- Use superRefine for dependent fields
- Use coerce for numeric inputs
- Validate only what you need
Top comments (0)