Skip to main content
RapidDev - Software Development Agency

How to Build a Form Builder Backend with Lovable

Build a dynamic form builder in Lovable where users drag-and-drop field types to create forms, preview them live, and collect submissions stored in Supabase. The key challenge is rendering a form dynamically from a JSON field schema with runtime Zod validation. Includes a DataTable for submissions and a Chart showing response analytics.

What you'll build

  • Drag-and-drop field editor for building form schemas (text, email, select, checkbox, textarea, number)
  • Live form preview that renders dynamically from the saved field JSON schema
  • Runtime Zod schema generation from field definitions for client-side validation
  • Supabase backend storing forms, field schemas, and all submissions as JSONB
  • Submissions DataTable with dynamic columns generated from the form's field schema
  • Response analytics Chart showing submission volume over time and field-level breakdown
  • Shareable public form URL for embedding or direct linking
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read2-3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a dynamic form builder in Lovable where users drag-and-drop field types to create forms, preview them live, and collect submissions stored in Supabase. The key challenge is rendering a form dynamically from a JSON field schema with runtime Zod validation. Includes a DataTable for submissions and a Chart showing response analytics.

What you're building

A self-contained form builder where non-technical users create forms by dragging field types into a canvas, then share the resulting form URL to collect responses.

The builder has two panels side by side: the field editor on the left (drag-and-drop field list, field settings) and a live preview on the right that re-renders the form in real time as fields are added or edited. The preview uses the same renderer as the public form page — so what you build is exactly what respondents see.

The technical challenge is the dynamic renderer: it reads an array of field objects from Supabase, generates a Zod validation schema at runtime (not hardcoded), and renders the correct shadcn/ui input component for each field type. Submissions are stored as JSONB objects keyed by field ID, making the schema flexible without needing to alter the submissions table when forms change.

Final result

A working form builder where you can create a form with mixed field types, preview it live, publish it at /forms/:id/fill, collect submissions in Supabase, and view response analytics in a Chart dashboard — all without writing SQL or changing the schema per form.

Tech stack

LovableUI scaffolding and Supabase integration
React + TypeScriptRecursive form renderer and field editor components
Supabase PostgreSQLForms, form_fields, and submissions tables with JSONB responses
ZodRuntime schema generation from field definitions for validation
shadcn/uiForm, Input, Select, Checkbox, DataTable, Chart, Tabs, Dialog
react-hook-formForm state management integrated with dynamic Zod schemas
Tailwind CSSBuilder layout, drag-and-drop visual feedback, and field styling

Prerequisites

  • Supabase project created with Auth enabled
  • Lovable Pro account (recommended for the full build with Edge Functions)
  • SUPABASE_URL and SUPABASE_ANON_KEY added to Lovable Cloud tab → Secrets
  • Familiarity with Zod basics — the dynamic schema generation is the hardest part of this build

Build steps

1

Create the Supabase schema for forms and submissions

Set up three tables: forms (metadata), form_fields (ordered schema), and submissions (JSONB responses). The JSONB approach means the submissions table never changes shape when form fields do.

supabase_schema.sql
1-- Run in Supabase SQL Editor
2create table forms (
3 id uuid primary key default gen_random_uuid(),
4 title text not null,
5 description text,
6 owner_id uuid references auth.users(id),
7 is_published boolean default false,
8 accepts_responses boolean default true,
9 created_at timestamptz default now(),
10 updated_at timestamptz default now()
11);
12
13create table form_fields (
14 id uuid primary key default gen_random_uuid(),
15 form_id uuid references forms(id) on delete cascade,
16 field_type text not null check (field_type in ('text','email','number','textarea','select','checkbox','radio','date')),
17 label text not null,
18 placeholder text,
19 required boolean default false,
20 options jsonb default '[]',
21 position int not null,
22 created_at timestamptz default now()
23);
24
25create table submissions (
26 id uuid primary key default gen_random_uuid(),
27 form_id uuid references forms(id) on delete cascade,
28 responses jsonb not null,
29 submitted_at timestamptz default now(),
30 respondent_ip text
31);
32
33create index idx_form_fields_form_position on form_fields(form_id, position);
34create index idx_submissions_form_submitted on submissions(form_id, submitted_at);
35
36alter table forms enable row level security;
37alter table form_fields enable row level security;
38alter table submissions enable row level security;
39
40create policy "Owners manage forms" on forms for all using (owner_id = auth.uid());
41create policy "Owners manage fields" on form_fields for all using (
42 form_id in (select id from forms where owner_id = auth.uid())
43);
44create policy "Public read published forms" on forms for select using (is_published = true);
45create policy "Public read published fields" on form_fields for select using (
46 form_id in (select id from forms where is_published = true)
47);
48create policy "Anyone insert submissions" on submissions for insert with check (
49 form_id in (select id from forms where accepts_responses = true)
50);
51create policy "Owners read submissions" on submissions for select using (
52 form_id in (select id from forms where owner_id = auth.uid())
53);

