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.
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),
});<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.
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.
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 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-primary | Buttons, focus rings, selection |
| --fw-bg | Form background |
| --fw-text | Body text |
| --fw-muted | Descriptions, hints |
| --fw-border | Input borders, dividers |
| --fw-error | Validation messages |
| --fw-radius | Corner radius scale |
| --fw-font | Font family |
| --fw-control-bg | Input backgrounds |
Swap any field for your own
<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().
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.
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.
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.
// 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.
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. Render a feedback form where things break.
feedback.tsximport { 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. Add a webhook in the form’s Settings → Webhooks, pointing at your
/api/formworkroute. Copy its signing secret intoFORMWORK_WEBHOOK_SECRET.3. Verify and handle the delivery. Each delivery is
POSTwith headerX-Fillo-Signatureand a body of{ event, form, response }, whereresponse.datais keyed by field id.app/api/formwork/route.tsimport { 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"); }