# Schema and validation

Last updated: 2026-07-12

Web page: https://fillo.so/docs/schema
Docs overview: https://fillo.so/docs.md
Quickstart: https://fillo.so/docs/embed.md
Agent setup: https://fillo.so/agents.md

Define fields, validation, conditions, form settings, and changes to published forms.

## Start from the schema shape

The builder, SDKs, API, validation, exports, and integrations all use the same form schema. JSX and `defineForm()` produce the same schema. The code form `id` identifies the form during sync. Fillo returns a separate `formId` for published links and submissions.

Every schema has `version: 1`, a title, at least one page, an ordered block list, and settings. Fields create answers. Content blocks shape the reading flow without adding answer keys.

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

export const supportIntake = defineForm({
  id: "support-intake",
  title: "Tell us what happened",
  pages: [
    {
      id: "request",
      blocks: [
        {
          id: "topic",
          kind: "select",
          label: "What do you need help with?",
          required: true,
          options: [
            { id: "billing", label: "Billing" },
            { id: "technical", label: "Technical issue" },
          ],
        },
        {
          id: "details",
          kind: "long_text",
          label: "Tell us more about {{topic}}",
          required: true,
          visibleIf: [{ fieldId: "topic", op: "answered" }],
        },
        {
          id: "account_id",
          kind: "hidden",
          paramName: "account",
        },
      ],
    },
  ],
  settings: { showProgress: true, saveProgress: true },
});
```

## Treat every id as stored data

- Use a permanent code form handle. Reusing it updates the same Fillo form; changing it creates a different sync identity.
- Page ids must be unique within the form. Block ids must be unique across every page because field ids become keys in `ResponseData`.
- Choice option ids, ranking option ids, and matrix row and column ids are stored answer values. Labels may change without rewriting those values.
- Ids are strings from 1 to 128 characters. Never derive them from array position, visible copy, translated labels, or a random value generated during render.
- A schema supports 1 to 50 pages, up to 500 blocks per page, and up to 200 options, rows, or columns in one list.

> **Renaming an id is a data change:** Changing `company_size` to `team_size` does not rename historical answers. It creates a new answer key. Keep the id and change the label, or add a new field and update downstream consumers deliberately.

## Field kinds and answer shapes

Every field accepts `id`, `label`, optional `description`, `required`, `placeholder`, and `visibleIf` where those properties apply. The table adds each kind's stored answer shape and field-specific rules.

| Kind | JSX | Stored answer | Validation and configuration |
| --- | --- | --- | --- |
| `short_text` | `Fillo.Text` | `string` | Optional `maxLength`; otherwise a 2,000 character safety cap. |
| `long_text` | `Fillo.LongText` | `string` | Optional `maxLength`; otherwise a 20,000 character safety cap. |
| `email` | `Fillo.Email` | `string` | Valid email syntax and optional `maxLength`. |
| `url` | `Fillo.Url` | `string` | An absolute `http:` or `https:` URL, with optional `maxLength`. |
| `phone` | `Fillo.Phone` | `string` stored in E.164 form | Server validation is authoritative; `defaultCountry` is a two-letter country code. |
| `number` | `Fillo.Number` | finite `number` | Optional `min` and `max`; numeric strings are normalized before validation. |
| `select` | `Fillo.Select` | option id or Other `string` | One known option id, or one free-text value up to 500 characters when `allowOther` is on. |
| `multi_select` | `Fillo.MultiSelect` | `string[]` | Unique known option ids, plus at most one Other value when enabled. |
| `dropdown` | `Fillo.Dropdown` | option id or Other `string` | One known option id, or one free-text value up to 500 characters when `allowOther` is on. |
| `checkbox` | `Fillo.Checkbox` | `boolean` | Required means the value must be `true`; `appearance` may be checkbox or toggle. |
| `rating` | `Fillo.Rating` | integer `number` | From 1 through `max`, which defaults to 5. CSAT requires a 1 through 5 scale. |
| `linear_scale` | `Fillo.Scale` | integer `number` | Between `min` and `max`, defaulting to 1 and 10. NPS requires 0 through 10. |
| `ranking` | `Fillo.Ranking` | ordered `string[]` | Every option id must appear exactly once. |
| `matrix` | `Fillo.Matrix` | `Record<rowId, columnId>` | Only declared row and column ids; a required matrix needs an answer for every row. |
| `signature` | `Fillo.Signature` | `data:image/...` string | Must be an image data URL produced by the signature control. |
| `date` | `Fillo.Date` | `YYYY-MM-DD` string | Must be a real calendar date, not only a string with the right shape. |
| `file_upload` | `Fillo.FileUpload` | `FileValue[]` | Completed file references only; `maxFiles`, per-file size, and `accept` are enforced. |
| `hidden` | `Fillo.Hidden` | `string` | Filled from `paramName` or `defaultValue`, never rendered, and never required. |
| `custom` | `Fillo.Custom` | arbitrary JSON | Core checks required presence only; the custom component owns richer validation. |

## Content blocks

Content blocks belong in the same ordered `blocks` array as fields. They need stable block ids for durable schema structure and may carry `visibleIf`, but they never create a response value.

| Kind | JSX | Schema content | Behavior |
| --- | --- | --- | --- |
| `heading` | `Fillo.Heading` | `text: string` | Displays a heading and supports answer piping. |
| `paragraph` | `Fillo.Paragraph` | `text: string` | Displays supporting copy and supports answer piping. |
| `divider` | `Fillo.Divider` | No content property | Adds a visual break and never creates an answer. |

## Read and write response data by field id

- `ResponseData` is an object keyed by field id. Optional unanswered fields are absent; do not depend on every schema field having a key.
- Choice fields store option ids. Use Fillo's formatting helpers or the response `formatted` payload when a destination needs labels.
- Numbers, ratings, and scales are stored as numbers after normalization. Matrix answers map row ids to column ids. Files are arrays of completed file references.
- A custom field may store any JSON value. Its renderer and your downstream code must agree on the shape.
- Each response is anchored to the schema version it answered. The response grid and CSV retain removed field ids, while live integrations work from the current schema.

## Validation runs at schema and response boundaries

- Fillo normalizes and validates untrusted schemas before storing or rendering them. Invalid top-level shapes, unknown kinds, duplicate ids, invalid number ranges, and unsupported schema versions fail. Malformed field blocks such as a choice with no valid options are removed, and a schema with no valid block is rejected.
- The client gives immediate field feedback, but the submission API validates the response again. Treat the server result as authoritative.
- Only fields visible for the submitted answers are validated and retained. A stale answer from a field hidden by logic is removed from the accepted data.
- Strings are trimmed except custom-field strings. Numeric strings are normalized for number, rating, and scale fields. Phone answers receive authoritative server validation and E.164 normalization.
- File answers are accepted only after the upload protocol has created and verified the referenced file for the same form.

## Model choices with stable option ids

- Store machine-friendly option ids such as `technical`; show respondents changeable labels such as `Technical issue`.
- `allowOther` stores the respondent's text directly when it is not a declared option id. Downstream code must handle both declared ids and free text.
- `shuffleOptions` changes presentation order only. It does not change the option list or stored values.
- A ranking answer contains every option id in respondent order. Adding or removing ranking options changes the required answer set.
- Matrix rows and columns each have their own stable ids. A required matrix must contain one valid column id for every row.

## Use visibleIf for conditional structure

Attach `visibleIf` to a field or content block. Every condition in the array must match, so multiple conditions use AND behavior. Reference the controller by field id and compare choice answers to option ids, not labels.

| Operator | Behavior |
| --- | --- |
| `eq` | Exact value match; a multi-select matches when it contains the value. |
| `neq` | The field must be answered and different from the value. |
| `contains` | Case-insensitive substring for text, or membership for an array. |
| `gt` / `lt` | Finite numeric comparison, including numeric input strings. |
| `answered` | True when the effective value is not empty. |
| `not_answered` | True when the effective value is empty. |

> **Hidden controllers count as unanswered:** If logic hides a controlling field, its stale value cannot keep a dependent field visible. This resolves chained conditions safely and matches submission validation.

## Pipe answers into respondent-facing copy

Use `{{field_id}}` in a field label or description, or in heading and paragraph text. Fillo substitutes the current answer for display only. Choice ids become option labels, arrays join with commas, and an unanswered field becomes an empty string.

Piping does not change stored answers and is not a templating or authorization system. Never put a secret in a schema and expect a conditional block or piping token to hide it from someone who can load the published schema.

## Know who owns each setting

All settings are part of `FormSchema`, but a browser-visible `pk_` sync cannot control settings with mail or pre-submission privacy effects. On public-key sync, Fillo strips those values from a new form and preserves the dashboard value on an existing form.

| Setting | Effect | Public-key sync |
| --- | --- | --- |
| `submitMode` | Use a normal submit button or eligible one-answer auto-submit behavior. | Incoming schema |
| `submitLabel` | Override the default submit button label. | Incoming schema |
| `successTitle` | Set the success screen heading. | Incoming schema |
| `successMessage` | Set the success screen supporting copy. | Incoming schema |
| `redirectUrl` | Send the respondent to an absolute HTTP or HTTPS URL after success. | Incoming schema |
| `showProgress` | Show progress through a multi-page form. | Incoming schema |
| `responseLimit` | Limit repeats by browser, identifying field, or identify context. | Incoming schema |
| `notifyEmail` | Email a workspace-selected address after a response. | Dashboard preserved |
| `sendReceipt` | Send a receipt to the first answered email field. | Dashboard preserved |
| `saveProgress` | Persist in-progress answers so a respondent can resume. | Incoming schema |
| `draftAnswersVisible` | Let workspace members view saved, unsubmitted answer content. | Dashboard preserved |
| `resumeEmails` | Send one resume link to an eligible respondent after abandonment. | Dashboard preserved |
| `resumeUrl` | Choose the HTTP or HTTPS page that receives an embedded resume link. | Dashboard preserved |
| `draftDigest` | Send daily drop-off counts and verified respondent context, never answer content. | Dashboard preserved |

> **Private pushes have broader authority:** A reviewed CLI push uses a private management token and replaces the complete schema, including dashboard-preserved keys. For a code-managed form, `saveProgress` and `responseLimit` stay code-controlled; dashboard forms edit them in Settings.

## Make version-safe schema changes

1. **Prefer additive optional fields:** An optional field does not block an older client or a respondent who never sees it. A new visible required field changes what a valid submission needs.
2. **Change copy without changing ids:** Labels, descriptions, placeholders, and option labels may evolve while stable ids preserve raw answers and downstream keys.
3. **Use a new id for a new answer shape:** Changing a field from `select` to `multi_select`, or reusing an old option id for a different meaning, makes one key represent incompatible data. Add a new field instead.
4. **Review logic and piping references:** When removing a field or option, search `visibleIf`, piping tokens, response limits, prefill, webhooks, exports, and destination mappings for its id.
5. **Stage and test the published path:** Public-key sync stages changes on claimed live forms. Test conditional branches, validation, uploads, and a real response before publishing. A private CLI push publishes directly unless you use `--draft`.

## Next steps

- [Author in code](https://fillo.so/docs/authoring): Build this contract with JSX or defineForm and sync it safely.
- [Prefill answers](https://fillo.so/docs/prefill): Map app state, URL values, and hidden defaults to stable field ids.
- [SDK reference](https://fillo.so/docs/reference): Look up renderer props, hooks, client methods, settings, and exports.
