Skip to main content

Author forms in JSX.

One inert component per question. The elements are compiled — never rendered — into the exact form defineForm() emits, then rendered through the same framed FilloForm: same sync, same draft-by-default, same responses.

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.

Installing for the first time? Start at /docs/embed. Something broken? See /docs/troubleshooting.

On this page

The whole form

contact-form.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). Everything else — the frame, styling, and renderer props like appearance and onSubmitted — works exactly as documented in /docs/embed.

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.

multi-page
<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).

BuilderShows 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.
visibleIf
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.

two spellings, one schema
// 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

functions, not components
// ✅ 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.

one form, framed or headless
"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.

ComponentkindPropsChildren
Fillo.Textshort_textlabel, description, required, placeholder, maxLength
Fillo.LongTextlong_textlabel, description, required, placeholder, maxLength
Fillo.Emailemaillabel, description, required, placeholder, maxLength
Fillo.Urlurllabel, description, required, placeholder, maxLength
Fillo.Phonephonelabel, description, required, placeholder, defaultCountry
Fillo.Numbernumberlabel, description, required, placeholder, min, max
Fillo.Selectselectlabel, description, required, placeholder, options, allowOther, shuffleOptions<Fillo.Option> elements
Fillo.MultiSelectmulti_selectlabel, description, required, placeholder, options, allowOther, shuffleOptions<Fillo.Option> elements
Fillo.Dropdowndropdownlabel, description, required, placeholder, options, allowOther, shuffleOptions<Fillo.Option> elements
Fillo.Checkboxcheckboxlabel, description, required, placeholder, appearance
Fillo.Ratingratinglabel, description, required, placeholder, max
Fillo.Scalelinear_scalelabel, description, required, placeholder, min, max, minLabel, maxLabel
Fillo.Rankingrankinglabel, description, required, placeholder, options<Fillo.Option> elements
Fillo.Matrixmatrixlabel, description, required, placeholder, rows, columns
Fillo.Signaturesignaturelabel, description, required, placeholder
Fillo.Datedatelabel, description, required, placeholder
Fillo.FileUploadfile_uploadlabel, description, required, placeholder, maxFiles, maxFileSizeMb, accept
Fillo.Hiddenhiddenlabel, description, required, placeholder, paramName, defaultValue
Fillo.Customcustomlabel, description, required, placeholder, component, config
Fillo.Headingheadingtextthe text (plain string)
Fillo.Paragraphparagraphtextthe text (plain string)
Fillo.Dividerdivider
Fillo.Page(page)titlethe 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.

params → jsx
// 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.

Authoring mistakes throw FilloJsxError with a stable code that links to its fix — the full catalog lives at /docs/troubleshooting#jsx-errors.

Was this page useful?

Was this page helpful?
Powered by Fillo