---
title: "Code-defined forms"
description: "Keep forms in your repo: author them typed with JSX or defineForm(), sync them into a workspace, and review changes like any other code."
group: "Build forms"
order: 0
type: "feature"
keywords:
  - "JSX"
  - "defineForm"
  - "code form"
  - "sync"
  - "lifecycle"
updated: "2026-07-17"
---

Use `defineForm()` to define a typed form in Vue, Svelte, plain HTML, or React. In React, you can use one `Fillo.*` JSX component per question instead. Both versions create the same schema, and this guide shows them side by side. The JSX components define the form; they do not render its fields.

**React: define forms in a client module.** This applies only to `<Fillo.Form>` JSX. Add `"use client"` at the top of the file. Pass the compiled form across server and client boundaries, not the JSX.

<a id="form"></a>

## The whole form

```tsx
"use client";

import { Fillo, createClient, when } from "@usefillo/react";
import "@usefillo/react/styles.css";

const client = createClient({ key: process.env.NEXT_PUBLIC_FILLO_KEY! });

export function ContactForm() {
  return (
    <Fillo.Form id="contact" title="Talk to us" client={client}>
      <Fillo.Text id="name" label="Your name" required />
      <Fillo.Email id="email" label="Work email" required />
      <Fillo.Select id="topic" label="What is this about?" required>
        <Fillo.Option id="support" label="Support" />
        <Fillo.Option id="sales" label="Sales" />
      </Fillo.Select>
      <Fillo.Text id="company" label="Company" visibleIf={when("topic").eq("sales")} />
      <Fillo.LongText id="message" label="How can we help?" />
    </Fillo.Form>
  );
}
```

```ts
import { defineForm, createClient, renderForm } from "@usefillo/dom";
import { when } from "@usefillo/core"; // @usefillo/dom doesn't re-export when()

const client = createClient({ key: "pk_…" });

const contact = defineForm({
  id: "contact",
  title: "Talk to us",
  pages: [{ id: "main", blocks: [
    { id: "name", kind: "short_text", label: "Your name", required: true },
    { id: "email", kind: "email", label: "Work email", required: true },
    { id: "topic", kind: "select", label: "What is this about?", required: true, options: [
      { id: "support", label: "Support" },
      { id: "sales", label: "Sales" },
    ]},
    { id: "company", kind: "short_text", label: "Company", visibleIf: [when("topic").eq("sales")] },
    { id: "message", kind: "long_text", label: "How can we help?" },
  ]}],
});

renderForm("#form", { form: contact, client });
// React: <FilloForm form={contact} client={client} />
```


`when` is exported by `@usefillo/core` (a dependency of both `@usefillo/react` and `@usefillo/dom`, though `@usefillo/dom` doesn't re-export it — import it from `@usefillo/core` directly alongside `defineForm`/`createClient` from `@usefillo/dom`).

<a id="rules"></a>

## Rules that keep the schema stable
Rules 2 and 5 apply to every framework. Rules 1, 3, and 4 apply only to JSX.

1. **Client module** *(JSX only).* Define forms in a client module (`"use client"`). JSX elements don't survive a server/client boundary. Pass the compiled value across it, never the JSX.
2. **Ids are permanent** *(every form).* Ids key responses, logic, piping, and sync. Never rename casually, never derive from position or a loop counter.
3. **Conditional questions are `visibleIf={when(…)…}`, never `{cond && <Fillo.…/>}`** *(JSX only — params forms are static objects, not re-evaluated per render).* Branching the JSX makes a per-visitor schema, which churns drafts in your workspace. A dev warning fires when a form's structure changes between renders.
4. **Reuse is a plain function returning elements** *(JSX only)*, called inline as `{contactFields()}`. Wrapper components are invisible to the compiler (children are never rendered) and throw.
5. **You can always use `defineForm()`** *(every form).* JSX compiles to the same schema. Use the version that is easier to read in your project.

<a id="pages"></a>

## Multi-page forms
Wrap blocks in `<Fillo.Page>` elements. Children are either all pages or all blocks; mixing throws `jsx-page_mix`. Blocks without pages become one implicit page.


```tsx
<Fillo.Form id="onboarding" title="Get set up" client={client}>
  <Fillo.Page id="you" title="About you">
    <Fillo.Text id="name" label="Full name" required />
    <Fillo.Email id="email" label="Work email" required />
  </Fillo.Page>
  <Fillo.Page id="team" title="Your team">
    <Fillo.Number id="seats" label="How many seats?" min={1} />
    <Fillo.Checkbox id="invite" label="Email my team an invite" />
  </Fillo.Page>
</Fillo.Form>
```

```ts
const onboarding = defineForm({
  id: "onboarding",
  title: "Get set up",
  pages: [
    { id: "you", title: "About you", blocks: [
      { id: "name", kind: "short_text", label: "Full name", required: true },
      { id: "email", kind: "email", label: "Work email", required: true },
    ]},
    { id: "team", title: "Your team", blocks: [
      { id: "seats", kind: "number", label: "How many seats?", min: 1 },
      { id: "invite", kind: "checkbox", label: "Email my team an invite" },
    ]},
  ],
});
```


<a id="jumps"></a>

## Page jumps and early exit
Give a page a `next` array to branch the flow. Each `{ when, to }` rule sends the respondent to another page ID or to `"end"`. Conditions use the same `when()` model as `visibleIf` and combine with AND.

Rules run in order and the first match wins. Without a match, the next page opens. Skipped pages are not required and their answers are left out of the submission. Add routing in `defineForm()` or in the builder's **Skip logic** editor; `<Fillo.Page>` does not take routing props.

```ts
import { when } from "@usefillo/core";

// A page's `next` rules decide where the form goes after that page. Rules run
// top-to-bottom; the first whose conditions all match wins. `to` is another
// page's id, or "end" to finish the form early. No rule matches → the next
// page as usual. Conditions reuse when() — the same model as visibleIf.
const triage = defineForm({
  id: "triage",
  pages: [
    { id: "start", blocks: [
      { id: "kind", kind: "select", label: "What do you need?", options: [
        { id: "bug", label: "Report a bug" },
        { id: "praise", label: "Say something nice" },
      ]},
    ],
      next: [
        // Praise skips the repro page and ends right here:
        { when: [when("kind").eq("praise")], to: "end" },
      ],
    },
    { id: "repro", blocks: [
      { id: "steps", kind: "long_text", label: "Steps to reproduce", required: true },
    ]},
    { id: "contact", blocks: [
      { id: "email", kind: "email", label: "Email for updates" },
    ]},
  ],
});

// "Report a bug" flows start → repro → contact and MUST answer steps.
// "Say something nice" ends after the first page — steps is never required,
// and a submission that skipped it validates fine on both client and server.
```

<a id="conditions"></a>

## Conditional questions: when()
`when("fieldId")` builds `visibleIf` conditions as plain data. Pass one condition or an array. Conditions in one array use AND; there is no OR operator. In the schema, `visibleIf` is always an array, even for one condition.

| Builder | Shows the field when |
| --- | --- |
| when("id").eq(value) | The answer equals value (any selected option in a multi-select). |
| when("id").neq(value) | The answer differs from value. Unanswered never satisfies neq. |
| when("id").contains(value) | Text contains value (case-insensitive), or a multi-select includes it. |
| when("id").gt(n) | A numeric answer is greater than n. |
| when("id").lt(n) | A numeric answer is less than n. |
| when("id").answered() | The field has any answer. |
| when("id").notAnswered() | The field has no answer yet. |


```tsx
import { when } from "@usefillo/react";

// ✅ conditional questions are schema data:
<Fillo.LongText
  id="unclear"
  label="What was unclear?"
  visibleIf={when("vote").eq("down")}
/>

// Multiple conditions AND together:
<Fillo.Text
  id="phone"
  label="Phone"
  visibleIf={[when("topic").eq("sales"), when("seats").gt(50)]}
/>

// ❌ never branch the JSX — a per-visitor schema churns drafts
// in your workspace (a dev warning fires):
{topic === "sales" && <Fillo.Text id="company" label="Company" />}
```

```ts
import { when } from "@usefillo/core";

// ✅ conditional questions are schema data — when() returns a plain
// {fieldId, op, value} object, valid directly in visibleIf:
{
  id: "unclear",
  kind: "long_text",
  label: "What was unclear?",
  visibleIf: [when("vote").eq("down")],
}

// Multiple conditions AND together. visibleIf is always an array in the
// schema, even for a single condition:
{
  id: "phone",
  kind: "short_text",
  label: "Phone",
  visibleIf: [when("topic").eq("sales"), when("seats").gt(50)],
}

// No per-visitor-schema footgun to avoid here: a params form is one
// static object, not recompiled on every render like JSX is.
```


<a id="options"></a>

## Options: children or prop
Choice fields accept `<Fillo.Option>` children or an `options` prop. Do not use both at once. The option ID is stored with each answer, so keep it stable. With `defineForm()`, use the `options` array.


```tsx
// As children — reads best inline:
<Fillo.Select id="plan" label="Plan" required>
  <Fillo.Option id="hobby" label="Hobby" />
  <Fillo.Option id="team" label="Team" />
</Fillo.Select>

// As a prop — best when the list already exists as data:
const PLANS = [
  { id: "hobby", label: "Hobby" },
  { id: "team", label: "Team" },
];
<Fillo.Select id="plan" label="Plan" required options={PLANS} />
```

```ts
// Only one spelling in params — this is exactly what both JSX
// spellings on the left compile to:
{
  id: "plan",
  kind: "select",
  label: "Plan",
  required: true,
  options: [
    { id: "hobby", label: "Hobby" },
    { id: "team", label: "Team" },
  ],
}
```


<a id="reuse"></a>

## Reuse without wrappers
Reuse fields with a function or array that returns blocks. Do not wrap fields in another React component.


```tsx
// ✅ reuse is a plain function returning elements, called inline:
function contactFields() {
  return (
    <>
      <Fillo.Text id="name" label="Your name" required />
      <Fillo.Email id="email" label="Work email" required />
    </>
  );
}

<Fillo.Form id="contact" client={client}>
  {contactFields()}
  <Fillo.LongText id="message" label="Message" />
</Fillo.Form>;

// ❌ a wrapper component — <Fillo.Form> never renders children,
// so <ContactFields /> is invisible to the compiler and throws:
<Fillo.Form id="contact" client={client}>
  <ContactFields />
</Fillo.Form>;
```

```ts
// Reuse is just a function (or array) returning blocks — plain data,
// spread into any pages array. There's no wrapper-component footgun in
// params: blocks are data, never components.
function contactFields() {
  return [
    { id: "name", kind: "short_text", label: "Your name", required: true },
    { id: "email", kind: "email", label: "Work email", required: true },
  ];
}

const contact = defineForm({
  id: "contact",
  pages: [{ id: "main", blocks: [
    ...contactFields(),
    { id: "message", kind: "long_text", label: "Message" },
  ]}],
});
```


<a id="module-scope"></a>

## Compile at module scope + headless
`Fillo.defineForm(<Fillo.Form …/>)` returns the same `CodeForm` value as `defineForm()`. Render it with `<FilloForm>`, build a custom React layout with `<FilloProvider>`, or use `createFormController()` in another framework.


```tsx
"use client";

import { Fillo, FilloForm, FilloProvider, FormField, createClient } from "@usefillo/react";

const client = createClient({ key: "pk_…" });

// Compile once at module scope. The result is a plain CodeForm —
// the same value defineForm() returns.
const feedback = Fillo.defineForm(
  <Fillo.Form id="feedback" title="Feedback">
    <Fillo.Rating id="score" label="How was it?" max={5} required />
    <Fillo.LongText id="notes" label="Tell us more" />
  </Fillo.Form>,
);

// Render it framed…
<FilloForm form={feedback} client={client} />;

// …or headless, in your own layout:
<FilloProvider form={feedback} client={client}>
  <FormField id="score" />
  <FormField id="notes" />
</FilloProvider>;
```

```ts
import { createClient, defineForm, createFormController, renderForm } from "@usefillo/dom";

const client = createClient({ key: "pk_…" });

// defineForm() already IS the module-scope compile step — there's no
// separate build phase like Fillo.defineForm(jsx) needs:
const feedback = defineForm({
  id: "feedback",
  title: "Feedback",
  pages: [{ id: "main", blocks: [
    { id: "score", kind: "rating", label: "How was it?", max: 5, required: true },
    { id: "notes", kind: "long_text", label: "Tell us more" },
  ]}],
});

// Render it framed…
renderForm("#form", { form: feedback, client });

// …or headless: createFormController runs the same engine and renders
// nothing — the same one <FilloProvider> wraps for React. Mirror
// getState() into your own layout (Vue, Svelte, or vanilla reactivity):
async function mountHeadlessFeedback() {
  const { formId } = await client.syncForm(feedback.id, feedback.schema, feedback.theme);
  const fillo = createFormController({ form: feedback.schema, formId, client });
  fillo.subscribe(() => {
    const state = fillo.getState();
    // update your own DOM/reactive state from state.data / state.errors / state.blocks
  });
  return fillo;
}
```


**Headless registration.** `syncForm()` is plain HTTP, so it also runs with no browser at all: call it from any Node process — a deploy hook, CI, a server route — to register the form in your workspace and get back `{ formId, status, warning }`, no render involved. The compiler behind `Fillo.defineForm()` is React-free by design (`codeFormFromJsx()` / `schemaFromJsx()`, exported by `@usefillo/core`), so compiling and syncing JSX-authored forms never needs a DOM. Keep the form in one shared module that both your app and the sync script import; then the form your visitors see and the schema you sync can never drift.

```ts
// scripts/sync-forms.ts — node/tsx in CI or a deploy hook. No browser, no render.
import { createClient } from "@usefillo/core";
import { feedback } from "../src/forms/feedback"; // the same module your app renders

const client = createClient({ key: process.env.FILLO_KEY! });

const { formId, status, warning } = await client.syncForm(
  feedback.id,
  feedback.schema,
  feedback.theme,
);
if (warning) console.warn(warning); // e.g. storage to connect — a person finishes that in the dashboard
console.log("synced", feedback.id, "→", formId, status);
```

<a id="components"></a>

## Component reference
Generated from the published subset of `JSX_BLOCK_SPECS` (exported by `@usefillo/core`) — source APIs appear here after their npm version is live. Every component also takes `id` (required) and `visibleIf`. Unknown props throw `jsx-unknown_prop`. The **kind** column is the exact string you write in a params block's `kind` field.

| Component | kind | Props | Children |
| --- | --- | --- | --- |
| Fillo.Text | short_text | label, description, required, placeholder, maxLength | — |
| Fillo.LongText | long_text | label, description, required, placeholder, maxLength | — |
| Fillo.Email | email | label, description, required, placeholder, maxLength | — |
| Fillo.Url | url | label, description, required, placeholder, maxLength | — |
| Fillo.Phone | phone | label, description, required, placeholder, defaultCountry | — |
| Fillo.Number | number | label, description, required, placeholder, min, max, decimals, prefix, suffix, notation | — |
| Fillo.Select | select | label, description, required, placeholder, options, allowOther, shuffleOptions | \<Fillo.Option\> elements |
| Fillo.MultiSelect | multi_select | label, description, required, placeholder, options, allowOther, shuffleOptions | \<Fillo.Option\> elements |
| Fillo.Dropdown | dropdown | label, description, required, placeholder, options, allowOther, shuffleOptions | \<Fillo.Option\> elements |
| Fillo.Checkbox | checkbox | label, description, required, placeholder, appearance | — |
| Fillo.Rating | rating | label, description, required, placeholder, max, insightsMetric | — |
| Fillo.Scale | linear_scale | label, description, required, placeholder, min, max, minLabel, maxLabel, insightsMetric | — |
| Fillo.Ranking | ranking | label, description, required, placeholder, options | \<Fillo.Option\> elements |
| Fillo.Matrix | matrix | label, description, required, placeholder, rows, columns | — |
| Fillo.Signature | signature | label, description, required, placeholder | — |
| Fillo.Date | date | label, description, required, placeholder, defaultToday | — |
| Fillo.FileUpload | file_upload | label, description, required, placeholder, maxFiles, maxFileSizeMb, accept | — |
| Fillo.Hidden | hidden | label, description, required, placeholder, paramName, defaultValue | — |
| Fillo.Calculated | calculated | label, description, required, placeholder, calc, decimals, prefix, suffix | — |
| Fillo.RepeatingGroup | repeating_group | label, description, required, placeholder, minInstances, maxInstances, addLabel, itemLabel | — |
| Fillo.Custom | custom | label, description, required, placeholder, component, config | — |
| Fillo.Heading | heading | text | the text (plain string) |
| Fillo.Paragraph | paragraph | text | the text (plain string) |
| Fillo.Divider | divider | — | — |

Structural elements:

| Component | Role | Props | Children |
| --- | --- | --- | --- |
| Fillo.Page | (page) | title | the page's blocks |
| Fillo.Option | (option) | label, icon | — |

<a id="migrating"></a>

## The two syntaxes compile identically
Translate between params and JSX block-by-block; ids carry over unchanged in either direction. Both compile to the same schema, so responses, logic, and piping carry over untouched whichever one you start from.

```tsx
// params:
const contact = defineForm({
  id: "contact",
  pages: [{ id: "main", blocks: [
    { id: "email", kind: "email", label: "Work email", required: true },
  ]}],
});

// JSX — same id, same schema:
const contact = Fillo.defineForm(
  <Fillo.Form id="contact">
    <Fillo.Email id="email" label="Work email" required />
  </Fillo.Form>,
);
```

Content is identical after normalization. If the key order changes when you translate between syntaxes, the pre-normalization content hash can differ once and stage a single draft. Publish it and you're aligned; subsequent deploys are hash-stable.

<a id="lifecycle"></a>

## What the dashboard shows
1. **Define in code and register the schema.** For a claimed workspace, stage the schema with authenticated fillo push --stage or a server-held sync token. If the project allows publishable-key staging, the first browser render can create the same draft. A capped, unclaimed preview workspace may apply its first sync immediately in its default project.
2. **Publish claimed-workspace drafts in the dashboard.** The draft shows up in your dashboard badged "Code managed". Publish it from the form's top bar; the browser console notice links straight there. Publishing turns on public submissions and the hosted /f page.
3. **Structure is read-only; operations stay yours.** There is no "Edit form" on a code form — the schema ships from code on every deploy. Dashboard-owned operations survive every sync: webhooks and integrations, notification email and respondent receipts, in-progress answer visibility, resume emails and URLs, drop-off digests, file storage, and publishing.
4. **Later changes stay behind review.** Authenticated pushes and allowed publishable-key syncs stage changed schemas beside the published form. Visitors keep the resolved live version until you re-publish, even if deployed inline code is already newer. The SDK reports that mismatch to developer diagnostics. With authenticated-only sync, a different schema must be staged first. Unchanged deploys are content-hash no-ops.
5. **A definition that can't render never reaches visitors.** If your code ships a form definition Fillo can't render, such as the same field id twice, development shows the reason. In production the SDK shows the version published on Fillo instead, or nothing when none is published, and the browser console names the fault. The form editor and Responses show a notice explaining the fault, and the workspace owner gets at most one email a day about it.
6. **Responses version across deploys.** Every publish snapshots a form version, and each response records the version it answered — old answers keep their meaning after fields change.

The pk_… key is publishable by design: it ships in your bundle and resolves or synchronizes code-defined forms; published form reads and responses work by form id independently. A project can require authenticated fillo push --stage or a server sync token for schema changes, or allow its public key to stage drafts; neither path lets a public key publish a live change, while capped unclaimed previews may still apply syncs immediately until claimed.

## Errors

Authoring mistakes in `<Fillo.Form>` JSX throw `FilloJsxError` with a stable code; each message links to its fix at `/docs/troubleshooting#jsx-<code>`. This catalog is JSX-specific — writing params directly, there's no separate authoring-error layer; TypeScript's own type checking catches shape mistakes. Browse the [full error catalog](/docs/troubleshooting) in the troubleshooting guide.

## Related

- [Schema and validation](/docs/schema): Keep field IDs and answer shapes stable.
- [Troubleshooting](/docs/troubleshooting#jsx-errors): Resolve JSX authoring errors.
