Build a full survey platform in Lovable where you create multi-question surveys, share a public link, and collect responses without requiring respondents to log in. Questions support multiple choice, text, rating, and boolean types. Results display as charts in a live dashboard. All data lives in Supabase with anonymous inserts via RLS.
What you're building
A self-serve survey tool where you log in as an admin, create surveys, add questions of different types, and share a URL. Respondents visit the link, answer each question, and submit — no account needed. The admin results page shows aggregated answer data as charts, with text responses listed below.
Final result
A shareable survey at /survey/[id] where anyone can respond, and an admin dashboard at /surveys showing all your surveys with response counts, open rates, and per-question result charts.
Tech stack
Prerequisites
- Lovable account (Free plan works)
- Supabase project created at supabase.com
- Supabase project URL and anon key ready
- Basic understanding of Lovable's chat prompt interface
Build steps
Create the database schema with JSONB for question options
Run this SQL in your Supabase SQL Editor. It creates surveys, questions, and responses tables. Question options are stored as a JSONB array so you can have a variable number of choices per question without extra tables.
1-- Run in Supabase SQL Editor2create table public.surveys (3 id uuid primary key default gen_random_uuid(),4 title text not null,5 description text,6 is_active boolean not null default true,7 created_at timestamptz not null default now()8);910create table public.questions (11 id uuid primary key default gen_random_uuid(),12 survey_id uuid not null references public.surveys(id) on delete cascade,13 question_text text not null,14 question_type text not null check (question_type in ('multiple_choice','text','rating','boolean')),15 options jsonb,16 position integer not null default 0,17 required boolean not null default true18);1920create table public.responses (21 id uuid primary key default gen_random_uuid(),22 survey_id uuid not null references public.surveys(id) on delete cascade,23 answers jsonb not null,24 submitted_at timestamptz not null default now()25);2627alter table public.surveys enable row level security;28alter table public.questions enable row level security;29alter table public.responses enable row level security;3031-- Public can read active surveys and their questions32create policy "public_read_surveys" on public.surveys33 for select to anon using (is_active = true);34create policy "public_read_questions" on public.questions35 for select to anon using (true);3637-- Public can insert responses38create policy "anon_insert_responses" on public.responses39 for insert to anon with check (true);4041-- Authenticated admins can do everything42create policy "admin_all_surveys" on public.surveys for all to authenticated using (true) with check (true);43create policy "admin_all_questions" on public.questions for all to authenticated using (true) with check (true);44create policy "admin_read_responses" on public.responses for select to authenticated using (true);Pro tip: The answers column in responses is JSONB in the format { "[question_id]": "answer_value" }. This makes it trivial to add new question types later without altering the table.
Expected result: Three tables visible in Supabase Table Editor: surveys, questions, responses. All have RLS enabled with the correct policies shown in the Policies tab.
Scaffold the full project structure with Lovable
Connect Supabase in Lovable's Cloud tab, then send the prompt below to generate the survey builder, public response page, and results dashboard in one go.
1// Lovable prompt — paste into chat2// Build a survey platform with Supabase.3// Tables: surveys, questions (question_type, options JSONB, position), responses (answers JSONB).4// Pages:5// /surveys — admin list with Card per survey, response count, Create Survey button.6// /surveys/[id]/edit — survey editor: title/description Form at top, then a list of questions.7// Each question shows type Badge, text, and Edit/Delete actions.8// Add Question button opens a Dialog with: Input for question text, Select for type9// (multiple_choice/text/rating/boolean), dynamic options list for multiple_choice.10// /survey/[id] — PUBLIC page (no auth): shows survey title, one question at a time,11// RadioGroup for multiple_choice, Input for text, 1-5 Button row for rating, Switch for boolean.12// Progress bar shows current question / total.13// Submit button on last question inserts to responses table as anon.14// /surveys/[id]/results — Bar chart for multiple_choice/rating, text list for text questions.15// shadcn/ui throughout. Zod validation on the builder form.Pro tip: Use Plan Mode first to verify the routing structure. Four pages with different auth requirements (admin vs public) benefit from a clear layout plan before Lovable writes code.
Expected result: Lovable generates four route files and the shared Supabase client. The preview shows the surveys list page with a Create Survey button.
Build the question editor Dialog with dynamic options
The Dialog for adding questions needs to show an options list only when the type is multiple_choice. Use React state to track the current type and conditionally render an option-adding UI.
1// src/components/QuestionDialog.tsx2import { useState } from 'react'3import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'4import { Button } from '@/components/ui/button'5import { Input } from '@/components/ui/input'6import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'7import { Label } from '@/components/ui/label'8import { supabase } from '@/lib/supabase'9import { toast } from 'sonner'1011type Props = { surveyId: string; open: boolean; onClose: () => void; onSaved: () => void }1213export function QuestionDialog({ surveyId, open, onClose, onSaved }: Props) {14 const [text, setText] = useState('')15 const [type, setType] = useState<string>('multiple_choice')16 const [options, setOptions] = useState<string[]>([''])1718 async function save() {19 if (!text.trim()) { toast.error('Question text is required'); return }20 const payload = {21 survey_id: surveyId,22 question_text: text,23 question_type: type,24 options: type === 'multiple_choice' ? options.filter(Boolean) : null25 }26 const { error } = await supabase.from('questions').insert(payload)27 if (error) { toast.error('Failed to save question'); return }28 toast.success('Question added')29 setText(''); setType('multiple_choice'); setOptions([''])30 onSaved(); onClose()31 }3233 return (34 <Dialog open={open} onOpenChange={onClose}>35 <DialogContent className="max-w-md">36 <DialogHeader><DialogTitle>Add Question</DialogTitle></DialogHeader>37 <div className="space-y-4">38 <div><Label>Question</Label><Input value={text} onChange={e => setText(e.target.value)} placeholder="What is your question?" /></div>39 <div>40 <Label>Type</Label>41 <Select value={type} onValueChange={setType}>42 <SelectTrigger><SelectValue /></SelectTrigger>43 <SelectContent>44 <SelectItem value="multiple_choice">Multiple Choice</SelectItem>45 <SelectItem value="text">Text Answer</SelectItem>46 <SelectItem value="rating">Rating (1-5)</SelectItem>47 <SelectItem value="boolean">Yes / No</SelectItem>48 </SelectContent>49 </Select>50 </div>51 {type === 'multiple_choice' && (52 <div className="space-y-2">53 <Label>Options</Label>54 {options.map((opt, i) => (55 <Input key={i} value={opt} onChange={e => setOptions(prev => prev.map((o, j) => j === i ? e.target.value : o))} placeholder={`Option ${i + 1}`} />56 ))}57 <Button variant="outline" size="sm" onClick={() => setOptions(prev => [...prev, ''])}>Add option</Button>58 </div>59 )}60 </div>61 <DialogFooter>62 <Button variant="ghost" onClick={onClose}>Cancel</Button>63 <Button onClick={save}>Save Question</Button>64 </DialogFooter>65 </DialogContent>66 </Dialog>67 )68}Expected result: The Add Question Dialog shows a dynamic options list when Multiple Choice is selected and hides it for other types. Saving inserts the row to Supabase and closes the dialog.
Build the public survey response form with Progress
The public response page fetches the survey and questions, then steps through them one at a time. Progress bar shows completion. Answers accumulate in a local object keyed by question ID, then the whole JSONB object is inserted into responses on submit.
1// src/pages/SurveyResponse.tsx (key logic)2import { useEffect, useState } from 'react'3import { useParams } from 'react-router-dom'4import { supabase } from '@/lib/supabase'5import { Button } from '@/components/ui/button'6import { Input } from '@/components/ui/input'7import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'8import { Label } from '@/components/ui/label'9import { Progress } from '@/components/ui/progress'10import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'11import { toast } from 'sonner'1213export function SurveyResponse() {14 const { id } = useParams<{ id: string }>()15 const [questions, setQuestions] = useState<any[]>([])16 const [current, setCurrent] = useState(0)17 const [answers, setAnswers] = useState<Record<string, string>>({})18 const [submitted, setSubmitted] = useState(false)1920 useEffect(() => {21 supabase.from('questions').select('*').eq('survey_id', id).order('position')22 .then(({ data }) => setQuestions(data ?? []))23 }, [id])2425 const q = questions[current]26 const progress = questions.length ? ((current) / questions.length) * 100 : 02728 async function submit() {29 const { error } = await supabase.from('responses').insert({ survey_id: id, answers })30 if (error) { toast.error('Submission failed'); return }31 setSubmitted(true)32 }3334 if (submitted) return <div className="text-center mt-20"><h2 className="text-2xl font-bold">Thank you!</h2><p className="text-muted-foreground mt-2">Your response has been recorded.</p></div>35 if (!q) return null3637 return (38 <div className="max-w-lg mx-auto mt-10 space-y-6">39 <Progress value={progress} />40 <Card>41 <CardHeader><CardTitle>{q.question_text}</CardTitle></CardHeader>42 <CardContent>43 {q.question_type === 'multiple_choice' && (44 <RadioGroup value={answers[q.id] ?? ''} onValueChange={v => setAnswers(p => ({ ...p, [q.id]: v }))}>45 {(q.options as string[]).map((opt: string) => (46 <div key={opt} className="flex items-center gap-2">47 <RadioGroupItem value={opt} id={opt} /><Label htmlFor={opt}>{opt}</Label>48 </div>49 ))}50 </RadioGroup>51 )}52 {q.question_type === 'text' && (53 <Input value={answers[q.id] ?? ''} onChange={e => setAnswers(p => ({ ...p, [q.id]: e.target.value }))} placeholder="Your answer" />54 )}55 {q.question_type === 'rating' && (56 <div className="flex gap-2">57 {[1,2,3,4,5].map(n => (58 <Button key={n} variant={answers[q.id] === String(n) ? 'default' : 'outline'} size="sm" onClick={() => setAnswers(p => ({ ...p, [q.id]: String(n) }))}>{n}</Button>59 ))}60 </div>61 )}62 {q.question_type === 'boolean' && (63 <div className="flex gap-4">64 {['Yes','No'].map(v => (65 <Button key={v} variant={answers[q.id] === v ? 'default' : 'outline'} onClick={() => setAnswers(p => ({ ...p, [q.id]: v }))}>{v}</Button>66 ))}67 </div>68 )}69 </CardContent>70 </Card>71 <div className="flex justify-between">72 <Button variant="ghost" onClick={() => setCurrent(p => Math.max(0, p - 1))} disabled={current === 0}>Back</Button>73 {current < questions.length - 174 ? <Button onClick={() => setCurrent(p => p + 1)} disabled={!answers[q.id]}>Next</Button>75 : <Button onClick={submit} disabled={!answers[q.id]}>Submit</Button>}76 </div>77 </div>78 )79}Pro tip: Store the session answers in sessionStorage so respondents don't lose their progress if they accidentally navigate away and return.
Expected result: Visiting /survey/[id] shows questions one at a time with a progress bar. Submitting the last question inserts a row into responses and shows the thank-you screen.
Add a results dashboard with Recharts
The results page fetches all responses for a survey, aggregates answers per question, and renders a Bar chart for numeric and multiple choice questions and a plain list for text answers.
1// src/pages/SurveyResults.tsx (key section)2import { useEffect, useState } from 'react'3import { useParams } from 'react-router-dom'4import { supabase } from '@/lib/supabase'5import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'6import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'78export function SurveyResults() {9 const { id } = useParams<{ id: string }>()10 const [questions, setQuestions] = useState<any[]>([])11 const [responses, setResponses] = useState<any[]>([])1213 useEffect(() => {14 Promise.all([15 supabase.from('questions').select('*').eq('survey_id', id).order('position'),16 supabase.from('responses').select('answers').eq('survey_id', id)17 ]).then(([q, r]) => {18 setQuestions(q.data ?? [])19 setResponses(r.data ?? [])20 })21 }, [id])2223 function aggregate(qId: string, type: string, options?: string[]) {24 const vals = responses.map(r => r.answers[qId]).filter(Boolean)25 if (type === 'text') return vals26 const counts: Record<string, number> = {}27 vals.forEach(v => { counts[v] = (counts[v] ?? 0) + 1 })28 return Object.entries(counts).map(([name, value]) => ({ name, value }))29 }3031 return (32 <div className="p-6 space-y-6">33 <h1 className="text-2xl font-bold">Results ({responses.length} responses)</h1>34 {questions.map(q => {35 const data = aggregate(q.id, q.question_type, q.options)36 return (37 <Card key={q.id}>38 <CardHeader><CardTitle>{q.question_text}</CardTitle></CardHeader>39 <CardContent>40 {q.question_type === 'text'41 ? <ul className="space-y-1">{(data as string[]).map((t, i) => <li key={i} className="text-sm border rounded p-2">{t}</li>)}</ul>42 : <ResponsiveContainer width="100%" height={200}>43 <BarChart data={data as any[]}>44 <XAxis dataKey="name" /><YAxis /><Tooltip />45 <Bar dataKey="value" className="fill-primary" />46 </BarChart>47 </ResponsiveContainer>48 }49 </CardContent>50 </Card>51 )52 })}53 </div>54 )55}Pro tip: Add a Supabase Realtime subscription on the responses table so the results charts update live as new responses come in — useful for live polling during a presentation.
Expected result: The results page shows one Card per question. Multiple choice and rating questions display a bar chart with answer counts. Text questions show individual response lines.
Complete code
1import { useState } from 'react'2import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'3import { Button } from '@/components/ui/button'4import { Input } from '@/components/ui/input'5import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'6import { Label } from '@/components/ui/label'7import { Badge } from '@/components/ui/badge'8import { X } from 'lucide-react'9import { supabase } from '@/lib/supabase'10import { toast } from 'sonner'1112type QType = 'multiple_choice' | 'text' | 'rating' | 'boolean'13type Props = { surveyId: string; open: boolean; onClose: () => void; onSaved: () => void }1415export function QuestionDialog({ surveyId, open, onClose, onSaved }: Props) {16 const [text, setText] = useState('')17 const [type, setType] = useState<QType>('multiple_choice')18 const [options, setOptions] = useState<string[]>(['', ''])19 const [saving, setSaving] = useState(false)2021 async function save() {22 if (!text.trim()) { toast.error('Question text is required'); return }23 if (type === 'multiple_choice' && options.filter(Boolean).length < 2) { toast.error('Need at least 2 options'); return }24 setSaving(true)25 const { error } = await supabase.from('questions').insert({26 survey_id: surveyId, question_text: text.trim(), question_type: type,27 options: type === 'multiple_choice' ? options.filter(Boolean) : null, required: true28 })29 setSaving(false)30 if (error) { toast.error('Failed to save question'); return }31 toast.success('Question added')32 setText(''); setType('multiple_choice'); setOptions(['', '']); onSaved(); onClose()33 }3435 return (36 <Dialog open={open} onOpenChange={onClose}>37 <DialogContent className="max-w-md">38 <DialogHeader><DialogTitle>Add Question</DialogTitle></DialogHeader>39 <div className="space-y-4 py-2">40 <div className="space-y-1">41 <Label>Question text</Label>42 <Input value={text} onChange={e => setText(e.target.value)} placeholder="What would you like to ask?" />43 </div>44 <div className="space-y-1">45 <Label>Answer type</Label>46 <Select value={type} onValueChange={v => setType(v as QType)}>47 <SelectTrigger><SelectValue /></SelectTrigger>48 <SelectContent>49 {(['multiple_choice','text','rating','boolean'] as QType[]).map(t => (50 <SelectItem key={t} value={t}>{t.replace('_',' ')}</SelectItem>51 ))}52 </SelectContent>53 </Select>54 </div>55 {type === 'multiple_choice' && (56 <div className="space-y-2">57 <Label>Options</Label>58 {options.map((opt, i) => (59 <div key={i} className="flex gap-2">60 <Input value={opt} onChange={e => setOptions(p => p.map((o,j) => j===i ? e.target.value : o))} placeholder={"Option " + (i+1)} />61 {options.length > 2 && <Button variant="ghost" size="icon" onClick={() => setOptions(p => p.filter((_,j) => j !== i))}><X className="h-4 w-4" /></Button>}62 </div>63 ))}64 <Button variant="outline" size="sm" onClick={() => setOptions(p => [...p, ''])}>Add option</Button>65 </div>66 )}67 {type === 'rating' && (68 <div className="flex gap-1">{[1,2,3,4,5].map(n => <Badge key={n} variant="outline" className="w-8 h-8 flex items-center justify-center">{n}</Badge>)}</div>69 )}70 </div>71 <DialogFooter>72 <Button variant="ghost" onClick={onClose}>Cancel</Button>73 <Button onClick={save} disabled={saving}>{saving ? 'Saving...' : 'Save Question'}</Button>74 </DialogFooter>75 </DialogContent>76 </Dialog>77 )78}Customization ideas
Conditional logic (skip branching)
Add a conditions JSONB column on questions to store rules like 'show this question only if question X equals Y', and evaluate them in the response form to skip irrelevant questions.
Response limit and deadline
Add max_responses integer and closes_at timestamptz columns to surveys. Check them in the public form and show a 'Survey closed' message when either limit is reached.
Email results export
Add an Export button in the results dashboard that triggers a Supabase Edge Function to generate a CSV of all responses and email it to the admin using Resend.
Embed code generator
Show a copyable iframe snippet on the survey edit page so admins can embed the public survey directly in any website without leaving Lovable.
Respondent analytics
Store a device type string and a completion time in seconds on each response row, then surface average completion time and mobile vs desktop split in the results dashboard.
Team collaboration
Add an owner_id column linked to Supabase Auth users and an RLS policy so each admin only sees and edits their own surveys, while a separate superadmin role sees all.
Common pitfalls
Pitfall: Not setting position on questions when inserting
How to avoid: When inserting a new question, set position to the current count of questions for that survey: SELECT COUNT(*) FROM questions WHERE survey_id = $1.
Pitfall: Treating the answers JSONB as a flat array
How to avoid: Always insert answers as { "[question_id]": "answer_value" } so you can do responses.map(r => r.answers[questionId]) to aggregate cleanly.
Pitfall: Forgetting the anon insert policy on the responses table
How to avoid: Run: CREATE POLICY "anon_insert_responses" ON public.responses FOR INSERT TO anon WITH CHECK (true); in Supabase SQL Editor.
Pitfall: Linking to /survey/[id] before publishing the Lovable app
How to avoid: Use the Publish icon to get your production URL before sharing survey links with real respondents.
Best practices
- Store question options as JSONB arrays rather than a separate options table — for a question builder, this is simpler and avoids complex JOINs.
- Index the survey_id column on both questions and responses tables for fast lookups when a survey accumulates many responses.
- Validate on the client with Zod before inserting to Supabase to surface errors immediately, and also check required fields before allowing Next button clicks.
- Use Supabase Realtime on the responses table in the results dashboard so charts update live without polling.
- Show a Progress bar to respondents so they know how many questions remain — this significantly reduces abandonment rates.
- Paginate the results queries in the admin dashboard as survey response counts grow, using Supabase's .range() pagination.
- Store a completed_in_seconds field on each response by calculating Date.now() minus the time the form was first rendered — useful for detecting bots.
- Test the full survey flow as an anonymous user in an incognito window before sharing the link.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a survey platform with React and Supabase. I have three tables: surveys, questions (with question_type enum and options JSONB), and responses (with answers JSONB keyed by question_id). I need a React component that steps through questions one at a time, renders a RadioGroup for multiple_choice, Input for text, numbered Buttons for rating, and Yes/No Buttons for boolean. A Progress component shows completion. On the last question, clicking Submit inserts the full answers object to Supabase as anon. Show me the complete TypeScript component.
Add a duplicate survey feature to my survey builder. When the admin clicks Duplicate on a survey card, copy the survey row with a new title prefixed by 'Copy of', then copy all its question rows with the new survey_id, and navigate to the new survey's edit page. Show a loading state on the button while copying.
In my Lovable project, add Supabase Realtime to the results page so that when a new row is inserted into the responses table for the current survey_id, the charts update automatically without refreshing. Use supabase.channel() with a postgres_changes filter on the responses table.
Frequently asked questions
Do respondents need to create an account to answer a survey?
No. The public /survey/[id] page uses anonymous Supabase access. The anon insert policy on the responses table allows anyone with the link to submit answers without signing in.
How do I share the survey link with respondents?
Click the Publish icon in Lovable's top-right corner to get your production URL. The survey link is then your-app.lovable.app/survey/[survey-id]. You can find the ID in your Supabase surveys table.
Can I have multiple active surveys at the same time?
Yes. Each survey is an independent row in the surveys table with its own unique ID. Create as many as you need and share different /survey/[id] links for each one.
What happens to responses if I delete a survey?
The responses table has ON DELETE CASCADE on the survey_id foreign key, so deleting a survey also deletes all its responses and questions automatically.
How do I close a survey so no new responses come in?
Set the is_active column to false in your Supabase Table Editor. The RLS policy only allows anon reads of active surveys, so respondents will see a not found page instead.
Can I export survey results to a spreadsheet?
The easiest way is to go to your Supabase Table Editor, open the responses table, filter by survey_id, and use the Export CSV button. For automated exports, add a Supabase Edge Function that formats the JSONB answers and emails a CSV.
Can RapidDev help me add advanced features like conditional logic or custom branding?
Yes. RapidDev builds custom Lovable extensions including conditional question branching, white-label survey pages, and integrations with tools like Notion or Airtable. Get in touch at rapiddev.io.
The bar charts show all responses but I only want to show the last 7 days. How do I filter?
In the results page query, add .gte('submitted_at', new Date(Date.now() - 7 * 86400 * 1000).toISOString()) to the Supabase responses query. Pass a date range picker value to make it dynamic.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation