Client-side validation

There are three ways of handling client-side validation with Superforms:

  • The built-in browser validation, which doesn’t require JavaScript to be enabled in the browser.
  • Using a Zod schema, usually the same one as on the server. Requires JavaScript and use:enhance.
  • Using a Superforms validators object. Requires JavaScript and use:enhance.

The last two are mutually exclusive, but the browser validation can be combined with any of them. Here’s how it works:

Built-in browser validation

There is a web standard for client-side form validation, which doesn’t require JavaScript and is virtually effortless to use with Superforms:


To use the built-in browser constraints, just spread the $constraints store for a schema field on its input field:

<script lang="ts">
  export let data;
  const { form, constraints } = superForm(data.form);

  {...$} />

The constraints is an object with validation properties mapped from the schema:

  pattern?: string;      // z.string().regex(r)
  step?: number | 'any'; // z.number().step(n)
  minlength?: number;    // z.string().min(n)
  maxlength?: number;    // z.string().max(n)
  min?: number | string; // number if z.number.min(n), ISO date string if
  max?: number | string; // number if z.number.max(n), ISO date string if
  required?: true;       // true if not nullable(), nullish() or optional()

For some input types, a correct format is required. For example with date fields, both the underlying data and the constraint needs to be in yyyy-mm-dd format, which can be handled by using a proxy and adding attributes after the constraints spread, in which case they will take precedence:

  aria-invalid={$ ? 'true' : undefined}
  min={$, 10)} />

Check the validation attributes and their valid values at MDN.


The built-in browser validation can be a bit constrained (pun intended); for example, you can’t easily control the position and appearance of the error messages. Instead, you can customize the validation with a number of options, so $errors will be displayed in real-time.

const { form, enhance, constraints, validate } = superForm(data.form, {
  validators: AnyZodObject | {
    field: (value) => string | string[] | null | undefined;
  validationMethod: 'auto' | 'oninput' | 'onblur' | 'submit-only' = 'auto',
  defaultValidator: 'keep' | 'clear' = 'keep',
  customValidity: boolean = false


validators: AnyZodObject | {
  field: (value) => string | string[] | null | undefined;

Setting the validators option to the same Zod schema as on the server is the most convenient and recommended way. Just put the schema in a shared directory, $lib/schemas for example, and import it on the client as well as on the server.

This will increase the size of the client bundle a bit however, since Zod now has to be imported on the client. If you’re highly concerned about a few extra kilobytes, a lightweight alternative is to use a Superforms validation object.

It’s an object with the same keys as the form, with a function that receives the field value and should return string | string[] as an error message, or null | undefined if the field is valid.

Here’s how to validate a string length, for example:

const { form, errors, enhance } = superForm(data.form, {
  validators: {
    name: (name) =>
      name.length < 3 ? 'Name must be at least 3 characters' : null

For nested data, just keep building on the validators structure. Note that arrays have a single validator that will be applied to each value in the array:

const schema = z.object({
  name: z.string().min(3),
  tags: z.string().min(2).array()

const { form, errors, enhance } = superForm(data.form, {
  validators: {
    name: (name) =>
      name.length < 3 ? 'Name must be at least 3 characters' : null,
    tags: (tag) => (tag.length < 2 ? 'Tag must be at least 2 characters' : null)


validationMethod: 'auto' | 'oninput' | 'onblur' | 'submit-only',

The validation is triggered when a value is changed, not just when focusing on, or tabbing through a field. The default validation method is based on the “reward early, validate late” pattern, a researched way of validating input data that makes for a high user satisfaction:

  • If entering data in a field that has or previously had errors, validate on input
  • Otherwise, validate on blur.

But you can instead use the oninput or onblur setting to always validate on one of these respective events, or submit-only to validate only on submit.


This is an option for specifying the validation behavior for fields when no validation exists for a specific field. In other words, if validators is set, this option won’t have any effect for any fields included there.

defaultValidator: 'keep' | 'clear' = 'keep'

The default value keep means that validation errors will be displayed until the form submits. The other option, clear, will remove the error as soon as the field value is modified.


This option uses the browser built-in tooltip to display validation errors, so neither $errors nor $constraints are required on the form. See the error handling page for details and an example.


The validate function, deconstructed from superForm, gives you complete control over the validation process. Examples of how to use it:

const { form, enhance, validate } = superForm(data.form);

// Simplest case, validate what's in the field right now

// Validate without updating, for error checking
const nameErrors = await validate('name', { update: false });

// Validate and update field with a custom value
validate('name', { value: 'Test' });

// Validate a custom value, update errors only
validate('name', { value: 'Test', update: 'errors' });

// Validate and update nested data, and also taint the field
validate('tags[1].name', { value: 'Test', taint: true });

// If called with no arguments, it validates the whole form and
// returns a result similar to superValidate
const result = await validate();

if (result.valid) {
  // ...

Asynchronous validation and debouncing

All the validators are asynchronous, so you can return a Promise and it will work. But a slow validator will delay the others, so for a server round-trip validation like checking if a username is available, you might want to exclude that field from the schema and handle it manually, with on:input and a package like throttle-debounce.

Errors can also be set manually by updating the $errors store:

// Needs to be a string[]
$errors.username = ['Username is already taken.'];

Test it out

This example demonstrates how validators are used to check if tags are of the correct length.

Set a tag name to blank and see that no errors show up until you move focus outside the field (blur). When you go back and correct the mistake, the error is removed as soon as you enter more than one character (input).