Migrate from Superforms

This guide walks you through migrating a form from Superforms to Formisch. It covers the mental-model differences, shows the same form implemented in both libraries, and maps the Superforms API to its Formisch equivalents.

Migrating is a rewrite of the form layer, not a drop-in replacement. Superforms is built around SvelteKit's server form actions, while Formisch is a pure client-side form layer. Migration typically means moving validation and submission out of +page.server.ts actions into client-side handlers, or wiring server validation separately. The good news: if you already validate with Valibot, your schemas carry over unchanged.

Both libraries can coexist in the same application, so you can migrate one form at a time instead of rewriting everything at once.

Key differences

The biggest shift is where the form lives. Superforms is server-first: you initialize the form with superValidate in a load function, post to a form action, and connect the client with superForm and use:enhance. Even in SPA mode, the API keeps this server-shaped structure with a SuperValidated object and an onUpdate event. Formisch is client-first: you call the createForm rune directly in your component, and there is no load function, form action, or adapter involved. Dedicated SvelteKit integration is planned for one of the next releases; until then, server validation is something you wire up separately, for example by parsing the same Valibot schema in an API endpoint.

Beyond that, three differences shape the migration:

  • Schema handling: Both libraries are schema-based, but Superforms supports many schema libraries through adapters like valibot(schema) and valibotClient(schema). Formisch supports only Valibot and takes the schema directly, without an adapter. The schema is the single source of truth for types, validation, and form structure at once.
  • Validation location and timing: Superforms validates on the server by default, and client-side validation is opt-in via the validators option with timing controlled by validationMethod. Formisch always validates on the client against your schema, and timing is controlled by the validate and revalidate config.
  • Reactivity: Superforms exposes Svelte stores that hold the whole form, like $form, $errors, and $tainted, and you connect inputs with bind:value={$form.email}. Formisch is headless with fine-grained per-field reactivity: the Field component and useField rune expose a store for a single field, and you connect inputs by spreading field.props. Nested data works out of the box through path arrays, so there is no equivalent of the dataType: 'json' option.

One trade-off to be aware of: because Formisch runs entirely on the client, your forms no longer submit without JavaScript. If progressive enhancement through form actions is a hard requirement, Superforms remains the better fit for that form.

Side-by-side example

The following login form has two validated fields and a submit handler. Both versions use the same Valibot schema.

Superforms

Superforms splits the form across three files: the shared schema, the server route with the load function and form action, and the page component.

// src/lib/schemas.ts
import * as v from 'valibot';

export const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
});
// src/routes/login/+page.server.ts
import { LoginSchema } from '$lib/schemas';
import { fail } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms';
import { valibot } from 'sveltekit-superforms/adapters';

export const load = async () => {
  const form = await superValidate(valibot(LoginSchema));
  return { form };
};

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, valibot(LoginSchema));
    if (!form.valid) {
      return fail(400, { form });
    }
    // Log in the user with form.data
    return { form };
  },
};
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import { superForm } from 'sveltekit-superforms';
  import { valibotClient } from 'sveltekit-superforms/adapters';
  import { LoginSchema } from '$lib/schemas';

  let { data } = $props();

  const { form, errors, enhance, submitting } = superForm(data.form, {
    validators: valibotClient(LoginSchema),
  });
</script>

<form method="POST" use:enhance>
  <input
    type="email"
    name="email"
    aria-invalid={$errors.email ? 'true' : undefined}
    bind:value={$form.email}
  />
  {#if $errors.email}<div>{$errors.email}</div>{/if}

  <input
    type="password"
    name="password"
    aria-invalid={$errors.password ? 'true' : undefined}
    bind:value={$form.password}
  />
  {#if $errors.password}<div>{$errors.password}</div>{/if}

  <button disabled={$submitting}>Login</button>
</form>

Formisch

The same form in Formisch is a single component. The schema stays exactly the same.

<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import { createForm, Field, Form } from '@formisch/svelte';
  import type { SubmitHandler } from '@formisch/svelte';
  import * as v from 'valibot';

  const LoginSchema = v.object({
    email: v.pipe(
      v.string(),
      v.nonEmpty('Please enter your email.'),
      v.email('The email address is badly formatted.')
    ),
    password: v.pipe(
      v.string(),
      v.nonEmpty('Please enter your password.'),
      v.minLength(8, 'Your password must have 8 characters or more.')
    ),
  });

  const loginForm = createForm({
    schema: LoginSchema,
    validate: 'blur',
    revalidate: 'input',
  });

  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    // Log in the user with the validated values
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
  };
</script>

<Form of={loginForm} onsubmit={submitForm}>
  <Field of={loginForm} path={['email']}>
    {#snippet children(field)}
      <input {...field.props} value={field.input} type="email" />
      {#if field.errors}<div>{field.errors[0]}</div>{/if}
    {/snippet}
  </Field>

  <Field of={loginForm} path={['password']}>
    {#snippet children(field)}
      <input {...field.props} value={field.input} type="password" />
      {#if field.errors}<div>{field.errors[0]}</div>{/if}
    {/snippet}
  </Field>

  <button type="submit" disabled={loginForm.isSubmitting}>Login</button>
</Form>

Migration steps

Install Formisch

Install the Svelte package of Formisch. If you used the Valibot adapter with Superforms, Valibot is already installed; otherwise add it as well, since it is a peer dependency.

npm install @formisch/svelte valibot

You can keep sveltekit-superforms installed while you migrate form by form and remove it at the end.

Move validation into a Valibot schema

If your Superforms setup already uses Valibot, your schemas carry over unchanged. Just drop the adapter: instead of wrapping the schema in valibot(...) or valibotClient(...), you pass it directly to createForm. If you used Zod or another schema library, rewrite the schema in Valibot. Most validations translate one to one.

import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(v.string(), v.nonEmpty(), v.email()),
  password: v.pipe(v.string(), v.nonEmpty(), v.minLength(8)),
});

Define the fields in the same order they appear in your form, since Formisch focuses the first invalid field on submit based on the schema order. See the define your form guide for details on how the schema shapes your form.

Replace the form setup

Remove the superValidate call from your load function and the data.form plumbing, and replace superForm with the createForm rune. Initial values that previously came from the load function move into the initialInput config.

<script lang="ts">
  import { createForm } from '@formisch/svelte';

  const loginForm = createForm({
    schema: LoginSchema,
    initialInput: {
      email: 'user@example.com',
    },
  });
</script>

If your Superforms form used client-side validators, carry the timing over with validate: 'blur' and revalidate: 'input', as explained in the validation timing section below. Without these options, Formisch validates on submit first and then revalidates on every input.

The form store returned by createForm replaces the destructured stores of superForm: you access field state through the Field component and form state through properties like loginForm.isSubmitting.

Replace field bindings

In Superforms, inputs bind to the central form store with bind:value={$form.email} and read errors from $errors.email. In Formisch, you wrap each input in a Field component, spread field.props onto the element, and set its value from field.input. The name attribute and all event handlers are included in field.props.

<Field of={loginForm} path={['email']}>
  {#snippet children(field)}
    <input {...field.props} value={field.input} type="email" />
    {#if field.errors}<div>{field.errors[0]}</div>{/if}
  {/snippet}
</Field>

The path property is an array of keys, so nested fields like $form.user.email become path={['user', 'email']} without any dataType: 'json' configuration. For fields with a non-string schema like v.number() or v.boolean(), see the controlled fields and special inputs guides.

Update submission handling

Replace the form action and use:enhance with the Form component and its onsubmit property. Where Superforms posts to a form action and processes the result in onUpdate or onUpdated, Formisch validates on the client and calls your handler with the typed output of your schema. Logic from your form action moves into this handler, for example as a fetch call to an API endpoint.

<script lang="ts">
  import type { SubmitHandler } from '@formisch/svelte';

  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
  };
</script>

<Form of={loginForm} onsubmit={submitForm}>
  <!-- fields -->
  <button type="submit">Login</button>
</Form>

If you still need server-side validation, parse the submitted data in your endpoint with the same Valibot schema. This keeps a single source of truth for both sides. See the handle submission guide for async submission and error handling patterns.

API mapping

SuperformsFormisch
superValidate(valibot(schema)) in loadNot needed, createForm runs on the client
superForm(data.form, options)createForm rune
defaults(valibot(schema))initialInput config of createForm
validators: valibotClient(schema)schema config, validation always comes from the schema
validationMethodvalidate / revalidate config
dataType: 'json'Not needed, path arrays support nested data
bind:value={$form.email}Field with field.props and field.input
$errors.emailfield.errors
$allErrorsgetDeepErrorEntries
$constraintsNo equivalent (see notes)
use:enhance with form actionForm with onsubmit
onUpdate / onUpdated eventsSubmitHandler passed to onsubmit
$submittingform.isSubmitting
$delayed / $timeoutNo equivalent (see notes)
$postedform.isSubmitted
$tainted / isTainted('email')field.isDirty / isDirty
reset({ data, newState })reset with initialInput
validateForm() / validate('email')validate
submit()submit
setError(form, 'email', message)setErrors
$message / setMessageNo equivalent (see notes)
taintedMessageNo equivalent (see notes)

A few entries have no direct counterpart:

  • $constraints: Superforms derives HTML validation attributes like required and minlength from the schema. Formisch validates at runtime against the schema instead and does not generate constraint attributes. Set attributes like required on your inputs yourself where you want them for accessibility or styling.
  • $delayed / $timeout: Formisch exposes isSubmitting and isValidating but has no built-in loading timers. If you need a delayed spinner, wrap form.isSubmitting in your own timer logic.
  • $message / setMessage: Status messages are not form state in Formisch. Use regular Svelte state that you set in your submit handler.
  • taintedMessage: There is no built-in navigation guard. You can build one with SvelteKit's beforeNavigate and the isDirty method.
  • validate('email'): Formisch has no partial validation. The validate method always parses the whole form against the schema, which keeps cross-field rules correct, and then distributes the errors to the individual fields.

Common patterns

Form state

Superforms spreads form state across several stores like $submitting, $posted, and $tainted with the isTainted helper. In Formisch, the form store exposes reactive properties directly: isSubmitting, isSubmitted, isValidating, isTouched, isEdited, isDirty, isValid, and errors. Each field offers per-field isTouched, isEdited, isDirty, and isValid counterparts.

<button type="submit" disabled={loginForm.isSubmitting || !loginForm.isDirty}>
  {loginForm.isSubmitting ? 'Saving...' : 'Save'}
</button>

To check the dirty state of a single field outside of a Field component, like isTainted('email') in Superforms, use the isDirty method. Wrap the call in $derived so it stays reactive:

import { isDirty } from '@formisch/svelte';

const emailIsDirty = $derived(isDirty(loginForm, { path: ['email'] }));

Validation timing

The validationMethod option of Superforms maps onto the validate and revalidate config of createForm. The default 'auto' behavior, where a field is first checked on blur and then re-checked on input once it has errors, corresponds to validate: 'blur' with revalidate: 'input':

const loginForm = createForm({
  schema: LoginSchema,
  validate: 'blur', // 'onblur' part of Superforms 'auto'
  revalidate: 'input', // 'oninput' part of Superforms 'auto'
});

'oninput' maps to validate: 'input', 'onblur' to validate: 'blur', and 'onsubmit' to validate: 'submit', which is the Formisch default. Note that validate and revalidate control when validation runs, not when errors are shown. The validation guide shows how to reveal errors at the right time using flags like field.isEdited and form.isSubmitted.

Field arrays

In Superforms, dynamic arrays require dataType: 'json', an {#each} loop over $form.tags, and manual array mutations like $form.tags = [...$form.tags, { name: '' }]. Formisch provides the FieldArray component, which exposes stable items keys for the {#each} block, together with methods like insert, remove, move, and swap:

<script lang="ts">
  import { Field, FieldArray, insert } from '@formisch/svelte';
</script>

<FieldArray of={todoForm} path={['todos']}>
  {#snippet children(fieldArray)}
    {#each fieldArray.items as item, index (item)}
      <Field of={todoForm} path={['todos', index, 'label']}>
        {#snippet children(field)}
          <input {...field.props} value={field.input} type="text" />
        {/snippet}
      </Field>
    {/each}
  {/snippet}
</FieldArray>

<button
  type="button"
  onclick={() =>
    insert(todoForm, { path: ['todos'], initialInput: { label: '' } })}
>
  Add todo
</button>

See the field arrays guide for the full API.

Reset

The reset() method of Superforms maps to the reset method of Formisch. Calling it without a config restores the initial values. Passing initialInput replaces the baseline, which covers both the data and newState options of Superforms at once, since future resets will use the new values:

import { reset } from '@formisch/svelte';

// Reset to the initial values
reset(loginForm);

// Reset to new values that become the new baseline
reset(loginForm, { initialInput: { email: 'new@example.com' } });

There is no resetForm option because Formisch does not reset the form after submission by default. If you want that behavior, call reset at the end of your submit handler.

Special inputs

Superforms binds special input types directly to the $form store, for example with bind:checked={$form.cookies} for a checkbox. In Formisch, you spread field.props and set the state attribute that matches the input type:

<Field of={form} path={['cookies']}>
  {#snippet children(field)}
    <label>
      <input {...field.props} type="checkbox" checked={field.input} />
      Yes, I want cookies
    </label>
  {/snippet}
</Field>

Checkboxes, radio buttons, selects and file inputs are covered in the special inputs guide. One thing to watch out for: Formisch's event handlers read values from the DOM as strings, so fields with a non-string schema like v.number() or v.date() must be controlled as described in the controlled fields guide.

Next steps

You now have everything you need to migrate your forms. To deepen your understanding of Formisch, work through the main concepts starting with define your form and add form fields. The validation guide covers error display in depth, the form methods guide lists all available methods, and the controlled fields guide explains how to handle non-string values.

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin