Skip to main content

Code-defined forms

Forms are typed code: a defineForm() object that renders in every framework. In React, you can write the same form as JSX, with one component per question. Every example below shows both because they compile to the identical schema.

The whole form

The JSX compiles to the exact schema defineForm() emits, then renders through the same default FilloForm renderer with the same syncing with a workspace, publishing, and responses.

contact-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>
  );
}

when is exported by @usefillo/core, which is 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. Everything else, including the frame, styling, and renderer props like appearance and onSubmitted, works exactly as documented in the quickstart guide.

Rules that keep the schema stable

Rules 2 and 5 apply to every framework. Rules 1, 3, and 4 are JSX mechanics, so skip them if you’re writing params directly.

  1. JSX onlyClient 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. every formIds are permanent. Ids key responses, logic, piping, and sync. Never rename casually, never derive from position or a loop counter.
  3. JSX onlyConditional 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. A params form is one static object, not recompiled per render, so this footgun doesn’t apply there.
  4. JSX onlyReuse is a plain function returning elements, called inline as {contactFields()}. Wrapper components are invisible to the compiler (children are never rendered) and throw.
  5. every formdefineForm() params stay available throughout Fillo. 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.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>

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.

page-jumps.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.

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.

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.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 and should not change. In params, there’s only one spelling: the options array is exactly what both JSX spellings compile to.

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

Reuse is a plain function or array returning blocks. Spread or call it inline when never a wrapper component around fields.

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; in params, defineForm() already produces that value directly, with no separate compile step. Use it to share one form across surfaces, or go headless: <FilloProvider> in React, createFormController() (from @usefillo/dom or @usefillo/core) anywhere else. It the same render-nothing engine either way.

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

Component reference

Generated from JSX_BLOCK_SPECS (exported by @usefillo/core), filtered to APIs in the published SDK. 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.

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, decimals, prefix, suffix, notation
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, insightsMetric
Fillo.Scalelinear_scalelabel, description, required, placeholder, min, max, minLabel, maxLabel, insightsMetric
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.Calculatedcalculatedlabel, description, required, placeholder, calc, decimals, prefix, suffix
Fillo.RepeatingGrouprepeating_grouplabel, description, required, placeholder, minInstances, maxInstances, addLabel, itemLabel
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

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.

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

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

Authoring mistakes in <Fillo.Form> JSX throw FilloJsxError with a stable code that links to its fix. Browse the full error catalog in the troubleshooting guide. This catalog is JSX-specific: writing params directly, there’s no separate authoring-error layer. TypeScript’s own type checking catches shape mistakes.

This page for agents: /docs/authoring.md · index at /llms.txt

Updated

Was this page helpful?