# How to Add User Polling with Anonymous Voting to Your App (Copy-Paste Prompts)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Anonymous polls need four pieces: a poll creation form, an anonymous voter token (UUID SHA-256 hashed before DB storage), a Supabase unique constraint that blocks duplicate votes without exposing identity, and a live results view via Supabase Realtime. With Lovable or V0, ship a working poll in 4-8 hours for $0/month on the free tier. AI tools often skip the hashing step — that is the one piece always worth reviewing after generation.

## What an Anonymous Polling Feature Actually Requires

Polling with anonymous voting sounds simple but has one genuinely hard problem: how do you prevent the same person from voting twice without knowing who they are? The answer is a voter token — a UUID generated on the client, stored in localStorage (web) or AsyncStorage (mobile), and SHA-256 hashed before being saved to the database. The unique constraint on (poll_id, voter_token_hash) blocks duplicate votes at the database level without ever storing a raw identity. The rest of the feature — poll creation form, live results chart, expiry handling, and creator controls — is straightforward with AI tools and Supabase Realtime.

## Anatomy of the Feature

Six components. The poll form and live results generate reliably with AI tools. The anonymous vote token and dedup constraint are where first builds typically break — AI omits the hashing step by default.

- **Poll creation form** (ui): shadcn/ui Form with react-hook-form managing fields: question (text), options (dynamic list with min 2 / max 10 inputs), expiry datetime picker, and an anonymous toggle. Zod validation schema enforces question max 280 characters, minimum 2 options, and expiry at least 5 minutes in the future.
- **Anonymous voter token** (backend): On a user's first visit to any poll, generate a UUID using crypto.randomUUID() and store it in localStorage (web) or AsyncStorage (React Native/FlutterFlow). Before inserting a vote, hash the raw token with the Web Crypto API (SHA-256) and store only the hash — never the raw UUID. This means the DB cannot be used to link votes back to a device even if the votes table is exposed.
- **Vote recording** (backend): Supabase RLS policy allows INSERT on the votes table only when no existing row matches (poll_id, voter_token_hash) — enforced by a unique constraint at the database level. The policy also checks that the poll's expires_at is in the future and is_closed is false, preventing late votes without requiring client-side validation alone.
- **Live results** (backend): Supabase Realtime subscription on the votes table (filtered by poll_id) triggers a React state update that recalculates vote counts per option and re-renders a Recharts BarChart or shadcn/ui Progress bars. The subscription uses Postgres Changes (not Broadcast) so late-joining viewers see current totals immediately on subscribe.
- **Poll expiry guard** (backend): Client-side: a useEffect interval computes time remaining from poll.expires_at and disables the vote button when expired. Server-side: the Supabase RLS INSERT policy includes a check (polls.expires_at > now()) so the constraint holds even if a client bypasses the UI.
- **Admin controls** (ui): Creator-only dashboard showing total votes, breakdown per option, close-poll button, and a CSV export of vote counts (not voter identities). Protected by Supabase RLS checking that auth.uid() = polls.creator_id. CSV export runs a SELECT COUNT(*) GROUP BY option_index query via a Supabase RPC function.

## Data model

Two tables: polls stores the question and metadata, votes stores one hashed-token row per vote. Run this in the Supabase SQL Editor (Dashboard → SQL Editor):

