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
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
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.
1-- Run in Supabase SQL Editor2create 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);1213create 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);2425create 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 text31);3233create index idx_form_fields_form_position on form_fields(form_id, position);34create index idx_submissions_form_submitted on submissions(form_id, submitted_at);3536alter table forms enable row level security;37alter table form_fields enable row level security;38alter table submissions enable row level security;3940create 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.
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.
1Build the form builder at /builder/:formId.23Left panel - Field Editor:41. Field type palette at top (8 buttons in a 4x2 grid):5 text, email, number, textarea, select, checkbox, radio, date6 Each button is an outlined Card with an icon and label.7 Clicking adds a new field to the form with a default label.892. 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.1415Right 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.1920Top 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.
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.
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'1415export interface FormFieldDef {16 id: string17 field_type: 'text' | 'email' | 'number' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'date'18 label: string19 placeholder?: string20 required: boolean21 options: string[]22 position: number23}2425function buildZodSchema(fields: FormFieldDef[]) {26 const shape: Record<string, z.ZodTypeAny> = {}27 for (const f of fields) {28 let schema: z.ZodTypeAny29 switch (f.field_type) {30 case 'email': schema = z.string().email('Invalid email'); break31 case 'number': schema = z.coerce.number(); break32 case 'checkbox': schema = z.boolean(); break33 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] = schema41 }42 return z.object(shape)43}4445interface FormRendererProps {46 formId: string47 fields: FormFieldDef[]48 preview?: boolean49}5051export 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>5556 const form = useForm<FormValues>({ resolver: zodResolver(schema) })5758 const onSubmit = async (values: FormValues) => {59 if (preview) return60 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 }6566 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.
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.
1Build a /builder/:formId/submissions page.23Fetch the form's form_fields ordered by position.4Fetch all submissions for the form ordered by submitted_at DESC.56Dynamically generate DataTable columns:71. First column: submitted_at (formatted date)82. One column per form field using field.label as the header9 - For each submission row, access submission.responses[field.id]10 - Truncate text values longer than 40 chars with an ellipsis11 - Show boolean checkbox responses as a green checkmark or red X123. Last column: Actions with a 'View Full' Button1314Clicking 'View Full' opens a Dialog showing all field labels and their full response values in a definition list.1516Above the table show:17- Total submissions count Card18- Average completion rate Card (submissions with all required fields filled)19- Export CSV Button that downloads all submissions as a CSV file20 (Map field IDs to field labels in the CSV header row)2122All 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.
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.
1Add a Tabs component to the submissions page with two tabs: 'Responses' (the DataTable) and 'Analytics'.23In the Analytics tab:451. 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.10112. 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.16173. Key metrics row above charts:18 - Total submissions (number)19 - Submissions today (number)20 - Most common answer for the first select/radio field2122All 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
1import { z } from 'zod'23export type FieldType = 'text' | 'email' | 'number' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'date'45export interface FieldDef {6 id: string7 field_type: FieldType8 label: string9 required: boolean10 options: string[]11}1213export function buildZodSchema(fields: FieldDef[]): z.ZodObject<Record<string, z.ZodTypeAny>> {14 const shape: Record<string, z.ZodTypeAny> = {}1516 for (const f of fields) {17 let base: z.ZodTypeAny1819 switch (f.field_type) {20 case 'email':21 base = z.string().email(`${f.label}: invalid email address`)22 break23 case 'number':24 base = z.coerce.number({ invalid_type_error: `${f.label}: must be a number` })25 break26 case 'checkbox':27 base = z.boolean()28 break29 case 'select':30 case 'radio':31 base = f.options.length > 032 ? z.enum(f.options as [string, ...string[]])33 : z.string()34 break35 case 'date':36 base = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, `${f.label}: invalid date`)37 break38 default:39 base = z.string()40 }4142 if (f.required && f.field_type !== 'checkbox' && f.field_type !== 'number') {43 base = (base as z.ZodString).min(1, `${f.label} is required`)44 }4546 shape[f.id] = f.required ? base : base.optional()47 }4849 return z.object(shape)50}5152/** 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation