Skip to main content

Embedding forms

Render forms inside your app - never an iframe. Use @usefillo/react for React, or @usefillo/dom for Vue, Svelte, web components, and plain browser pages. Validation, multi-page logic, conditional questions, and uploads are built in.

Start with the setup prompt, then use the reference below when you want to customize fields, styling, uploads, or webhooks.

Set up the first form with your coding agent

Copy the prompt into the tool you use to build your app. It installs@usefillo/react, places a working form in your app, and points the agent at the full SDK reference.

Open /llms.txt

Quickstart

quickstart
npm install @usefillo/react

import { FilloForm } from "@usefillo/react";
import "@usefillo/react/styles.css"; // optional default theme

export function Feedback() {
  return (
    <FilloForm
      formId="your-form-id-or-slug"
      onSubmitted={(id) => console.log("response", id)}
    />
  );
}

Forms must be published before the public API will serve them — a fetch by id or slug returns 404 until then. The editor’s live preview runs this same renderer, so what you build there is exactly what your users get.

Other frameworks

If your app is not React, use @usefillo/dom. It exports the samedefineForm() helper, a framework-agnostic renderForm() function, a web component, and a standalone global bundle for pages without a build step.

vite, vue, svelte, or any bundled app
npm install @usefillo/dom

import { renderForm } from "@usefillo/dom";
import "@usefillo/dom/styles.css";

renderForm("#form", {
  formId: "customer-onboarding",
  onSubmitted: (id) => console.log("response", id),
});
plain html
<link rel="stylesheet" href="https://unpkg.com/@usefillo/dom@0.3/dist/styles.css" />
<script src="https://unpkg.com/@usefillo/dom@0.3/dist/standalone.global.js"></script>

<div id="form"></div>
<script>
  Fillo.renderForm("#form", { formId: "waitlist" });
</script>

See these patterns running live at fillo.so/examples.

Define a form in code (no editor)

A form is just data, so you can hand-write it and render it without ever opening the visual editor — validation, multi-page logic and uploads all still work. Add a client and responses are collected in your Fillo workspace; use onSubmitted(id, data) to run your own code the moment a response lands (redirect, analytics, your own API). To also deliver responses to your backend, use webhooks. Without a client the form is render-only — handy for previews and tests.

code-first
import { FilloForm, createClient } from "@usefillo/react";

// No visual builder — the form is just an object.
const form = {
  version: 1,
  title: "Start a project",
  pages: [{ id: "p1", blocks: [
    { id: "name", kind: "short_text", label: "Project name", required: true },
    { id: "plan", kind: "select", label: "Plan", options: [
      { id: "hobby", label: "Hobby" },
      { id: "pro", label: "Pro" },
    ]},
  ]}],
  settings: {},
};

// A client collects responses into your Fillo workspace.
const client = createClient({ key: "pk_…" });

// onSubmitted runs your own code right after Fillo records the response:
<FilloForm form={form} client={client} onSubmitted={(id, data) => track(data)} />

Live, working version at /examples/code-first.

…and sync it to your workspace

Add your workspace’s publishable key and defineForm(), and the form registers itself the first time the code runs — like an analytics event. Responses, insights, and webhooks work like for any other form; schema changes in a deploy become new versions, so charts stay comparable. In the dashboard the form is marked Code: structure is read-only there, while webhooks, close dates, and storage stay editable. To place your own content between the fields, pass the same form to <FilloProvider> instead — see layout control (a Pro feature). Lock the key to your sites under Settings → Code-defined forms → Allowed origins.

synced from code
import { FilloForm, createClient, defineForm } from "@usefillo/react";

const client = createClient({
  key: "pk_…",                       // Settings → Code-defined forms
});

const feedback = defineForm({
  id: "conversion-failed",           // stable handle in your workspace
  title: "Conversion failed",
  pages: [{ id: "p1", blocks: [
    { id: "reason", kind: "select", label: "What went wrong?",
      allowOther: true, options: [
        { id: "crash", label: "It crashed" },
        { id: "wrong", label: "Wrong output" },
      ] },
    { id: "details", kind: "long_text", label: "Tell us more" },
    { id: "email", kind: "email", label: "Email me when it's fixed" },
  ]}],
});

<FilloForm form={feedback} client={client} />

Styling

Start with the theme prop ({ primary, background, text, radius, fontFamily }) or set the CSS variables on any ancestor. For anything the theme variables can’t express, write normal CSS or Tailwind against the fillo-* classes — the SDK ships its defaults in a lower cascade layer, so your rules always win. Every field carries data-field and every choice row data-option, so you can restyle one specific question or option without touching the rest.

your-styles.css
/* Your CSS (or Tailwind) always wins over the defaults —
   the SDK ships its styles in a lower cascade layer. */

.fillo-option { border-radius: 0; }            /* every option row */

[data-field="quality"] .fillo-options {        /* one question */
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem;
}

