# How to Build Polls and surveys with V0

- Tool: v0
- Difficulty: Beginner
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a polls and surveys app with V0 using Next.js and Supabase that lets you create questions, share via link, and see real-time results as votes come in. Features multiple question types, duplicate vote prevention, and live result visualization — all in about 30-60 minutes.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- An idea for your first poll or survey to test with

## Step-by-step guide

### 1. Set up the project and database schema

Open V0 and create a new project. Use the Connect panel to add Supabase — this auto-provisions your database credentials. Then prompt V0 to create the schema for surveys, questions, and responses.

```
// Paste this prompt into V0's AI chat:
// Build a polls and surveys app. Create a Supabase schema with these tables:
// 1. surveys: id (uuid PK), creator_id (uuid FK to auth.users), title (text), description (text), type (text check poll/survey), is_published (boolean default false), closes_at (timestamptz), created_at (timestamptz)
// 2. questions: id (uuid PK), survey_id (uuid FK), question_text (text), question_type (text check single/multiple/text/rating), options (jsonb), position (integer), created_at (timestamptz)
// 3. responses: id (uuid PK), survey_id (uuid FK), question_id (uuid FK), respondent_id (uuid), answer (jsonb), created_at (timestamptz) with unique constraint on (question_id, respondent_id)
// Add RLS: anyone can INSERT responses on published surveys, only creator can SELECT all responses.
// Generate SQL migration and TypeScript types.
```

> Pro tip: Use V0's Design Mode (Option+D) after generating the survey form to visually adjust spacing, font sizes, and colors for free — no credits spent.

**Expected result:** Supabase is connected via the Connect panel with surveys, questions, and responses tables created. RLS policies allow public response submission but restrict result viewing to the survey creator.

### 2. Build the survey creation form

Prompt V0 to generate a survey builder page where users add questions with different types. Each question type gets a different input component — RadioGroup for single-choice, Checkbox for multiple-choice, Textarea for text, and a star rating component.

```
// Paste this prompt into V0's AI chat:
// Build a survey builder at app/surveys/[id]/page.tsx.
// Requirements:
// - Fetch the survey and its questions from Supabase
// - Display each question in a shadcn/ui Card with the question text and type Badge
// - "Add Question" Button opens a Dialog with: Input for question text, Select for question_type (single/multiple/text/rating), dynamic options editor for choice questions (add/remove option inputs)
// - Questions are reorderable by position number
// - "Publish" Button with Switch toggle sets is_published to true via Server Action
// - Use Separator between questions
// - Server Actions: createSurvey(), addQuestion(), publishSurvey()
// - Server Components for data fetching, 'use client' for the interactive form elements
```

**Expected result:** A survey builder page where you add questions of different types, configure options for choice questions, reorder them, and publish the survey with a single toggle.

### 3. Create the public response form

Build the page respondents see when they open a shared survey link. This must work without authentication — anyone with the link can respond. The form renders different input components based on question type and prevents duplicate submissions.

```
'use client'

import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { submitResponse } from '@/app/actions/surveys'

interface Question {
  id: string
  question_text: string
  question_type: 'single' | 'multiple' | 'text' | 'rating'
  options: string[] | null
}

export function SurveyResponseForm({
  surveyId,
  questions,
}: {
  surveyId: string
  questions: Question[]
}) {
  const [answers, setAnswers] = useState<Record<string, unknown>>({})
  const [respondentId, setRespondentId] = useState<string>('')
  const [submitted, setSubmitted] = useState(false)

  useEffect(() => {
    const stored = localStorage.getItem(`survey-${surveyId}-respondent`)
    if (stored) {
      setSubmitted(true)
      return
    }
    const id = crypto.randomUUID()
    setRespondentId(id)
  }, [surveyId])

  async function handleSubmit() {
    for (const question of questions) {
      await submitResponse({
        survey_id: surveyId,
        question_id: question.id,
        respondent_id: respondentId,
        answer: answers[question.id],
      })
    }
    localStorage.setItem(`survey-${surveyId}-respondent`, respondentId)
    setSubmitted(true)
  }

  if (submitted) return <p>Thank you for your response!</p>

  return (
    <div className="space-y-6">
      {questions.map((q) => (
        <Card key={q.id} className="p-6">
          <Label className="text-lg font-medium">{q.question_text}</Label>
          {q.question_type === 'single' && q.options && (
            <RadioGroup onValueChange={(v) => setAnswers({ ...answers, [q.id]: v })}>
              {q.options.map((opt) => (
                <div key={opt} className="flex items-center space-x-2">
                  <RadioGroupItem value={opt} id={`${q.id}-${opt}`} />
                  <Label htmlFor={`${q.id}-${opt}`}>{opt}</Label>
                </div>
              ))}
            </RadioGroup>
          )}
          {q.question_type === 'text' && (
            <Textarea
              onChange={(e) => setAnswers({ ...answers, [q.id]: e.target.value })}
              placeholder="Type your answer..."
            />
          )}
        </Card>
      ))}
      <Button onClick={handleSubmit} size="lg">Submit Response</Button>
    </div>
  )
}
```

**Expected result:** A public form at /surveys/[id]/respond that renders each question with the appropriate input type. After submission, localStorage prevents duplicate votes and shows a thank-you message.

### 4. Build the live results page with progress bars

Create a results page that shows vote counts and percentages for each question. For choice questions, display results as Progress bars. The page uses Server Components for initial data and can be refreshed to show updated results.

```
import { createClient } from '@/lib/supabase/server'
import { Card } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'

export default async function ResultsPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const supabase = await createClient()

  const { data: questions } = await supabase
    .from('questions')
    .select('id, question_text, question_type, options')
    .eq('survey_id', id)
    .order('position')

  const { data: responses } = await supabase
    .from('responses')
    .select('question_id, answer')
    .eq('survey_id', id)

  return (
    <div className="max-w-2xl mx-auto p-6 space-y-8">
      {questions?.map((q) => {
        const qResponses = responses?.filter((r) => r.question_id === q.id) ?? []
        const total = qResponses.length

        return (
          <Card key={q.id} className="p-6">
            <h3 className="text-lg font-semibold mb-1">{q.question_text}</h3>
            <Badge variant="outline" className="mb-4">{total} responses</Badge>
            {q.question_type === 'single' && q.options?.map((opt: string) => {
              const count = qResponses.filter((r) => r.answer === opt).length
              const pct = total > 0 ? Math.round((count / total) * 100) : 0
              return (
                <div key={opt} className="mb-3">
                  <div className="flex justify-between text-sm mb-1">
                    <span>{opt}</span>
                    <span>{pct}% ({count})</span>
                  </div>
                  <Progress value={pct} />
                </div>
              )
            })}
            <Separator className="mt-4" />
          </Card>
        )
      })}
    </div>
  )
}
```

> Pro tip: Use V0's Design Mode to visually adjust the Progress bar colors and Card spacing to match your brand — completely free, no credits consumed.

**Expected result:** The results page shows each question with vote counts and Progress bars. Single-choice questions display option-by-option percentages. The page renders with Server Components for fast initial load.

## Complete code example

File: `app/actions/surveys.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createSurvey(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  const { data, error } = await supabase
    .from('surveys')
    .insert({
      creator_id: user?.id,
      title: formData.get('title') as string,
      description: formData.get('description') as string,
      type: formData.get('type') as string,
    })
    .select()
    .single()

  if (error) throw new Error(error.message)
  revalidatePath('/surveys')
  return data
}

export async function submitResponse(input: {
  survey_id: string
  question_id: string
  respondent_id: string
  answer: unknown
}) {
  const supabase = await createClient()

  const { error } = await supabase.from('responses').insert({
    survey_id: input.survey_id,
    question_id: input.question_id,
    respondent_id: input.respondent_id,
    answer: input.answer,
  })

  if (error && error.code !== '23505') {
    throw new Error(error.message)
  }

  revalidatePath(`/surveys/${input.survey_id}/results`)
}
```

## Common mistakes

- **Accessing localStorage directly in a Server Component to check for duplicate votes** — Server Components run on the server where localStorage does not exist, causing a ReferenceError at build time. Fix: Wrap the duplicate check in a 'use client' component and access localStorage inside useEffect, which only runs in the browser.
- **Not adding a unique constraint on (question_id, respondent_id) for vote deduplication** — Without the constraint, rapid double-clicks or network retries can insert duplicate votes. Fix: Add a unique constraint in your Supabase schema and handle the 23505 duplicate key error gracefully in your Server Action.
- **Requiring authentication for respondents to submit votes** — Most polls and surveys need maximum participation. Requiring sign-up dramatically reduces response rates. Fix: Keep the response form public with no auth required. Use anonymous fingerprinting (UUID in localStorage) for duplicate prevention instead.

## Best practices

- Use Server Actions for all mutations (creating surveys, submitting responses) — no API routes needed for this project
- Use V0's Design Mode (Option+D) to visually adjust the survey form layout and result charts without spending credits
- Set RLS policies that allow public INSERT on responses but restrict SELECT to the survey creator
- Handle the Supabase 23505 duplicate key error gracefully to prevent duplicate vote error messages
- Use shadcn/ui Skeleton components while loading results to provide visual feedback during data fetching
- Store the respondent UUID in localStorage only after successful submission to prevent premature blocking

## Frequently asked questions

### Can I build this poll app on V0's free tier?

Yes. The free tier gives you enough credits to generate the survey builder, response form, and results page. Supabase free tier handles the database. You only need a paid plan if you want more complex features or faster generation.

### How do I prevent people from voting multiple times?

The build uses anonymous fingerprinting — a UUID stored in localStorage when a user submits their first response. Combined with a unique constraint on (question_id, respondent_id) in Supabase, this prevents duplicate votes without requiring login.

### Can I share surveys publicly without requiring respondents to sign up?

Yes. The response form at /surveys/[id]/respond is a public page with no authentication required. RLS policies allow anyone to INSERT responses on published surveys while restricting result viewing to the survey creator.

### How do I see results update in real time?

The base build uses Server Components that show results on page load. For live updates, add Supabase Realtime subscriptions in a 'use client' component that listens for new inserts on the responses table and updates the UI immediately.

### How do I deploy my polls app to production?

Click Share then Publish to Production in V0 — it deploys to Vercel in 30-60 seconds. Your Supabase credentials are automatically configured from the Connect panel. Share the survey URL from your Vercel domain.

### Can RapidDev help build a custom polls and surveys platform?

Yes. RapidDev has built 600+ apps including survey platforms with advanced features like conditional logic, branching, and analytics dashboards. Book a free consultation to discuss your specific requirements.

---

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