```sql
create table public.polls (
  id uuid primary key default gen_random_uuid(),
  creator_id uuid references auth.users(id) on delete cascade,
  question text not null check (length(question) <= 280),
  options jsonb not null,
  expires_at timestamptz not null,
  is_anonymous boolean not null default true,
  is_closed boolean not null default false,
  created_at timestamptz not null default now()
);

create table public.votes (
  id uuid primary key default gen_random_uuid(),
  poll_id uuid references public.polls(id) on delete cascade not null,
  option_index integer not null,
  voter_token_hash text not null,
  created_at timestamptz not null default now(),
  constraint votes_poll_voter_unique unique (poll_id, voter_token_hash)
);

alter table public.polls enable row level security;
alter table public.votes enable row level security;

create policy "Anyone can read active polls"
  on public.polls for select
  using (true);

create policy "Authenticated users can create polls"
  on public.polls for insert
  with check (auth.uid() = creator_id);

create policy "Creator can update own poll"
  on public.polls for update
  using (auth.uid() = creator_id)
  with check (auth.uid() = creator_id);

create policy "Creator can delete own poll"
  on public.polls for delete
  using (auth.uid() = creator_id);

create policy "Anyone can insert a vote if poll is open"
  on public.votes for insert
  with check (
    exists (
      select 1 from public.polls
      where id = poll_id
        and is_closed = false
        and expires_at > now()
    )
  );

create policy "Anyone can read votes (for results)"
  on public.votes for select
  using (true);

create policy "Creator can read own poll votes"
  on public.votes for select
  using (
    exists (
      select 1 from public.polls
      where id = poll_id
        and creator_id = auth.uid()
    )
  );

create index votes_poll_id_idx
  on public.votes (poll_id, option_index);

create index polls_expires_at_idx
  on public.polls (expires_at) where is_closed = false;
```

The unique constraint votes_poll_voter_unique is the dedup backbone. The options column is a JSONB array of strings (e.g., ['Option A', 'Option B']) — option_index in votes references the array position, keeping the votes table simple and the query fast.

## Build paths

### Lovable — fit 4/10, 4-6 hours

Strong fit — Supabase Realtime and RLS are Lovable's home turf. The poll form, live results, and dedup constraint generate well. The anonymous token SHA-256 hashing is the one area that needs manual review after generation.

1. Create a Lovable project with Lovable Cloud enabled — Supabase auth and the polls schema will be provisioned for you
2. Paste the prompt below; Lovable will generate the poll creation form, vote button, live results component, and creator dashboard in one pass
3. After generation, find the vote submission function and verify it calls crypto.subtle.digest('SHA-256', ...) on the raw token before the Supabase insert; add the hashing step if missing
4. Open the Supabase Cloud tab in Lovable and confirm the unique constraint on (poll_id, voter_token_hash) was created in the SQL migration
5. Publish and test: vote once, refresh the page, attempt to vote again — the second attempt should be blocked

Starter prompt:

```
Build an anonymous polling feature. Polls table: id, creator_id, question (text, max 280 chars), options (jsonb array of strings, min 2 max 10), expires_at (timestamptz), is_anonymous (boolean default true), is_closed (boolean default false), created_at. Votes table: id, poll_id, option_index (integer), voter_token_hash (text), created_at. Unique constraint on (poll_id, voter_token_hash). RLS: anyone can INSERT a vote when poll is open and not expired (expires_at > now() AND is_closed = false); anyone can SELECT votes for results; only creator can UPDATE/DELETE their poll. Anonymous voter token logic: on first page load, call crypto.randomUUID() and store in localStorage under key 'anon_voter_token'. Before vote INSERT, hash the raw token with Web Crypto API: const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(rawToken)); convert to hex string. Send the hex hash to Supabase, never the raw token. UI states for a poll: pre-vote (options as radio buttons, 'Vote' button, expiry countdown), post-vote (results as percentage bars using shadcn/ui Progress, 'You voted for: [option]' label), closed/expired (results only, no vote button, 'Poll closed' banner). Live results: Supabase Realtime Postgres Changes subscription on votes table filtered by poll_id; recalculate percentages on every change event. Guard totalVotes === 0 to render 0% not NaN. Poll creation form: shadcn/ui Form with react-hook-form + zod; dynamic option inputs (add/remove); datetime picker for expiry. Creator dashboard: shows total vote count per option, 'Close poll early' button that sets is_closed = true, CSV export of option labels + vote counts. Show an 'Anonymous' badge on the voting UI. Disable vote button and show countdown when expires_at < now().
```

