---
url: https://formhaus.dev/recipes/svelte.md
description: >-
  Render a Formhaus JSON form in Svelte 5 with runes: a FormEngine wrapper
  component with steps, branching routes, skip, validation errors and async
  submit.
---

# How do I use Formhaus with Svelte 5?

There is no Svelte adapter. A small runes component renders `FormEngine` from `@formhaus/core`.

```bash
npm install @formhaus/core
```

## Definition

```json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "id": "project-inquiry",
  "title": "Start a project",
  "submit": { "label": "Send inquiry" },
  "steps": [
    {
      "id": "service",
      "title": "What do you need?",
      "fields": [
        {
          "key": "service",
          "type": "radio",
          "label": "Service",
          "validation": { "required": true },
          "options": [
            { "value": "design", "label": "Design" },
            { "value": "development", "label": "Development" }
          ]
        }
      ],
      "routes": [
        { "to": "design", "show": [{ "field": "service", "eq": "design" }] },
        { "to": "development", "show": [{ "field": "service", "eq": "development" }] }
      ]
    },
    {
      "id": "design",
      "title": "Design scope",
      "fields": [
        { "key": "pages", "type": "number", "label": "Number of screens", "validation": { "required": true, "min": 1 } },
        { "key": "hasBrand", "type": "switch", "label": "We have brand guidelines" }
      ],
      "routes": [{ "to": "budget" }]
    },
    {
      "id": "development",
      "title": "Development scope",
      "fields": [
        {
          "key": "platform",
          "type": "select",
          "label": "Platform",
          "validation": { "required": true },
          "options": [
            { "value": "web", "label": "Web" },
            { "value": "ios", "label": "iOS" },
            { "value": "android", "label": "Android" }
          ]
        },
        { "key": "repoUrl", "type": "text", "label": "Existing repository URL" }
      ],
      "routes": [{ "to": "budget" }]
    },
    {
      "id": "budget",
      "title": "Budget",
      "skip": { "label": "Not sure yet" },
      "fields": [
        {
          "key": "budget",
          "type": "radio",
          "label": "Budget range",
          "validation": { "required": true },
          "options": [
            { "value": "small", "label": "Under $10k" },
            { "value": "medium", "label": "$10k to $50k" },
            { "value": "large", "label": "Over $50k" }
          ]
        }
      ]
    },
    {
      "id": "contact",
      "title": "Contact details",
      "back": { "label": "Edit answers" },
      "fields": [
        { "key": "name", "type": "text", "label": "Name", "validation": { "required": true } },
        { "key": "email", "type": "email", "label": "Work email", "validation": { "required": true } }
      ]
    }
  ]
}

```

## Component

`FormhausForm.svelte` bumps a `$state` counter on every engine change, and `$derived` reads the engine again:

```svelte
<script lang="ts">
  import { FormEngine, type FormDefinition, type SubmitFn } from '@formhaus/core';

  let { definition, onsubmit }: { definition: FormDefinition; onsubmit: SubmitFn } = $props();

  const engine = $derived(new FormEngine(definition));
  let version = $state(0);
  $effect(() => engine.subscribe(() => version++));

  const view = $derived.by(() => {
    version;
    return {
      step: engine.currentStep,
      fields: engine.visibleFields,
      values: { ...engine.values },
      errors: { ...engine.errors },
      busy: engine.stepValidating || engine.submitting,
      first: engine.isFirstStep,
      last: engine.isLastStep,
    };
  });

  const value = (key: string) => (view.values[key] ?? '') as string;

  function primary(event: SubmitEvent) {
    event.preventDefault();
    if (view.last) engine.submit(onsubmit);
    else engine.next();
  }
</script>

<form onsubmit={primary} novalidate>
  {#if view.step}<h2>{view.step.title}</h2>{/if}

  {#each view.fields as field (field.key)}
    <div>
      {#if field.type === 'radio'}
        <fieldset>
          <legend>{field.label}</legend>
          {#each field.options ?? [] as option}
            <label>
              <input type="radio" name={field.key} value={option.value} checked={view.values[field.key] === option.value}
                onchange={() => engine.setValue(field.key, option.value)} />
              {option.label}
            </label>
          {/each}
        </fieldset>
      {:else if field.type === 'checkbox' || field.type === 'switch'}
        <label>
          <input type="checkbox" role={field.type === 'switch' ? 'switch' : undefined} checked={view.values[field.key] === true}
            onchange={(e) => engine.setValue(field.key, e.currentTarget.checked)} />
          {field.label}
        </label>
      {:else}
        <label for={field.key}>{field.label}</label>
        {#if field.type === 'select'}
          <select id={field.key} value={value(field.key)} onchange={(e) => engine.setValue(field.key, e.currentTarget.value)}>
            <option value="" disabled>{field.placeholder ?? 'Select…'}</option>
            {#each field.options ?? [] as option}<option value={option.value}>{option.label}</option>{/each}
          </select>
        {:else if field.type === 'textarea'}
          <textarea id={field.key} value={value(field.key)} oninput={(e) => engine.setValue(field.key, e.currentTarget.value)}></textarea>
        {:else if field.type === 'number'}
          <input id={field.key} type="number" value={value(field.key)}
            oninput={(e) => engine.setValue(field.key, e.currentTarget.value === '' ? '' : e.currentTarget.valueAsNumber)} />
        {:else}
          <input id={field.key} type={field.type === 'email' ? 'email' : 'text'} value={value(field.key)}
            oninput={(e) => engine.setValue(field.key, e.currentTarget.value)} />
        {/if}
      {/if}
      {#if view.errors[field.key]}<p role="alert">{view.errors[field.key]}</p>{/if}
    </div>
  {/each}

  {#if !view.first && view.step?.back !== false}
    <button type="button" disabled={view.busy} onclick={() => engine.back()}>{view.step?.back?.label ?? 'Back'}</button>
  {/if}
  {#if view.step?.skip}<button type="button" disabled={view.busy} onclick={() => engine.skip(onsubmit)}>{view.step.skip.label}</button>{/if}
  <button type="submit" disabled={view.busy}>{view.last ? definition.submit.label : 'Continue'}</button>
</form>
```

## Usage

```svelte
<script lang="ts">
  import { parseDefinition } from '@formhaus/core';
  import FormhausForm from './FormhausForm.svelte';
  import json from './project-inquiry.json';

  const definition = parseDefinition(json);
  let sent = $state<Record<string, unknown> | null>(null);

  async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
    await fetch('/api/inquiry', { method: 'POST', body: JSON.stringify({ values, skippedSteps }) });
    sent = values;
  }
</script>

{#if sent}
  <pre>{JSON.stringify(sent, null, 2)}</pre>
{:else}
  <FormhausForm {definition} onsubmit={submit} />
{/if}
```

## How it works

* `engine.subscribe()` returns the unsubscribe function, so `$effect` cleans it up.
* `next()` validates the current step and follows `routes`. `submit(onsubmit)` validates every visible step and calls `onsubmit(values, skippedSteps)`.
* `skip(onsubmit)` skips a step with `skip`, and submits when it is the last step.
* `stepValidating` and `submitting` are `true` while navigation or the submit handler runs; the buttons are disabled then.
* `number` fields store a number, or `''` when empty. Checkbox and switch store booleans.
* Add `multiselect`, `file` and custom types the same way: call `engine.setValue(field.key, value)` with the value shape from [Field Types](/guide/fields).

## Related

* [Headless engine](/recipes/headless-engine): the same engine in plain JavaScript
* [FormEngine reference](/api/form-engine): every property and method
* [Multi-step with branching](/recipes/multi-step-branching): the same definition in React and Vue