Pro tip: The responses JSONB is keyed by field ID (not field label). This means renaming a field label after submissions exist does not break existing response data — the ID is stable.

Expected result: Three tables in Supabase. The RLS policy allows public form filling without auth while protecting submission reads to owners only.

2

Build the field editor with drag-and-drop reordering

The form builder left panel shows a palette of field types and a sortable list of added fields. Dragging changes the position values which reorder the form.

prompt.txt
1Build the form builder at /builder/:formId.
2
3Left panel - Field Editor:
41. Field type palette at top (8 buttons in a 4x2 grid):
5 text, email, number, textarea, select, checkbox, radio, date
6 Each button is an outlined Card with an icon and label.
7 Clicking adds a new field to the form with a default label.
8
92. Added fields list below the palette:
10 Render each field_fields row as a draggable item.
11 Each item shows: drag handle icon, field_type Badge, label Input (editable inline), required Switch, delete Button.
12 For 'select' and 'radio' fields, show a comma-separated options Input.
13 Reordering updates the position column in Supabase.
14
15Right panel - Live Preview:
16 Import and render the <FormRenderer> component (built in Step 3).
17 Pass the current fields array to FormRenderer.
18 Re-render on every field change.
19
20Top bar: form title Input, 'Published' Switch (updates forms.is_published), and a 'Share' Button that copies /forms/:id/fill to clipboard.

Expected result: The builder page shows a two-panel layout. Clicking a field type adds it to the list. Editing the label updates both the list item and the live preview in real time.

3

Build the dynamic form renderer with runtime Zod validation

This is the core technical challenge: a component that reads an array of field objects and renders the correct input, generates a Zod schema at runtime, and submits validated responses to Supabase.

