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.

Why anyone builds a form tool in 2026

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.

Why this market is brutal

Three forces make a generic form builder a bad bet, and a vertical form builder a fine bet.

The free tier of Google Forms is “good enough” for most people

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 is competing on price and Typeform is competing on polish

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.

Drag-and-drop is harder than picking a chart library

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.

Step 1 — Data model with field-type discriminator

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.

Prompt 1 — Data model with field-type discriminator
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.

Step 2 — Drag-and-drop editor with dnd-kit

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.

Prompt 2 — Drag-and-drop form editor scaffold
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).

Step 3 — Validation and conditional logic

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.

Prompt 3 — Conditional logic + validation
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.

Step 4 — File uploads to Supabase Storage

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.

Prompt 4 — Direct file upload to Supabase Storage
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.

Step 5 — Embed code and iframe security

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.

Step 6 — Anti-spam without breaking real users

Public forms get spam. Always. Two layers handle 95% of it without a CAPTCHA (which kills conversion):

  • Honeypot field — a hidden input named something innocuous like “website”. Real users don’t fill it; bots do. Reject any submission where the honeypot has a value.
  • Time-on-form check — record the page-load timestamp on first render, send it with the submission. Reject anything submitted in under 3 seconds (no human types a form that fast).

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.

Pricing for a freemium form tool

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:

  • Free — 3 forms, 100 submissions/month, “Powered by [yourbrand]” footer on every form, no file uploads.
  • Starter — $19/mo — unlimited forms, 1,000 submissions/month, file uploads up to 10MB, no branding, basic logic.
  • Pro — $49/mo — 10,000 submissions/month, file uploads up to 100MB, advanced logic, webhooks, custom domain.

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.

Where you can actually win

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.

  • Job application forms — built-in resume parsing, ATS export, structured candidate pipeline.
  • Event RSVPs — capacity tracking, waitlists, +1 logic, confirmation calendar attachments.
  • Customer feedback for SaaS — baked-in NPS templates, integration with Linear or Intercom, sentiment tagging.
  • Lead-qualification quizzes for B2B — opinionated about scoring, integrates with HubSpot/Pipedrive, redirects to a calendar.
  • Intake forms for service businesses — HIPAA-considered storage, e-signature, deposit collection on submit.

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.

Form builder SaaS, in one paragraph
Vertical opinion or design opinion. Editor or die. Don’t fight on free tier.

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.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.