[data-option="very-good"] { background: #f0fdf4; }   /* one option */
[data-option="very-bad"]  { background: #fef2f2; }
--fillo-primaryButtons, focus rings, selection
--fillo-bgForm background
--fillo-textBody text
--fillo-mutedDescriptions, hints
--fillo-borderInput borders, dividers
--fillo-errorValidation messages
--fillo-radiusCorner radius scale
--fillo-fontFont family
--fillo-control-bgInput backgrounds

Swap any field for your own

components override
<FilloForm
  client={client}
  formId="cust-feedback"
  components={{
    rating: ({ field, value, setValue, error }) => (
      <MyEmojiRating
        label={field.label}
        value={value}
        onChange={setValue}
        error={error}
      />
    ),
  }}
/>

Every field kind accepts an override via components. For full control, drop to useFillo() and own the entire render — the hook exposes data, errors, page state, navigation and submit.

Total layout control (no Fillo chrome)

<FilloForm> renders a default layout — a stacked form with pages and a submit button. When you want something else entirely, reach for <FilloProvider> — a Pro feature. It runs the engine — validation, conditional logic, uploads, submit — but renders no layout at all. You place fields with <FormField>, render any field from scratch with useField(), and drive everything through useFillo().

headless provider
import { FilloProvider, FormField, useField, useFillo } from "@usefillo/react";

// <FilloProvider> runs the engine but renders no layout — you build it
// all. Works with a schema, or a defineForm() form (which also syncs).
<FilloProvider form={feedback} client={client}>
  <div className="grid grid-cols-2 gap-4">
    <FormField id="first" />        {/* default render, placed by you */}
    <FormField id="last" />
  </div>

  <YourMarketingBlock />            {/* anything, anywhere */}
  <BareEmail />                     {/* a field rendered from scratch */}
  <SubmitButton />                  {/* your button → useFillo().submit() */}
</FilloProvider>

// A field rendered with zero Fillo chrome:
function BareEmail() {
  const { value, error, setValue } = useField("email");
  return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}

Live, working version at /examples/custom-layout.

Multi-page in your own layout

<FilloForm> handles pages automatically. In a custom layout, useFillo() gives you the page state and navigation — next() validates the current page before advancing, and conditional logic is already applied to blocks.

wizard.tsx
function MyWizard() {
  const {
    blocks,          // blocks on the current page, after visibility logic
    pageIndex, pageCount, isFirstPage, isLastPage,
    next, back, submit, status,
  } = useFillo();

  return (
    <>
      {blocks.map((b) => <FormField key={b.id} id={b.id} />)}

      <nav>
        {!isFirstPage && <button onClick={back}>Back</button>}
        <button onClick={() => (isLastPage ? submit() : next())}>
          {isLastPage ? "Submit" : `Next (${pageIndex + 1} of ${pageCount})`}
        </button>
      </nav>
    </>
  );
}

Custom layout in any framework

Not React? createFormController() (in @usefillo/dom and @usefillo/core) is the same engine <FilloForm> runs — validation, conditional logic, pages, spam checks, and submit — exposed as a subscribable store that renders nothing — a Pro capability. You lay out the fields and add your own markup around them: call setValue, next()/submit(), and re-read getState() inside subscribe(). In Vue or Svelte, mirror getState() into reactive state; the React <FilloProvider> is a thin wrapper over this same engine.

any framework — your layout
import { createFormController, createClient } from "@usefillo/dom";

const client = createClient({ key: "pk_…" });
const form = { version: 1, title: "Bug report", settings: {}, pages: [{ id: "p1", blocks: [
  { id: "summary", kind: "long_text", label: "Summary", required: true },
  { id: "email", kind: "email", label: "Email" },
]}]};

// The engine: validation, conditional logic, pages, spam checks, submit.
// It renders nothing — you do.
const fillo = createFormController({ form, formId: "bug-report", client });

// Your layout, your markup, fields wherever you want:
root.innerHTML = `
  <h2>Tell us what broke</h2>
  <textarea id="summary"></textarea>
  <aside class="callout">📎 Screenshots help.</aside>   <!-- your markup, mid-form -->
  <input id="email" type="email" />
  <p id="err" class="err"></p>
  <button id="send">Send</button>`;

root.querySelector("#summary").oninput = (e) => fillo.setValue("summary", e.target.value);
root.querySelector("#email").oninput   = (e) => fillo.setValue("email", e.target.value);
root.querySelector("#send").onclick    = () => fillo.submit();

// Re-read getState() and repaint the reactive bits on every change:
fillo.subscribe(() => {
  const s = fillo.getState();                 // { data, errors, status, blocks, … }
  root.querySelector("#err").textContent = s.errors.summary ?? "";
  root.querySelector("#send").disabled = s.status === "submitting";
  if (s.status === "submitted") root.innerHTML = "<p>Thanks!</p>";
});

Your own field types

To re-render a field we already ship — say radios instead of our default — pass a components override (above). To add a field kind we don’t ship at all, use a custom block and supply its renderer in customComponents. The value is whatever your component sets — Fillo stores it, shows it in the grid, and only enforces required; your component does any richer validation.

custom field kind
// 1. Define a field kind we don't ship, in your schema:
{ id: "accent", kind: "custom", component: "color",
  label: "Brand color", config: { swatches: ["#5240FF", "#16a34a"] } }

// 2. Render it with your own component:
<FilloForm
  form={form}
  customComponents={{
    color: ({ field, value, setValue }) => (
      <SwatchPicker
        swatches={field.config.swatches}
        value={value}
        onChange={setValue}
      />
    ),
  }}
/>

Prefill

Hosted forms accept query parameters: /f/your-slug?email=ada@example.com&src=newsletter. Any field can be prefilled by id; hidden fields read their configured parameter name — values are validated and coerced server-side.

Embedding in code? Pass an initialData object to <FilloForm> (or the controller) to prefill any field by id — useful when you already know who the user is. Fields can also carry a defaultValue in the schema.

Webhooks

Configure per form under Form settings. Each submission POSTs JSON with an X-Fillo-Signature header — hex HMAC-SHA256 of the raw body with your signing secret. Failed deliveries retry with backoff (up to 6 attempts over a few hours).