src/components/FormRenderer.tsx
1import { useMemo } from 'react'
2import { useForm } from 'react-hook-form'
3import { zodResolver } from '@hookform/resolvers/zod'
4import { z } from 'zod'
5import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
6import { Input } from '@/components/ui/input'
7import { Textarea } from '@/components/ui/textarea'
8import { Checkbox } from '@/components/ui/checkbox'
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
10import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
11import { Button } from '@/components/ui/button'
12import { supabase } from '@/lib/supabase'
13import { toast } from 'sonner'
14
15export interface FormFieldDef {
16 id: string
17 field_type: 'text' | 'email' | 'number' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'date'
18 label: string
19 placeholder?: string
20 required: boolean
21 options: string[]
22 position: number
23}
24
25function buildZodSchema(fields: FormFieldDef[]) {
26 const shape: Record<string, z.ZodTypeAny> = {}
27 for (const f of fields) {
28 let schema: z.ZodTypeAny
29 switch (f.field_type) {
30 case 'email': schema = z.string().email('Invalid email'); break
31 case 'number': schema = z.coerce.number(); break
32 case 'checkbox': schema = z.boolean(); break
33 default: schema = z.string()
34 }
35 if (f.required && f.field_type !== 'checkbox') {
36 schema = (schema as z.ZodString).min(1, `${f.label} is required`)
37 } else if (!f.required) {
38 schema = schema.optional()
39 }
40 shape[f.id] = schema
41 }
42 return z.object(shape)
43}
44
45interface FormRendererProps {
46 formId: string
47 fields: FormFieldDef[]
48 preview?: boolean
49}
50
51export function FormRenderer({ formId, fields, preview = false }: FormRendererProps) {
52 const sorted = useMemo(() => [...fields].sort((a, b) => a.position - b.position), [fields])
53 const schema = useMemo(() => buildZodSchema(sorted), [sorted])
54 type FormValues = z.infer<typeof schema>
55
56 const form = useForm<FormValues>({ resolver: zodResolver(schema) })
57
58 const onSubmit = async (values: FormValues) => {
59 if (preview) return
60 const { error } = await supabase.from('submissions').insert({ form_id: formId, responses: values })
61 if (error) { toast.error('Submission failed'); return }
62 toast.success('Response submitted!')
63 form.reset()
64 }
65
66 return (
67 <Form {...form}>
68 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
69 {sorted.map((f) => (
70 <FormField key={f.id} control={form.control} name={f.id as never}
71 render={({ field }) => (
72 <FormItem>
73 <FormLabel>{f.label}{f.required && <span className="text-red-500 ml-1">*</span>}</FormLabel>
74 <FormControl>
75 {f.field_type === 'textarea' ? (
76 <Textarea placeholder={f.placeholder} {...field} />
77 ) : f.field_type === 'select' ? (
78 <Select onValueChange={field.onChange} defaultValue={field.value}>
79 <SelectTrigger><SelectValue placeholder={f.placeholder ?? 'Select...'} /></SelectTrigger>
80 <SelectContent>{f.options.map(o => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
81 </Select>
82 ) : f.field_type === 'checkbox' ? (
83 <Checkbox checked={field.value} onCheckedChange={field.onChange} />
84 ) : f.field_type === 'radio' ? (
85 <RadioGroup onValueChange={field.onChange} defaultValue={field.value}>
86 {f.options.map(o => (
87 <div key={o} className="flex items-center gap-2">
88 <RadioGroupItem value={o} id={`${f.id}-${o}`} />
89 <label htmlFor={`${f.id}-${o}`}>{o}</label>
90 </div>
91 ))}
92 </RadioGroup>
93 ) : (
94 <Input type={f.field_type} placeholder={f.placeholder} {...field} />
95 )}
96 </FormControl>
97 <FormMessage />
98 </FormItem>
99 )}
100 />
101 ))}
102 {!preview && <Button type="submit">Submit</Button>}
103 </form>
104 </Form>
105 )
106}

Pro tip: Wrap buildZodSchema in useMemo with fields as a dependency. Without memoization, a new Zod schema object is created on every render, which forces react-hook-form to re-initialize and clears all field values.

Expected result: The form renderer displays all field types correctly. Required validation shows errors on submit. The live preview in the builder reflects every field change. Submitting on the public page inserts a JSONB row to Supabase.

4

Build the submissions DataTable with dynamic columns

The submissions view dynamically generates DataTable columns from the form's field schema, then maps each JSONB responses object to a row. The column headers are the field labels, not field IDs.

prompt.txt
1Build a /builder/:formId/submissions page.
2
3Fetch the form's form_fields ordered by position.
4Fetch all submissions for the form ordered by submitted_at DESC.
5
6Dynamically generate DataTable columns:
71. First column: submitted_at (formatted date)
82. One column per form field using field.label as the header
9 - For each submission row, access submission.responses[field.id]
10 - Truncate text values longer than 40 chars with an ellipsis
11 - Show boolean checkbox responses as a green checkmark or red X
123. Last column: Actions with a 'View Full' Button
13
14Clicking 'View Full' opens a Dialog showing all field labels and their full response values in a definition list.
15
16Above the table show:
17- Total submissions count Card
18- Average completion rate Card (submissions with all required fields filled)
19- Export CSV Button that downloads all submissions as a CSV file
20 (Map field IDs to field labels in the CSV header row)
21
22All columns and rows must be TypeScript typed.

Expected result: The submissions page shows a table where each column corresponds to a form field by label. The Export CSV button downloads a properly labeled CSV file.

5

Add response analytics charts

A Charts tab on the submissions page shows submission volume over time and, for select/radio fields, a breakdown bar chart showing how respondents answered each choice field.

prompt.txt
1Add a Tabs component to the submissions page with two tabs: 'Responses' (the DataTable) and 'Analytics'.
2
3In the Analytics tab:
4
51. Submission volume chart:
6 Group submissions by date (date_trunc day in Supabase query).
7 Render as a shadcn BarChart (recharts) with x=date, y=count.
8 Use ResponsiveContainer width=100%.
9 Add a DateRangePicker above the chart to filter.
10
112. Field response charts (for select and radio fields only):
12 For each select/radio field, fetch all submissions and count how many times each option appears in responses[field.id].
13 Render each field's distribution as a horizontal BarChart.
14 Label each chart with the field's label as the heading.
15 Show the option label on the y-axis and count on the x-axis.
16
173. Key metrics row above charts:
18 - Total submissions (number)
19 - Submissions today (number)
20 - Most common answer for the first select/radio field
21
22All chart data fetching should be TypeScript typed.

Expected result: The Analytics tab shows a submission volume bar chart and per-field distribution charts for all select/radio fields. The date range filter re-fetches and re-renders the volume chart.

Complete code

src/lib/buildZodSchema.ts
1import { z } from 'zod'
2
3export type FieldType = 'text' | 'email' | 'number' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'date'
4
5export interface FieldDef {
6 id: string
7 field_type: FieldType
8 label: string
9 required: boolean
10 options: string[]
11}
12
13export function buildZodSchema(fields: FieldDef[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
14 const shape: Record<string, z.ZodTypeAny> = {}
15
16 for (const f of fields) {
17 let base: z.ZodTypeAny
18
19 switch (f.field_type) {
20 case 'email':
21 base = z.string().email(`${f.label}: invalid email address`)
22 break
23 case 'number':
24 base = z.coerce.number({ invalid_type_error: `${f.label}: must be a number` })
25 break
26 case 'checkbox':
27 base = z.boolean()
28 break
29 case 'select':
30 case 'radio':
31 base = f.options.length > 0
32 ? z.enum(f.options as [string, ...string[]])
33 : z.string()
34 break
35 case 'date':
36 base = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, `${f.label}: invalid date`)
37 break
38 default:
39 base = z.string()
40 }
41
42 if (f.required && f.field_type !== 'checkbox' && f.field_type !== 'number') {
43 base = (base as z.ZodString).min(1, `${f.label} is required`)
44 }
45
46 shape[f.id] = f.required ? base : base.optional()
47 }
48
49 return z.object(shape)
50}
51
52/** Infer the TypeScript type from a set of field definitions at runtime */
53export type DynamicFormValues = Record<string, string | number | boolean | undefined>

Customization ideas

Conditional field visibility

Add a conditions array to each field definition: show field B only if field A equals a specific value. Update the FormRenderer to evaluate conditions on each render and hide/show fields accordingly.

Multi-page forms

Add a page_number to form_fields and render one page at a time with Previous/Next buttons. Validate only the current page's fields before advancing. Show a Progress bar indicating completion percentage.

Email notifications on submission

Create a Supabase Edge Function triggered by a database webhook on submissions INSERT. Send the form owner an email via Resend with a summary of the new response.

Embed code generator

Add an Embed tab in the form builder that shows a copy-paste iframe snippet: <iframe src='/forms/:id/fill' />. This lets users embed the form in any external website.

File upload field type

Add a file field type that uploads to a private Supabase Storage bucket and stores the file path in the JSONB response. Show a signed URL link in the submissions DataTable.

Response editing and partial saves

Add a draft_responses table to save partial form completions keyed by a browser session ID. Pre-fill the form with draft values on return visits so respondents don't lose progress.

Common pitfalls

Pitfall: Storing submissions with field labels instead of field IDs as keys

How to avoid: Always key the responses JSONB object by field.id (a stable UUID), not field.label. Map IDs to labels only at display time in the DataTable column generation.

Pitfall: Recreating the Zod schema on every render

How to avoid: Wrap buildZodSchema(fields) in useMemo with fields as the dependency. The schema only rebuilds when the field definitions actually change.

Pitfall: Not enabling RLS on the submissions table

How to avoid: The schema in Step 1 includes an Owners read submissions policy using a subquery on the forms table. Make sure both the forms and submissions tables have RLS enabled.

Pitfall: Letting the public form page load without checking accepts_responses

How to avoid: On the public /forms/:id/fill page, fetch the form and check accepts_responses. If false, show a 'This form is closed' message instead of rendering the form.

Best practices

  • Always key submission responses by field ID not field label so renames don't break historical data
  • Memoize the Zod schema with useMemo to prevent react-hook-form from reinitializing on every render
  • Use JSONB with GIN index on submissions for efficient querying of specific response values in analytics
  • Enable the accepts_responses flag check on the public form page to give users a clear message when a form is closed
  • Generate DataTable columns dynamically from field metadata at render time — never hardcode column definitions for dynamic forms
  • Scope form editing in Lovable with owner_id RLS policies so users can never edit or view another owner's forms
  • Use Lovable Plan Mode to design the recursive field renderer logic before generating code — this component is the hardest part of the build
  • Test the Zod schema builder with edge cases: required select with no options, optional email with empty string, nested conditional fields

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

Write a TypeScript function that takes an array of form field definitions (with field_type, label, required, and options properties) and returns a Zod validation schema where each field's key is its UUID. Handle text, email, number, textarea, select, checkbox, radio, and date field types with appropriate validators.

Lovable Prompt

Build a form builder at /builder/:formId with a two-panel layout: left panel has a field type palette and sortable field list, right panel shows a live form preview. Add a public /forms/:id/fill page that renders the form dynamically from Supabase and submits responses as JSONB.

Build Prompt

Generate the submissions DataTable dynamically from form field metadata. Fetch form_fields ordered by position to create column definitions, then map each submission's JSONB responses object to row data using field.id as the key. Show field.label as the column header and handle boolean checkbox fields by rendering a checkmark icon.

Frequently asked questions

How does the dynamic Zod schema handle select fields with changing options?

The buildZodSchema function uses z.enum(options) when options is a non-empty array. If the owner adds or removes options after submissions exist, old response values may fail validation on new submissions — but existing submissions are already stored and unaffected. For full flexibility, fall back to z.string() for select fields if backwards compatibility with old data matters.

Can I build this on the Lovable free plan?

The form builder UI, field editor, live preview, and basic submissions storage work on the free plan. The email notification Edge Function requires Lovable Pro. The free plan's 5 daily credits will be challenging for a multi-step build — use Plan Mode extensively to minimize credit usage by planning each component before generating code.

How do I make the form accessible (a11y)?

The FormRenderer component uses shadcn/ui Form, FormItem, FormLabel, and FormMessage components which are built on Radix UI primitives with full accessibility support. Every label is associated with its input via the FormField's name prop. The main additional step is ensuring the required asterisk has an aria-hidden='true' attribute so screen readers don't read it literally.

How many fields can a form have before performance degrades?

The memoized Zod schema and sorted field list handle up to 50-100 fields without noticeable performance issues. Beyond that, consider virtualizing the field list with a virtualization library. The Supabase submission query is unaffected by field count since responses are a single JSONB object.

How do I deploy the public form fill page with a custom domain?

Click the Publish icon in Lovable and set up your custom domain in Settings → Custom Domain. The public /forms/:id/fill route works without authentication — the Supabase RLS policy allows insert on submissions for any form where accepts_responses is true.

Can I export submission data to Google Sheets?

The Export CSV Button in the submissions page downloads all data as a CSV file. You can then import this directly into Google Sheets via File → Import. For automatic syncing, create a Supabase Edge Function with a cron trigger that uses the Google Sheets API to append new rows on a schedule.

My Lovable build keeps rewriting the FormRenderer when I ask for other changes. How do I prevent that?

Use @src/components/FormRenderer.tsx in your prompt to tell Lovable which file to leave untouched. Example: 'Add the Analytics tab to @src/pages/Submissions.tsx without modifying FormRenderer.tsx.' RapidDev recommends extracting core components like FormRenderer into separate files early so they are easy to pin in subsequent prompts.

How do I add spam protection to the public form?

Add a honeypot field: a hidden Input in the form that bots fill in but humans do not. In the submission handler, reject any submission where the honeypot field is non-empty. For higher security, integrate Cloudflare Turnstile (free) by adding the widget to the form page and verifying the token in a Supabase Edge Function before inserting the submission.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.