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.
What you're building
Polls and surveys are essential for gathering feedback, validating ideas, and engaging audiences. Whether you are running a product survey, a team vote, or a community poll, you need a tool that is easy to create, simple to share, and shows results instantly.
V0 makes building this straightforward — describe the survey builder and response form in chat, and V0 generates the Next.js pages, form components, and Server Actions. Connect Supabase via the Connect panel for instant database provisioning, and your poll app is live in under an hour.
The architecture uses Next.js App Router with Server Components for the survey builder and results pages, a client component for the interactive response form, Server Actions for all mutations, and Supabase for storing surveys, questions, and responses with RLS policies for access control.
Final result
A complete polls and surveys application with a drag-and-drop question builder, public shareable response forms, real-time result visualization with progress bars, and duplicate vote prevention.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a polls and surveys app. Create a Supabase schema with these tables:3// 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)4// 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)5// 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)6// Add RLS: anyone can INSERT responses on published surveys, only creator can SELECT all responses.7// 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.
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.
1// Paste this prompt into V0's AI chat:2// Build a survey builder at app/surveys/[id]/page.tsx.3// Requirements:4// - Fetch the survey and its questions from Supabase5// - Display each question in a shadcn/ui Card with the question text and type Badge6// - "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)7// - Questions are reorderable by position number8// - "Publish" Button with Switch toggle sets is_published to true via Server Action9// - Use Separator between questions10// - Server Actions: createSurvey(), addQuestion(), publishSurvey()11// - Server Components for data fetching, 'use client' for the interactive form elementsExpected 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.
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.
1'use client'23import { useState, useEffect } from 'react'4import { Button } from '@/components/ui/button'5import { Card } from '@/components/ui/card'6import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'7import { Checkbox } from '@/components/ui/checkbox'8import { Textarea } from '@/components/ui/textarea'9import { Label } from '@/components/ui/label'10import { submitResponse } from '@/app/actions/surveys'1112interface Question {13 id: string14 question_text: string15 question_type: 'single' | 'multiple' | 'text' | 'rating'16 options: string[] | null17}1819export function SurveyResponseForm({20 surveyId,21 questions,22}: {23 surveyId: string24 questions: Question[]25}) {26 const [answers, setAnswers] = useState<Record<string, unknown>>({})27 const [respondentId, setRespondentId] = useState<string>('')28 const [submitted, setSubmitted] = useState(false)2930 useEffect(() => {31 const stored = localStorage.getItem(`survey-${surveyId}-respondent`)32 if (stored) {33 setSubmitted(true)34 return35 }36 const id = crypto.randomUUID()37 setRespondentId(id)38 }, [surveyId])3940 async function handleSubmit() {41 for (const question of questions) {42 await submitResponse({43 survey_id: surveyId,44 question_id: question.id,45 respondent_id: respondentId,46 answer: answers[question.id],47 })48 }49 localStorage.setItem(`survey-${surveyId}-respondent`, respondentId)50 setSubmitted(true)51 }5253 if (submitted) return <p>Thank you for your response!</p>5455 return (56 <div className="space-y-6">57 {questions.map((q) => (58 <Card key={q.id} className="p-6">59 <Label className="text-lg font-medium">{q.question_text}</Label>60 {q.question_type === 'single' && q.options && (61 <RadioGroup onValueChange={(v) => setAnswers({ ...answers, [q.id]: v })}>62 {q.options.map((opt) => (63 <div key={opt} className="flex items-center space-x-2">64 <RadioGroupItem value={opt} id={`${q.id}-${opt}`} />65 <Label htmlFor={`${q.id}-${opt}`}>{opt}</Label>66 </div>67 ))}68 </RadioGroup>69 )}70 {q.question_type === 'text' && (71 <Textarea72 onChange={(e) => setAnswers({ ...answers, [q.id]: e.target.value })}73 placeholder="Type your answer..."74 />75 )}76 </Card>77 ))}78 <Button onClick={handleSubmit} size="lg">Submit Response</Button>79 </div>80 )81}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.
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.
1import { createClient } from '@/lib/supabase/server'2import { Card } from '@/components/ui/card'3import { Progress } from '@/components/ui/progress'4import { Badge } from '@/components/ui/badge'5import { Separator } from '@/components/ui/separator'67export default async function ResultsPage({8 params,9}: {10 params: Promise<{ id: string }>11}) {12 const { id } = await params13 const supabase = await createClient()1415 const { data: questions } = await supabase16 .from('questions')17 .select('id, question_text, question_type, options')18 .eq('survey_id', id)19 .order('position')2021 const { data: responses } = await supabase22 .from('responses')23 .select('question_id, answer')24 .eq('survey_id', id)2526 return (27 <div className="max-w-2xl mx-auto p-6 space-y-8">28 {questions?.map((q) => {29 const qResponses = responses?.filter((r) => r.question_id === q.id) ?? []30 const total = qResponses.length3132 return (33 <Card key={q.id} className="p-6">34 <h3 className="text-lg font-semibold mb-1">{q.question_text}</h3>35 <Badge variant="outline" className="mb-4">{total} responses</Badge>36 {q.question_type === 'single' && q.options?.map((opt: string) => {37 const count = qResponses.filter((r) => r.answer === opt).length38 const pct = total > 0 ? Math.round((count / total) * 100) : 039 return (40 <div key={opt} className="mb-3">41 <div className="flex justify-between text-sm mb-1">42 <span>{opt}</span>43 <span>{pct}% ({count})</span>44 </div>45 <Progress value={pct} />46 </div>47 )48 })}49 <Separator className="mt-4" />50 </Card>51 )52 })}53 </div>54 )55}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
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'56export async function createSurvey(formData: FormData) {7 const supabase = await createClient()8 const { data: { user } } = await supabase.auth.getUser()910 const { data, error } = await supabase11 .from('surveys')12 .insert({13 creator_id: user?.id,14 title: formData.get('title') as string,15 description: formData.get('description') as string,16 type: formData.get('type') as string,17 })18 .select()19 .single()2021 if (error) throw new Error(error.message)22 revalidatePath('/surveys')23 return data24}2526export async function submitResponse(input: {27 survey_id: string28 question_id: string29 respondent_id: string30 answer: unknown31}) {32 const supabase = await createClient()3334 const { error } = await supabase.from('responses').insert({35 survey_id: input.survey_id,36 question_id: input.question_id,37 respondent_id: input.respondent_id,38 answer: input.answer,39 })4041 if (error && error.code !== '23505') {42 throw new Error(error.message)43 }4445 revalidatePath(`/surveys/${input.survey_id}/results`)46}Customization ideas
Add Supabase Realtime for live results
Subscribe to the responses table using Supabase Realtime so the results page updates instantly as new votes come in, without manual page refresh.
Add survey expiration with countdown
Use the closes_at field to automatically disable the response form when the deadline passes, showing a countdown timer component until closure.
Add CSV export for responses
Create a Server Action that queries all responses for a survey and generates a downloadable CSV file for analysis in spreadsheet tools.
Add conditional logic between questions
Implement skip logic where answering a specific option on one question shows or hides follow-up questions, creating branching survey flows.
Common pitfalls
Pitfall: Accessing localStorage directly in a Server Component to check for duplicate votes
How to avoid: Wrap the duplicate check in a 'use client' component and access localStorage inside useEffect, which only runs in the browser.
Pitfall: Not adding a unique constraint on (question_id, respondent_id) for vote deduplication
How to avoid: Add a unique constraint in your Supabase schema and handle the 23505 duplicate key error gracefully in your Server Action.
Pitfall: Requiring authentication for respondents to submit votes
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a polls and surveys app with Next.js App Router and Supabase. Help me design the database schema for supporting single-choice, multiple-choice, open text, and rating question types. I need to prevent duplicate votes from the same person without requiring login. How should I structure the responses table and what constraints should I add?
Create a real-time poll results component. Use Supabase Realtime to subscribe to new responses and update vote counts live. Show each option as a shadcn/ui Progress bar with percentage labels. Include a Badge showing total response count. The component should be 'use client' with useEffect for the Realtime subscription.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation