# How to Build a Polls and Surveys with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable (any plan), Supabase Free tier
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
-- Run in Supabase SQL Editor
create table public.surveys (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  description text,
  is_active boolean not null default true,
  created_at timestamptz not null default now()
);

create table public.questions (
  id uuid primary key default gen_random_uuid(),
  survey_id uuid not null references public.surveys(id) on delete cascade,
  question_text text not null,
  question_type text not null check (question_type in ('multiple_choice','text','rating','boolean')),
  options jsonb,
  position integer not null default 0,
  required boolean not null default true
);

create table public.responses (
  id uuid primary key default gen_random_uuid(),
  survey_id uuid not null references public.surveys(id) on delete cascade,
  answers jsonb not null,
  submitted_at timestamptz not null default now()
);

alter table public.surveys enable row level security;
alter table public.questions enable row level security;
alter table public.responses enable row level security;

-- Public can read active surveys and their questions
create policy "public_read_surveys" on public.surveys
  for select to anon using (is_active = true);
create policy "public_read_questions" on public.questions
  for select to anon using (true);

-- Public can insert responses
create policy "anon_insert_responses" on public.responses
  for insert to anon with check (true);

-- Authenticated admins can do everything
create policy "admin_all_surveys" on public.surveys for all to authenticated using (true) with check (true);
create policy "admin_all_questions" on public.questions for all to authenticated using (true) with check (true);
create 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.

### 2. 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.

```
// Lovable prompt — paste into chat
// Build a survey platform with Supabase.
// Tables: surveys, questions (question_type, options JSONB, position), responses (answers JSONB).
// Pages:
//   /surveys — admin list with Card per survey, response count, Create Survey button.
//   /surveys/[id]/edit — survey editor: title/description Form at top, then a list of questions.
//     Each question shows type Badge, text, and Edit/Delete actions.
//     Add Question button opens a Dialog with: Input for question text, Select for type
//     (multiple_choice/text/rating/boolean), dynamic options list for multiple_choice.
//   /survey/[id] — PUBLIC page (no auth): shows survey title, one question at a time,
//     RadioGroup for multiple_choice, Input for text, 1-5 Button row for rating, Switch for boolean.
//     Progress bar shows current question / total.
//     Submit button on last question inserts to responses table as anon.
//   /surveys/[id]/results — Bar chart for multiple_choice/rating, text list for text questions.
// 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.

### 3. 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.

```
// src/components/QuestionDialog.tsx
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'

type Props = { surveyId: string; open: boolean; onClose: () => void; onSaved: () => void }

