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 Cursor, Claude Code, or another AI editor. It installs@usefillo/react, adds a working form inside your app, and points the agent at the full SDK reference.

Open /llms.txt

Quickstart

quickstart
npm install @usefillo/react

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

const client = createClient({ baseUrl: "https://your-formwork-host" });

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

Forms must be published to be fetched by the public API. While embedding, your form keeps working in the editor’s live preview — they’re the same renderer.

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 { defineForm, renderForm } from "@usefillo/dom";
import "@usefillo/dom/styles.css";

const form = defineForm({
  id: "customer-onboarding",
  title: "Customer onboarding",
  pages: [{ id: "p1", blocks: [
    { id: "email", kind: "email", label: "Work email", required: true },
  ]}],
});

renderForm("#form", {
  form,
  onSubmit: (data) => console.log(data),
});
plain html
<link rel="stylesheet" href="/node_modules/@usefillo/dom/dist/styles.css" />
<script src="/node_modules/@usefillo/dom/dist/standalone.global.js"></script>

<div id="form"></div>
<script>
  const form = Fillo.defineForm({ id: "waitlist", title: "Join waitlist", pages });
  Fillo.renderForm("#form", { form, onSubmit });
</script>

The repository includes passing example apps at examples/no-framework, examples/vue, and examples/svelte. Run pnpm test:frameworks to build and submit all three.

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. Pass onSubmit and the responses go to your own backend instead of ours; add a client and they’re collected in your Fillo workspace.

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

// No builder, no Fillo backend — 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: {},
};

<FilloForm form={form} onSubmit={(data) => save(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. For interleaving your own markup between fields, pass the same form to <FilloProvider> instead — see layout control. 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({
  baseUrl: "https://your-formwork-host",
  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 everything beyond tokens, write normal CSS or Tailwind against the fw-* 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. */

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

[data-field="quality"] .fw-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; }
--fw-primaryButtons, focus rings, selection
--fw-bgForm background
--fw-textBody text
--fw-mutedDescriptions, hints
--fw-borderInput borders, dividers
--fw-errorValidation messages
--fw-radiusCorner radius scale
--fw-fontFont family
--fw-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>. 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. You lay out the fields and weave in your own markup: 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({ baseUrl: "https://fillo.so", 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

Two levels of control. 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 via URL

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.

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. Deliveries retry three times.

Feedback → agent → PR

One thing signed webhooks make easy: a feedback form, shown when something fails, can hand a user’s report to a coding agent that opens a pull request. If that’s useful to you, here’s the whole loop in about five minutes — it’s just one way to use webhooks, not a requirement.

Quickest: hand it to your AI editor

Paste this into Cursor or Claude Code. It scaffolds the form and a signature-checked webhook route, then tells you the one setting to flip.

paste into your AI editor
Set up a Fillo feedback-to-PR loop in this app.

1. Install @usefillo/react and render a feedback form where errors surface —
   a long_text "What went wrong?" and a file_upload "Screenshot".
2. Create the webhook route app/api/formwork/route.ts that:
   - reads the raw request body,
   - verifies the X-Fillo-Signature header equals
     hex HMAC-SHA256(body, process.env.FORMWORK_WEBHOOK_SECRET),
   - parses { response: { data } } (data is keyed by field id),
   - hands the report to a coding agent to reproduce, fix, and open a PR.
3. Then tell me to add a webhook in Fillo (the form's Settings → Webhooks)
   pointing at /api/formwork, and to put its signing secret in
   FORMWORK_WEBHOOK_SECRET.

Fillo SDK + API reference: /llms.txt

…or wire it by hand

  1. 1. Render a feedback form where things break.

    feedback.tsx
    import { FilloForm, createClient, defineForm } from "@usefillo/react";
    
    const client = createClient({ baseUrl: "https://your-formwork-host", key: "pk_..." });
    
    const feedback = defineForm({
      id: "bug-report",
      pages: [{ id: "p1", blocks: [
        { id: "what", kind: "long_text", label: "What went wrong?" },
        { id: "shot", kind: "file_upload", label: "Screenshot" },
      ]}],
    });
    
    <FilloForm form={feedback} client={client} />;
  2. 2. Add a webhook in the form’s Settings → Webhooks, pointing at your /api/formwork route. Copy its signing secret into FORMWORK_WEBHOOK_SECRET.

  3. 3. Verify and handle the delivery. Each delivery is POST with header X-Fillo-Signature and a body of { event, form, response }, where response.data is keyed by field id.

    app/api/formwork/route.ts
    import { createHmac, timingSafeEqual } from "node:crypto";
    
    export async function POST(req: Request) {
      const raw = await req.text();
      const sig = req.headers.get("x-fillo-signature") ?? "";
      const want = createHmac("sha256", process.env.FORMWORK_WEBHOOK_SECRET!)
        .update(raw)
        .digest("hex");
    
      if (sig.length !== want.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(want)))
        return new Response("bad signature", { status: 401 });
    
      const { response } = JSON.parse(raw);
      const report = response.data.what; // "what" is a field id from your form
    
      // Hand it to your coding agent — Claude Code, a Cursor background agent, a
      // GitHub Action — to reproduce, fix, and open a PR before you're involved.
      await startAgent(`A user reported: "${report}". Reproduce and fix it, open a PR.`);
    
      return new Response("ok");
    }