Building a Schema-First Collaborative Form Builder with Next.js and Supabase
Lessons from building Fill.in, where one JSON schema powers editing, execution, and measurement.
When I started building Fill.in, I kept coming back to one question: what’s the thing that, if I get it wrong early, I’ll be fighting forever? The answer turned out to be schema. Not as a database concern — as a product contract. One JSON definition that the builder, runtime, and analytics all speak natively. Everything else in the architecture followed from that choice.
The Problem I Wanted to Avoid
Most form products start clean and become inconsistent over time:
- Builder supports a feature runtime doesn’t fully honor.
- Runtime validation behaves differently from editor assumptions.
- Analytics are computed from partial or mismatched data.
I wanted Fill.in to avoid that trap from the start.
So instead of treating schema as “just persistence,” I treated it as the product contract.
The Core Idea: One Contract for Three Surfaces
In Fill.in, each form is stored in forms.schema (jsonb) and drives:
- Builder (/create/[slug])
- Runtime (/f/[slug])
- Responses + analytics (/create/[slug]/responses)
{
"id": "uuid",
"slug": "string",
"title": "string",
"description": "string",
"blocks": [],
"logicJumps": [],
"visibilityRules": []
}This made behavior predictable across the product because there is one canonical representation of a form.
Architecture Overview
Builder Flow
- Load form schema
- Apply typed edits (add/reorder/config/logic/visibility)
- Autosave draft locally and remotely
- Publish by changing form status
Runtime Flow
- Load published schema
- Render question flow step-by-step
- Evaluate visibility rules and logic jumps in real time
- Submit filtered answers
Analytics Flow
- Store response payloads
- Track behavior events (view, answer, submit)
- Aggregate funnel metrics (completion/drop-off)
Simple flow, but it scales well because each stage speaks the same schema language.
Data Model: Flexible Where Needed, Structured Where Critical
I used relational data for access and lifecycle, and JSONB for dynamic content.
create table if not exists public.forms (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
title text not null default '',
description text not null default '',
status text not null default 'draft' check (status in ('draft', 'published')),
schema jsonb not null,
user_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.responses (
id uuid primary key default gen_random_uuid(),
form_id uuid not null references public.forms(id) on delete cascade,
answers jsonb not null,
created_at timestamptz not null default now()
);
create table if not exists public.form_events (
id uuid primary key default gen_random_uuid(),
form_id uuid not null references public.forms(id) on delete cascade,
session_id text,
event_type text not null check (event_type in ('view', 'answer', 'submit')),
block_id text,
created_at timestamptz not null default now()
);Why this split worked:
- relational tables enforce ownership and queryability,
- JSONB supports evolving question config without frequent migrations.
Builder Design: Intent-Based Edits + Autosave
A major implementation detail was exposing intent APIs instead of raw mutation from components.
Get Manas Raghuwanshi’s stories in your inbox
Join Medium for free to get updates from this writer.
Examples:
- add
- remove
- duplicate
- reorder
- upsertVisibilityRule
- addLogicJump
This made edits safer and easier to reason about.
For resilience, I used:
- local draft storage (localStorage)
- debounced server persistence (upsert)
const debouncePersist = useMemo(() => debounce(persist, 1500), []);
useEffect(() => {
debouncePersist(form);
}, [form, debouncePersist]);
async function saveDraft() {
if (!isDirty) return;
setIsDirty(false);
const payload = {
id: form.id,
slug: form.slug ?? form.id,
title: form.title ?? "Untitled",
description: form.description ?? "",
schema: form,
};
const { error } = await supabase.from("forms").upsert(payload).select();
if (error) {
alert("Save failed");
return;
}
}This keeps editing responsive and reduces accidental data loss.
Runtime Logic: Branching + Visibility Without Chaos
Conditional flows are where form systems usually get brittle.
I kept runtime logic small and explicit.
First, compute the next block:
- evaluate ordered logic jumps
- fallback to sequential order
export function getNextBlockId(currentBlockId, answers, form) {
const jumps = form.logicJumps
.filter((j) => j.fromBlockId === currentBlockId)
.sort((a, b) => a.order - b.order);
for (const jump of jumps) {
const answer = answers[jump.condition.blockId];
if (answer === undefined) continue;
if (matchesCondition(answer, jump.condition)) {
return jump.toBlockId;
}
}
const index = form.blocks.findIndex((b) => b.id === currentBlockId);
if (index === -1) return null;
return form.blocks[index + 1]?.id ?? null;
}Then resolve only visible blocks and guard against cycles:
function resolveVisibleBlockId(startId, answers, form) {
let blockId = startId;
const visited = new Set<string>();
while (blockId) {
if (visited.has(blockId)) return null;
visited.add(blockId);
if (isBlockVisible(blockId, answers, form)) {
return blockId;
}
blockId = getNextBlockId(blockId, answers, form);
}
return null;
}That combination made form progression deterministic even with branching complexity.
Submission + Analytics: Keep Data Honest
A subtle but important choice: only persist answers for blocks that were visible.
const filtered: Record<string, unknown> = {};
for (const block of form.blocks) {
if (!isBlockVisible(block.id, finalAnswers, form)) continue;
if (finalAnswers[block.id] === undefined) continue;
filtered[block.id] = finalAnswers[block.id];
}
await supabase.from("responses").insert({
form_id: runtimeFormId,
answers: filtered,
});For behavior analytics, runtime emits events with session IDs:
await supabase.from("form_events").insert({
form_id: runtimeFormId,
session_id: sessionId,
event_type: eventType, // "view" | "answer" | "submit"
...(blockId ? { block_id: blockId } : {}),
});This powers completion and drop-off metrics on the responses dashboard.
Security Boundary: Trust Nothing on the Client
Because runtime forms are public, authorization must live in server logic and RLS/storage policies.
One policy example: allow uploads only for published forms in the expected path pattern.
create policy "runtime_upload_published_forms"
on storage.objects
for insert
to anon, authenticated
with check (
bucket_id = 'uploads'
and exists (
select 1
from public.forms f
where f.id = split_part(name, '/', 1)::uuid
and f.status = 'published'
)
);This keeps public uploads constrained without blocking valid form submissions.
What Worked and What I’d Improve Next
Worked well
- Shared schema removed builder/runtime drift.
- Intent APIs improved maintainability in editor code.
- Event stream gave useful analytics quickly.
Next steps
- Add schema versioning + migration utilities.
- Add builder-time checks for cycles/unreachable questions.
- Expand test coverage for visibility + jump edge cases.
- Improve event retention/index strategy for scale.
Final Thought
Fill.in is still early, but the schema-first decision has already paid off in ways I didn’t fully anticipate. Adding a new block type means touching one place. A runtime bug is reproducible directly from the stored schema. Analytics don’t require translation because the response payload speaks the same language as the form definition. If you’re building anything with dynamic structure — forms, workflows, no-code tooling — I’d make this the first architectural decision, not an afterthought.