export function QuestionDialog({ surveyId, open, onClose, onSaved }: Props) {
  const [text, setText] = useState('')
  const [type, setType] = useState<string>('multiple_choice')
  const [options, setOptions] = useState<string[]>([''])

  async function save() {
    if (!text.trim()) { toast.error('Question text is required'); return }
    const payload = {
      survey_id: surveyId,
      question_text: text,
      question_type: type,
      options: type === 'multiple_choice' ? options.filter(Boolean) : null
    }
    const { error } = await supabase.from('questions').insert(payload)
    if (error) { toast.error('Failed to save question'); return }
    toast.success('Question added')
    setText(''); setType('multiple_choice'); setOptions([''])
    onSaved(); onClose()
  }

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>Add Question</DialogTitle></DialogHeader>
        <div className="space-y-4">
          <div><Label>Question</Label><Input value={text} onChange={e => setText(e.target.value)} placeholder="What is your question?" /></div>
          <div>
            <Label>Type</Label>
            <Select value={type} onValueChange={setType}>
              <SelectTrigger><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="multiple_choice">Multiple Choice</SelectItem>
                <SelectItem value="text">Text Answer</SelectItem>
                <SelectItem value="rating">Rating (1-5)</SelectItem>
                <SelectItem value="boolean">Yes / No</SelectItem>
              </SelectContent>
            </Select>
          </div>
          {type === 'multiple_choice' && (
            <div className="space-y-2">
              <Label>Options</Label>
              {options.map((opt, i) => (
                <Input key={i} value={opt} onChange={e => setOptions(prev => prev.map((o, j) => j === i ? e.target.value : o))} placeholder={`Option ${i + 1}`} />
              ))}
              <Button variant="outline" size="sm" onClick={() => setOptions(prev => [...prev, ''])}>Add option</Button>
            </div>
          )}
        </div>
        <DialogFooter>
          <Button variant="ghost" onClick={onClose}>Cancel</Button>
          <Button onClick={save}>Save Question</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

**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.

### 4. 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.

```
// src/pages/SurveyResponse.tsx (key logic)
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'

export function SurveyResponse() {
  const { id } = useParams<{ id: string }>()
  const [questions, setQuestions] = useState<any[]>([])
  const [current, setCurrent] = useState(0)
  const [answers, setAnswers] = useState<Record<string, string>>({})
  const [submitted, setSubmitted] = useState(false)

  useEffect(() => {
    supabase.from('questions').select('*').eq('survey_id', id).order('position')
      .then(({ data }) => setQuestions(data ?? []))
  }, [id])

  const q = questions[current]
  const progress = questions.length ? ((current) / questions.length) * 100 : 0

  async function submit() {
    const { error } = await supabase.from('responses').insert({ survey_id: id, answers })
    if (error) { toast.error('Submission failed'); return }
    setSubmitted(true)
  }

  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>
  if (!q) return null

  return (
    <div className="max-w-lg mx-auto mt-10 space-y-6">
      <Progress value={progress} />
      <Card>
        <CardHeader><CardTitle>{q.question_text}</CardTitle></CardHeader>
        <CardContent>
          {q.question_type === 'multiple_choice' && (
            <RadioGroup value={answers[q.id] ?? ''} onValueChange={v => setAnswers(p => ({ ...p, [q.id]: v }))}>
              {(q.options as string[]).map((opt: string) => (
                <div key={opt} className="flex items-center gap-2">
                  <RadioGroupItem value={opt} id={opt} /><Label htmlFor={opt}>{opt}</Label>
                </div>
              ))}
            </RadioGroup>
          )}
          {q.question_type === 'text' && (
            <Input value={answers[q.id] ?? ''} onChange={e => setAnswers(p => ({ ...p, [q.id]: e.target.value }))} placeholder="Your answer" />
          )}
          {q.question_type === 'rating' && (
            <div className="flex gap-2">
              {[1,2,3,4,5].map(n => (
                <Button key={n} variant={answers[q.id] === String(n) ? 'default' : 'outline'} size="sm" onClick={() => setAnswers(p => ({ ...p, [q.id]: String(n) }))}>{n}</Button>
              ))}
            </div>
          )}
          {q.question_type === 'boolean' && (
            <div className="flex gap-4">
              {['Yes','No'].map(v => (
                <Button key={v} variant={answers[q.id] === v ? 'default' : 'outline'} onClick={() => setAnswers(p => ({ ...p, [q.id]: v }))}>{v}</Button>
              ))}
            </div>
          )}
        </CardContent>
      </Card>
      <div className="flex justify-between">
        <Button variant="ghost" onClick={() => setCurrent(p => Math.max(0, p - 1))} disabled={current === 0}>Back</Button>
        {current < questions.length - 1
          ? <Button onClick={() => setCurrent(p => p + 1)} disabled={!answers[q.id]}>Next</Button>
          : <Button onClick={submit} disabled={!answers[q.id]}>Submit</Button>}
      </div>
    </div>
  )
}
```

> 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.

### 5. 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.

```
// src/pages/SurveyResults.tsx (key section)
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'

export function SurveyResults() {
  const { id } = useParams<{ id: string }>()
  const [questions, setQuestions] = useState<any[]>([])
  const [responses, setResponses] = useState<any[]>([])

  useEffect(() => {
    Promise.all([
      supabase.from('questions').select('*').eq('survey_id', id).order('position'),
      supabase.from('responses').select('answers').eq('survey_id', id)
    ]).then(([q, r]) => {
      setQuestions(q.data ?? [])
      setResponses(r.data ?? [])
    })
  }, [id])

  function aggregate(qId: string, type: string, options?: string[]) {
    const vals = responses.map(r => r.answers[qId]).filter(Boolean)
    if (type === 'text') return vals
    const counts: Record<string, number> = {}
    vals.forEach(v => { counts[v] = (counts[v] ?? 0) + 1 })
    return Object.entries(counts).map(([name, value]) => ({ name, value }))
  }

  return (
    <div className="p-6 space-y-6">
      <h1 className="text-2xl font-bold">Results ({responses.length} responses)</h1>
      {questions.map(q => {
        const data = aggregate(q.id, q.question_type, q.options)
        return (
          <Card key={q.id}>
            <CardHeader><CardTitle>{q.question_text}</CardTitle></CardHeader>
            <CardContent>
              {q.question_type === 'text'
                ? <ul className="space-y-1">{(data as string[]).map((t, i) => <li key={i} className="text-sm border rounded p-2">{t}</li>)}</ul>
                : <ResponsiveContainer width="100%" height={200}>
                    <BarChart data={data as any[]}>
                      <XAxis dataKey="name" /><YAxis /><Tooltip />
                      <Bar dataKey="value" className="fill-primary" />
                    </BarChart>
                  </ResponsiveContainer>
              }
            </CardContent>
          </Card>
        )
      })}
    </div>
  )
}
```

> 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 example

File: `src/components/QuestionDialog.tsx`

```typescript
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'

type QType = 'multiple_choice' | 'text' | 'rating' | 'boolean'
type Props = { surveyId: string; open: boolean; onClose: () => void; onSaved: () => void }

export function QuestionDialog({ surveyId, open, onClose, onSaved }: Props) {
  const [text, setText] = useState('')
  const [type, setType] = useState<QType>('multiple_choice')
  const [options, setOptions] = useState<string[]>(['', ''])
  const [saving, setSaving] = useState(false)

  async function save() {
    if (!text.trim()) { toast.error('Question text is required'); return }
    if (type === 'multiple_choice' && options.filter(Boolean).length < 2) { toast.error('Need at least 2 options'); return }
    setSaving(true)
    const { error } = await supabase.from('questions').insert({
      survey_id: surveyId, question_text: text.trim(), question_type: type,
      options: type === 'multiple_choice' ? options.filter(Boolean) : null, required: true
    })
    setSaving(false)
    if (error) { toast.error('Failed to save question'); return }
    toast.success('Question added')
    setText(''); setType('multiple_choice'); setOptions(['', '']); onSaved(); onClose()
  }

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>Add Question</DialogTitle></DialogHeader>
        <div className="space-y-4 py-2">
          <div className="space-y-1">
            <Label>Question text</Label>
            <Input value={text} onChange={e => setText(e.target.value)} placeholder="What would you like to ask?" />
          </div>
          <div className="space-y-1">
            <Label>Answer type</Label>
            <Select value={type} onValueChange={v => setType(v as QType)}>
              <SelectTrigger><SelectValue /></SelectTrigger>
              <SelectContent>
                {(['multiple_choice','text','rating','boolean'] as QType[]).map(t => (
                  <SelectItem key={t} value={t}>{t.replace('_',' ')}</SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          {type === 'multiple_choice' && (
            <div className="space-y-2">
              <Label>Options</Label>
              {options.map((opt, i) => (
                <div key={i} className="flex gap-2">
                  <Input value={opt} onChange={e => setOptions(p => p.map((o,j) => j===i ? e.target.value : o))} placeholder={"Option " + (i+1)} />
                  {options.length > 2 && <Button variant="ghost" size="icon" onClick={() => setOptions(p => p.filter((_,j) => j !== i))}><X className="h-4 w-4" /></Button>}
                </div>
              ))}
              <Button variant="outline" size="sm" onClick={() => setOptions(p => [...p, ''])}>Add option</Button>
            </div>
          )}
          {type === 'rating' && (
            <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>
          )}
        </div>
        <DialogFooter>
          <Button variant="ghost" onClick={onClose}>Cancel</Button>
          <Button onClick={save} disabled={saving}>{saving ? 'Saving...' : 'Save Question'}</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

## Common mistakes

- **Not setting position on questions when inserting** — Without position values the ORDER BY position clause returns questions in an unpredictable order, so respondents may see them shuffled. Fix: When inserting a new question, set position to the current count of questions for that survey: SELECT COUNT(*) FROM questions WHERE survey_id = $1.
- **Treating the answers JSONB as a flat array** — If you insert answers as an array instead of an object keyed by question ID, aggregating per-question results in the results dashboard becomes very difficult. Fix: Always insert answers as { "[question_id]": "answer_value" } so you can do responses.map(r => r.answers[questionId]) to aggregate cleanly.
- **Forgetting the anon insert policy on the responses table** — RLS is enabled by default once you turn it on, blocking all anonymous inserts. Public respondents get a silent permission error. Fix: Run: CREATE POLICY "anon_insert_responses" ON public.responses FOR INSERT TO anon WITH CHECK (true); in Supabase SQL Editor.
- **Linking to /survey/[id] before publishing the Lovable app** — The Lovable preview URL is an iframe and behaves differently from the published URL — OAuth flows and some cookies do not work correctly. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/polls-and-surveys
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/polls-and-surveys