Limitations:

- Lovable may generate localStorage token storage without the SHA-256 hashing step — always review the vote submission function before publishing
- Incognito browsers get a new localStorage token and can technically vote again — acceptable for most use cases but not high-stakes polls
- Realtime Postgres Changes subscriptions count against Supabase concurrent connection limits; upgrade to Pro if you have many simultaneous poll viewers

### V0 — fit 4/10, 5-8 hours

Excellent component output for the polling UI. Next.js Server Actions keep the voter token server-side during submission. Supabase Realtime subscription for live results needs a 'use client' wrapper which V0 usually generates correctly.

1. Prompt V0 with the spec below; it will generate the PollVote client component and PollResults client component with the Realtime subscription
2. Open the Vars panel in V0 and add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL from af_data_model in your Supabase SQL Editor to create the polls and votes tables with the unique constraint
4. Verify the generated code hashes the voter token before submission — check the vote handler for crypto.subtle.digest usage
5. Deploy to production and test duplicate vote blocking: vote once, clear localStorage, vote again — the DB unique constraint should block the second vote even with a fresh token if the same hash cannot re-appear

Starter prompt:

```
Build an anonymous polling feature in Next.js. Component 1 (use client): PollVote — displays a poll's question and options as radio buttons; on mount, reads or creates an anonymous voter token from localStorage using crypto.randomUUID(); on vote submit, hashes the token with Web Crypto API (SHA-256 hex string) and inserts into Supabase votes table via a Server Action; handles duplicate vote error (unique constraint violation) by showing 'You already voted' UI; shows expiry countdown from poll.expires_at; disables vote button when expired or is_closed. State machine: 'voting' (radio options), 'voted' (show which option you chose + results), 'expired' (results only). Component 2 (use client): PollResults — subscribes to Supabase Realtime Postgres Changes on votes table filtered by poll_id=eq.{id}; renders a bar chart of vote percentages using shadcn/ui Progress components; guards totalVotes === 0 to show 0% not NaN; shows total vote count. Component 3: PollCreator (server component page) with a client-side form using react-hook-form + zod: question field (max 280 chars), dynamic options list (2-10), datetime picker for expiry at least 5 minutes from now, is_anonymous toggle. Server Action for poll creation inserts to Supabase polls table with creator_id = auth.uid(). Creator dashboard page: shows vote counts per option, close poll button (sets is_closed = true), CSV download of results. Anonymous badge displayed on PollVote when poll.is_anonymous = true. Supabase env vars from process.env.
```

Limitations:

- V0 won't auto-provision the Supabase schema — run the SQL manually in Supabase SQL Editor
- The Realtime subscription must be in a 'use client' component — if V0 places PollResults in a Server Component, move it to a client component manually
- localStorage is not available during SSR; the voter token generation must be inside a useEffect or a 'use client' component

### Flutterflow — fit 3/10, 1-2 days

Firebase or Supabase as the backend; FlutterFlow's custom widget system renders a bar chart for results. Anonymous token stored in SharedPreferences. Live updates need a custom Dart action for Realtime.

