The drag-and-drop editor is the make-or-break. The market is brutal. Here’s the build, with no pretending the easy parts are easy.
Research-based methodology. This guide draws on dnd-kit and Supabase Storage docs, public Tally and Typeform writeups, and our own builds with Claude. First-person testing is called out where it exists. How we research.
Form builders are a strange market: enormous TAM, four well-funded incumbents (Typeform, Tally, Jotform, Google Forms), and yet new entrants keep finding wedges. The reason is that “a form” is actually a hundred different products glued together — an event RSVP form is a different product than a job application, which is a different product than a customer feedback survey, which is a different product than a B2B lead-gen quiz. The incumbents serve the average; vertical and design-opinionated form tools keep peeling off chunks.
This guide is for someone who wants to build a form product, knows it’s a crowded market, and has a specific opinion about who they’re for and why a generic Tally form isn’t enough for that audience. If you don’t have that opinion yet, browse our micro SaaS ideas page for sharper angles before you write code.
Three forces make a generic form builder a bad bet, and a vertical form builder a fine bet.
Anyone willing to use Google Forms is not your customer, no matter how nice your design is. Your customer is the person who has tried Google Forms, hit the wall, and is now actively unhappy with the alternatives.
Tally has a generous free tier and a great editor. Typeform owns the “each question on its own screen” conversational format. Trying to be a slightly-better-Tally or a slightly-cheaper-Typeform won’t move buyers.
Every form builder rises or falls on the editor. A janky drag-and-drop, slow undo, broken keyboard shortcuts — any of these and the customer goes back to Tally inside ten minutes. We’ll cover the dnd-kit setup in step 2, but be honest with yourself: this is the part of the build that will take longest.
The data model needs to handle a form composed of N fields where each field has a different schema depending on its type. The cleanest pattern is a discriminator column with the type-specific config in a JSONB column, validated at write time with Zod.
I'm building a form builder SaaS. Design the Postgres schema for Supabase.
Tables:
- workspaces (id, name, owner_user_id, plan, created_at)
- forms (id, workspace_id, slug unique, title, description, theme jsonb,
is_published bool, submission_count int, created_at, archived_at)
- fields (id, form_id, position int, type text, label, placeholder text,
is_required bool, config jsonb, logic jsonb, created_at)
-- type is one of: 'short_text' | 'long_text' | 'email' | 'number' |
-- 'select' | 'multi_select' | 'date' | 'rating' | 'file' | 'section'
-- config is type-specific: select has { options: [{label, value}] },
-- file has { max_mb, allowed_mime }, etc.
- submissions (id, form_id, submitted_at, ip_hash, user_agent, completion_ms,
is_spam bool default false)
- submission_values (submission_id, field_id, value_text, value_number,
value_jsonb, value_file_url, primary key (submission_id, field_id))
Constraints:
- (form_id, position) is unique among non-deleted fields
- is_published cannot be true if forms has zero non-section fields
Generate:
1. The full SQL
2. A check constraint or trigger that enforces the position uniqueness
3. RLS policies: workspace members can read/write their forms; submissions
are write-only from the public (anonymous) and read-only by workspace
members
4. A Zod schema for each field `type`'s `config` jsonb shape, so I can
validate on write from the editor
5. A view `form_with_fields` that returns a form joined with its fields
ordered by position, for the public render endpoint
Output as one migration plus a TypeScript file with the Zod schemas.
The discriminated-union pattern in Zod is what keeps this honest. Without it, your editor will eventually save a malformed config and the public form will break in ways that are hard to debug.
Use dnd-kit. Don’t use react-dnd (older, more code). Don’t use react-beautiful-dnd (no longer maintained). Don’t roll your own (you will spend a week and have a worse result). dnd-kit handles keyboard accessibility, touch, and screen-reader announcements out of the box.
Build a form editor at /forms/[id]/edit using Next.js, Tailwind, and
dnd-kit (@dnd-kit/core + @dnd-kit/sortable).
Layout:
- Three-column desktop layout:
- Left rail (240px): "Add field" palette with one button per field
type (short text, long text, email, number, select, etc.)
- Center column: the form preview, rendered as a sortable vertical
list of fields. Click a field to select it.
- Right rail (320px): the inspector for the selected field --
edit label, placeholder, required, and type-specific config
- On mobile: tabs across the bottom (Fields / Preview / Settings)
Drag-and-drop:
- Use DndContext with SortableContext + useSortable
- Dragging from the left palette ADDS a new field at the drop position
- Dragging an existing field REORDERS it
- Use a DragOverlay so the dragged item ghost looks identical to the
resting state
- Persist position changes via a server action `reorder_fields(form_id,
ordered_ids[])` that does ONE UPDATE per row in a transaction
Editor UX:
- Cmd/Ctrl+Z undoes the last edit (use a simple in-memory undo stack
capped at 50 actions)
- Auto-save on every change with a 600ms debounce
- Show a "Saving..." / "Saved" indicator in the top bar
- Keyboard: arrow keys move focus between fields when nothing is being
typed; Enter opens the inspector
The inspector for each field type renders different controls. For
'select', it should let the user add/remove/reorder options inline.
Reference the official dnd-kit docs for SortableContext patterns.
Two warnings: don’t do per-keystroke saves (your DB will hate you), and don’t skip the DragOverlay (without it, the dragged element ghost looks broken on field types with custom rendering).
The validation runs in two places: the public form (so users see errors immediately) and the server endpoint (so a malicious user can’t bypass the client). Build the validation as a single Zod schema generated from the form’s field definitions, used on both sides.
Build the validation + conditional logic system for a form builder.
Conditional logic spec (stored in fields.logic jsonb):
{
"show_if": {
"operator": "and" | "or",
"rules": [
{ "field_id": "uuid", "op": "equals" | "not_equals" |
"contains" | "greater_than" | "less_than" | "is_filled" |
"is_empty",
"value": any }
]
}
}
A field is rendered if show_if evaluates true for the current draft
answers, OR if show_if is null.
Build:
1. A `buildFormSchema(fields)` function that returns a Zod object schema:
- For each field, generate a Zod type from its `type` and `config`
- Wrap each in .optional() if the field's show_if would hide it given
the current values (this requires the schema to be re-built on each
change, OR use Zod's superRefine + access to the parsed parent)
- For `is_required: true` fields that ARE shown, error if empty
- For email type: z.string().email()
- For number with config.min/max: refine accordingly
2. A `evaluateShowIf(field, currentValues)` pure function that returns
true/false for whether a given field should render right now.
3. A React component <FormField field={...} value={...} onChange=
{...} error={...} /> that renders the right input for the field's
type and shows inline errors below.
4. A <FormFiller /> component that:
- Holds draft state in useState
- Hides fields where evaluateShowIf returns false
- On submit, runs buildFormSchema(visibleFields).safeParse(values)
- Sends to /api/forms/[id]/submit on success
- Re-runs validation on every change (debounced 150ms) to surface
errors live
5. The /api/forms/[id]/submit handler must INDEPENDENTLY re-derive
visible fields and re-validate — never trust the client about
what was shown.
Reference the official Zod docs for discriminated unions and
superRefine.
Server-side re-validation is the part most form builders skip and end up with garbage in their submissions table because a sophisticated user just POST-ed missing fields directly. Don’t skip it.
For uploads, use Supabase Storage with signed URLs. Don’t proxy uploads through your Next.js server — you’ll burn through your function execution time on a single 50MB resume PDF. Get a signed URL from the server and let the browser PUT directly to the storage bucket. The Supabase vs Firebase comparison covers the storage tradeoff if you’re still deciding.
Build the file-upload flow for a form field of type='file'.
The field config looks like:
{ max_mb: 25, allowed_mime: ["application/pdf", "image/jpeg",
"image/png"] }
Flow:
1. /api/forms/[id]/upload-url POST handler:
- Accepts: form_id, field_id, filename, content_type, size_bytes
- Verifies form is published and the field exists with type=file
- Verifies content_type is in allowed_mime and size_bytes <
max_mb*1024*1024
- Generates a unique storage path: submissions/[form_id]/[uuid]/[safe_filename]
- Calls supabase.storage.from('submissions').createSignedUploadUrl(path)
- Returns { upload_url, storage_path, expires_at }
2. Public form file input component:
- On file selection: validate locally first (size, mime), then call
/api/forms/[id]/upload-url
- PUT the file directly to upload_url with the right Content-Type
- On success, store storage_path in the local field value
- Show a progress bar via xhr.upload.onprogress (fetch doesn't support
progress)
- Allow remove + retry on error
3. On final form submit:
- Send the storage_path values along with other answers
- Server stores storage_path in submission_values.value_file_url
- When the form owner views the submission, server-side generate a
short-lived signed download URL (createSignedUrl, 5 minutes)
4. Storage RLS:
- Bucket 'submissions' is private (no public read)
- Anonymous users can INSERT into the path pattern submissions/*
ONLY via the signed upload URL
- Authenticated workspace members can READ any object whose path
starts with submissions/[their_form_ids]/
Output the SQL for the storage policies + the route handlers + the React
component.
Two non-obvious bugs: (a) signed upload URLs work for exactly one upload — if the user selects, removes, and re-adds, you must request a new URL; (b) the Content-Type the user’s browser sends must match the Content-Type passed to createSignedUploadUrl — if not, the upload silently 403s.
Most form-builder customers want to embed the form on their own site. The embed has to render fast, work without JavaScript on the host page, and not let the host page snoop on submissions.
Two embed modes: an iframe (simple, secure, slightly bulkier) and a script-tag inline embed (more flexible, more attack surface). Start with iframe-only.
<!-- Embed code customers paste --> <iframe src="https://yourapp.com/embed/[form_slug]" width="100%" height="600" frameborder="0" allow="clipboard-write" style="border:0;max-width:680px" title="[Form Title]"></iframe>
The /embed/[slug] page should be identical to the public form page minus the site nav and footer, and should set X-Frame-Options: ALLOWALL (or use a Content-Security-Policy frame-ancestors directive scoped to whatever the form owner allowed in settings). Most form builders fail closed here — they ship with strict X-Frame-Options that break embedding entirely — or fail open in a way that lets a malicious site clickjack the form.
Add a postMessage hook so the parent page can listen for a “form submitted” event and run host-side analytics. Document the message shape in your help docs and don’t change it across versions.
Public forms get spam. Always. Two layers handle 95% of it without a CAPTCHA (which kills conversion):
Add a third layer for high-traffic forms: per-IP rate limiting via Upstash Redis or Vercel KV. Cap to 10 submissions per IP per hour, more for known good IPs (logged-in workspace members testing).
If spam still leaks through, add Cloudflare Turnstile as an opt-in setting per form. It’s lighter than reCAPTCHA and free.
The market has trained users to expect a generous free tier. Try to charge from $0 and you’ll lose. Three-tier freemium is standard:
The free tier branding is your acquisition engine. Every form your customers ship is a backlink and an impression. Price the Starter tier at the “remove branding” threshold — that’s the moment buyers convert.
Don’t fight Tally on price or Typeform on polish. Win on a vertical opinion or a design opinion that the incumbents can’t adopt without alienating their base.
Each of these is a real micro SaaS angle. The pattern: identify a buyer who currently uses a generic form tool and a separate vertical tool, and bake them together into one. Charge $19–$99/mo. The willingness to pay is much higher than for a generic form because the product is replacing two tools, not one.
If you’re scaffolding fast, Lovable can produce a competent first version of the editor screen in a few prompts — though you’ll re-do most of the dnd-kit logic by hand to make it actually work.
Generic form builders won’t survive against Tally and Google Forms. Vertical form builders for a specific buyer (job apps, event RSVPs, customer feedback) are alive and well. The editor is the product; spend 2x the time you think you need on dnd-kit.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.