# Author Fillo forms in JSX

Last updated: 2026-07-03

Canonical human page: https://fillo.so/docs/authoring
Install docs: https://fillo.so/docs/embed.md
Styling docs: https://fillo.so/docs/styling.md
Troubleshooting: https://fillo.so/docs/troubleshooting.md

In React, declare a form as JSX: one inert `Fillo.*` component per question. The elements are never rendered — they compile into the exact form `defineForm()` emits (identical JSON, identical content hash, guarded by equivalence tests), rendered through the same framed `FilloForm` with the same sync and draft-by-default behavior.

**Rule 1, before you paste anything: define forms in a client module — `"use client"` at the top — and pass the compiled value across server/client boundaries, never the JSX.**

```tsx
"use client";

import { Fillo, createClient } from "@usefillo/react";
import { 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>
  );
}
```

`when` is exported by `@usefillo/core` (a dependency of `@usefillo/react`).

## The five hard rules

1. **Client module.** 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.** 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.…/>}`.** 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**, called inline as `{contactFields()}`. Wrapper components are invisible to the compiler (children are never rendered) and throw.
5. **`defineForm()` params stay first-class and canonical.** JSX is sugar that compiles to it — use whichever reads best; nothing is deprecated.

## 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>
```

## Conditional questions: when()

`when("fieldId")` builds `visibleIf` conditions as plain data. Pass one condition or an array — arrays AND together (there is deliberately no OR).

| 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" />}
```

## Options: children or prop

Choice fields (Select, MultiSelect, Dropdown, Ranking) take options as `<Fillo.Option>` children or as an `options` prop. Both compile identically; both at once throws `jsx-option_prop_conflict`. The option id is the stored answer value — permanent, like field ids.

```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} />
```

## Reuse without wrappers

```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>;
```

## Compile at module scope + headless

`Fillo.defineForm(<Fillo.Form …/>)` compiles the element to a `CodeForm` — the same value `defineForm()` returns. Use it to share one form across surfaces, or to feed `<FilloProvider>` for a fully headless layout.

```tsx
"use client";

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

// 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>;
```

## Component reference

Generated from `JSX_BLOCK_SPECS` (exported by `@usefillo/core`) — the same manifest the compiler walks, so this table can't drift. Every component also takes `id` (required) and `visibleIf`. Unknown props throw `jsx-unknown_prop`.

| 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 | — |
| 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 | — |
| Fillo.Scale | linear_scale | label, description, required, placeholder, min, max, minLabel, maxLabel | — |
| 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 | — |
| Fillo.FileUpload | file_upload | label, description, required, placeholder, maxFiles, maxFileSizeMb, accept | — |
| Fillo.Hidden | hidden | label, description, required, placeholder, paramName, defaultValue | — |
| 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 | — |

## Migrating from defineForm

Keep every id exactly as it is and translate blocks 1:1 — the JSX compiles to the same schema, so responses, logic, and piping carry over untouched.

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

// After — JSX, same ids, compiles to the same form:
const contact = Fillo.defineForm(
  <Fillo.Form id="contact">
    <Fillo.Email id="email" label="Work email" required />
  </Fillo.Form>,
);
```

Content is identical after normalization. If your original params literal used a different key order, the pre-normalization content hash can differ once, staging ONE draft — publish it and you're aligned; subsequent deploys are hash-stable.

## Errors

Authoring mistakes throw `FilloJsxError` with a stable code; each message links to its fix at `https://fillo.so/docs/troubleshooting#jsx-<code>`. Full catalog: https://fillo.so/docs/troubleshooting.md.