1. Set up a Firebase Firestore backend with collections: polls and votes; create the equivalent dedup logic using Firestore security rules to block duplicate (pollId + voterToken) combinations
2. In FlutterFlow, create the poll display page with option radio buttons; add a Custom Action that reads or generates the anonymous token from SharedPreferences using the flutter_secure_storage package for persistence
3. Wire a Firestore Write action on vote submit that includes the hashed token value; Firestore security rules enforce the unique constraint via a read-then-write check
4. Add a StreamBuilder custom widget (or use FlutterFlow's built-in Firestore stream query) to display live vote count updates
5. For the results chart, add a third-party FlutterFlow marketplace widget (fl_chart) to render a horizontal bar chart of percentages

Limitations:

- Realtime live vote count updates in FlutterFlow require a custom Dart action or a Firestore stream query widget — the built-in query widgets don't auto-refresh on remote changes without polling
- SHA-256 hashing of the voter token requires a custom Dart action using the crypto package — FlutterFlow does not expose crypto primitives natively
- FlutterFlow SharedPreferences is wiped on app update on some Android OEM builds; use flutter_secure_storage for the voter token to persist across updates

### Custom — fit 5/10, 3-5 days

Full control over fraud prevention, weighted voting, ranked-choice logic, and analytics integration. The right path when poll integrity is business-critical or when you need multi-choice and ranked voting variants.

1. Implement server-side voter fingerprinting combining hashed IP address + user-agent as a secondary dedup signal alongside the client token — raises the friction for motivated duplicate voters
2. Add IP-based rate limiting (one vote per IP per poll per hour) using Supabase Edge Function with Upstash Redis for the rate limit counter
3. Build ranked-choice tallying logic (Instant Runoff Voting) in a PostgreSQL stored procedure that runs on poll close
4. Integrate poll results with your analytics pipeline (PostHog, Mixpanel) to track conversion from view to vote and option selection patterns

Limitations:

- Even custom implementations cannot fully prevent determined duplicate voters without requiring login — anonymous voting has an inherent tradeoff between privacy and integrity

## Gotchas

- **Duplicate votes from incognito tabs or cleared localStorage** — The anonymous voter token lives in localStorage. Any user who opens an incognito window, clears browser storage, or uses a different device gets a fresh UUID that generates a new unique hash — allowing them to vote again. This is an inherent limitation of client-side anonymous tokens, not a bug. Fix: For casual polls, this is acceptable — add a server-side secondary signal (hashed IP + user-agent) as a soft dedup without exposing it to users. For high-stakes polls (product decisions, elections), require login instead of anonymous tokens. Document clearly in your UI that anonymous votes have soft dedup, not hard identity-based prevention.
- **Recharts renders NaN% when the poll has zero votes** — When a poll has just been created and has zero votes, the percentage calculation (0 / 0 * 100) produces NaN. Recharts renders this as 'NaN%' in the bar label and can throw a console error. This happens every time a new poll loads before any votes are cast. Fix: Add a guard before rendering: const pct = totalVotes === 0 ? 0 : (count / totalVotes) * 100. Initialize all option counts to 0 on component mount so the chart renders immediately with empty bars rather than waiting for the first Realtime event.
- **AI generates token storage without SHA-256 hashing** — Lovable and V0 both commonly generate the voter token as a raw UUID stored directly in the votes table. This means anyone with database access (including Supabase Dashboard users or a leaked service key) can correlate tokens with device fingerprints or browser storage to de-anonymize voters. Fix: After generation, locate the vote submission handler and confirm the code calls crypto.subtle.digest('SHA-256', ...) before the Supabase insert. If missing, add it: hash the raw UUID with Web Crypto API and insert only the hex string of the hash.
- **Creator RLS blocks them from reading vote results** — AI-generated RLS on the votes table often restricts SELECT to anonymous voters (no auth) but forgets to add a creator exception. This means the poll creator's admin dashboard returns zero results even though votes exist. Fix: Add an explicit RLS policy: SELECT allowed when auth.uid() = (SELECT creator_id FROM polls WHERE id = poll_id). This gives creators full read access to their poll's votes without exposing other polls' data.
- **FlutterFlow SharedPreferences voter token wiped on app update** — On some Android OEM builds (particularly Samsung and Huawei), SharedPreferences is cleared during an app update. The voter gets a new token on the next launch and can vote again on any previously voted poll. Fix: Use flutter_secure_storage instead of SharedPreferences for the voter token — it stores the value in the OS Keychain (iOS) or Android Keystore, which persists across app updates and restores from backup.

## Best practices

- Always hash the voter token with SHA-256 before inserting into the database — store the hex hash, never the raw UUID, to protect voter anonymity even from database administrators
- Enforce the unique constraint (poll_id, voter_token_hash) at the database level, not just in application code — a race condition on double-click can bypass client-side guards
- Show the 'Anonymous' badge prominently on the voting UI so users understand and trust the system before they vote
- Guard the Recharts chart against zero-vote division: initialize all option counts to 0 and guard (count / totalVotes * 100) with a totalVotes === 0 check
- Set an explicit maximum expiry window (30 days) and close polls automatically with a Supabase scheduled function — orphaned polls with thousands of rows accumulate fast
- Never expose voter_token_hash in the CSV export or any user-facing view — export only option labels and vote counts
- Add client-side optimistic update when a user votes — immediately show 'You voted for X' and the estimated result without waiting for the Realtime subscription to confirm, then reconcile with the actual DB result

## Frequently asked questions

### How do I stop the same person from voting twice if they are anonymous?

You generate a UUID on first visit using crypto.randomUUID() and store it in localStorage. Before every vote, SHA-256 hash the UUID and enforce a unique constraint on (poll_id, voter_token_hash) in Supabase. This blocks same-device duplicate votes without revealing who the voter is. Someone who clears their storage or uses incognito can vote again — for high-stakes polls, require account login instead.

### Can I show poll results before someone votes?

Yes — it's a UI design choice, not a technical constraint. Add a 'Show results before voting' toggle to the poll creation form stored as a column in the polls table. When true, render the results chart alongside the vote options. When false, show only the options and reveal results after the user votes or after the poll closes.

### What is the difference between anonymous and private voting?

Anonymous voting means the system cannot link a vote to a specific person — even the app owner cannot tell who voted for what. Private voting means the vote is associated with an authenticated user but not shown to other users publicly. Anonymous is achieved with hashed tokens and no login requirement. Private voting requires authentication and RLS policies that restrict SELECT to the poll creator.

### How do I export poll results to a spreadsheet?

Add a 'Download CSV' button in the creator dashboard that calls a Supabase RPC function: SELECT option_index, COUNT(*) as vote_count FROM votes WHERE poll_id = $1 GROUP BY option_index ORDER BY option_index. Join with the options JSONB array to get option labels. Convert to CSV in the browser using a simple array-to-CSV function and trigger a download with a Blob URL. Never include voter_token_hash in the export.

### Can I embed a poll inside a blog post or landing page?

Yes — build the poll as an iframe-embeddable component. Create a dedicated /polls/{id}/embed route that renders the voting UI without your app's navigation. Set the Content-Security-Policy to allow framing from allowed domains. The Supabase Realtime subscription works inside iframes as long as the page is served over HTTPS.

### How many options can a poll have?

The brief sets the practical limit at 10 options per poll — enforce this with a Zod validation constraint (z.array(z.string().min(1)).min(2).max(10)) on the creation form and a database CHECK constraint on the options JSONB column. Polls with more than 10 options have significantly lower completion rates; if you need more, consider a ranking or bracket format instead.

### Is it possible to add image options to a poll?

Yes. Change the options JSONB schema from an array of strings to an array of objects: [{ label: 'Option A', image_url: 'https://...' }]. Upload images to Supabase Storage during poll creation and store the signed URL in the image_url field. Render each option with the image above the label text in the voting UI.

### Can I schedule a poll to open and close automatically?

Yes. Add opens_at and expires_at columns to the polls table. The RLS INSERT policy checks opens_at <= now() AND expires_at > now(). Add a pg_cron job in Supabase to set is_closed = true for expired polls every minute. On the client, disable the vote button and show a countdown when the poll hasn't opened yet or when expires_at has passed.

---

Source: https://www.rapidevelopers.com/app-features/user-polling-with-anonymous-voting
© RapidDev — https://www.rapidevelopers.com/app-features/user-polling-with-anonymous-voting
