The "Menti-Killer." Instant polling with <50ms latency, military-grade anti-abuse, and cyber-organic aesthetics.
No sign-up. No paywall. Just decisions β made together, in real time.
π Live at poll-rooms.vercel.app β Frontend on Vercel Edge Β· Socket Server on Render Β· Database on Supabase
π± QR Code Modal β "Scan to Join"
One-tap QR generation for instant mobile participation. Point your camera β join the poll.
Split-Stack Design β The frontend lives on Vercel's edge network, the WebSocket server runs as an always-on process on Render, and Supabase provides managed PostgreSQL. They communicate via a clean
/broadcastHTTP bridge.
graph LR
subgraph Client ["π₯οΈ Browser"]
A["React 19 + Socket.io Client<br/>FingerprintJS Β· Framer Motion"]
end
subgraph Vercel ["βοΈ Vercel (Serverless)"]
B["Next.js 16 API Routes<br/>/api/polls/create<br/>/api/polls/[slug]<br/>/api/polls/[slug]/vote"]
end
subgraph Render ["π¨ Render (Always-On)"]
C["Node.js + Express<br/>Socket.io Server<br/>POST /broadcast<br/>GET /health"]
end
subgraph Supabase ["π Supabase"]
D["PostgreSQL<br/>polls Β· options Β· votes<br/>RPC: increment_vote_count<br/>Triggers Β· RLS"]
end
A -- "HTTPS Β· REST API" --> B
A -- "WSS Β· WebSocket" --> C
B -- "POST /broadcast<br/>{pollId, optionId, newCount}" --> C
B -- "Supabase SDK<br/>Queries + RPC" --> D
C -. "vote_update event<br/>β all clients in room" .-> A
style Client fill:#0a0a0a,stroke:#10b981,color:#f5f5f5
style Vercel fill:#0a0a0a,stroke:#f5f5f5,color:#f5f5f5
style Render fill:#0a0a0a,stroke:#46e3b7,color:#f5f5f5
style Supabase fill:#0a0a0a,stroke:#3fcf8e,color:#f5f5f5
The Vote Flow (4 steps, <50ms end-to-end):
| Step | What Happens | Where |
|---|---|---|
| 1 | User clicks an option β Optimistic UI update (bar moves instantly) | Client |
| 2 | POST /api/polls/[slug]/vote β validates poll, checks fingerprint, checks IP |
Vercel |
| 3 | INSERT INTO votes + RPC increment_vote_count (atomic) β POST /broadcast |
Vercel β Render |
| 4 | Socket.io emits vote_update to all clients in poll:{id} room |
Render β All Clients |
Custom Socket.io server separated from Next.js for true stateful WebSocket connections. Vercel's serverless functions are stateless β they can't hold open sockets. Our dedicated Node.js process on Render manages persistent room-based pub/sub, handles reconnections, and exposes a /health endpoint for uptime monitoring.
Dual-layer protection that creates serious friction for manipulation:
| Layer | Technology | What It Prevents | How It Works |
|---|---|---|---|
| 1. Device Fingerprinting | @fingerprintjs/fingerprintjs |
Same device voting twice | Generates a stable hash from 50+ browser signals (canvas, WebGL, screen, timezone). Stored per-poll in PostgreSQL with UNIQUE(poll_id, voter_fingerprint). Falls back to SHA-256 of manual browser properties if FingerprintJS fails. |
| 2. IP Rate Limiting | Sliding window (10 min) | Bot attacks, VPN hopping | Checks votes table for any vote from the same IP within the last 10 minutes. Returns 429 Too Many Requests with Retry-After header. Uses server-side UTC timestamps β never trusts the client clock. |
Toggle results_hidden on poll creation to hide all vote counts and bars until the poll expires. This eliminates the "Bandwagon Effect" β voters commit to their genuine preference without being swayed by the crowd. When the deadline hits, all results are revealed simultaneously.
Precise deadline control with custom-styled dark emerald calendar:
- Presets:
10 minΒ·1 hourΒ·24 hoursΒ·No Limit - Custom: Full
react-datepickerwith date + time selection, past-date rejection, and emerald-themed dark mode styling - Enforcement: Server-side expiration check on every vote β
new Date()on the server, never the client
When the clock hits zero, the winning option gets the full ceremony:
canvas-confettiburst π (triggered once viauseEffecton expiry detection)- Gold gradient vote bar (
linear-gradient(90deg, #eab308, #fbbf24, #fde68a)) - π Trophy icon + ambient glow effect
- Haptic click sound via
use-sound
- QR Code Modal β
react-qr-coderenders the poll URL for instant mobile scanning - Native Share Sheet β uses
navigator.share()on supported devices - Sound Feedback β subtle click/vote SFX with a mute toggle
- Clipboard Fallback β 3-tier copy strategy (Clipboard API β Share API β
<textarea>+execCommand)
These aren't hypothetical. Every one was encountered, debugged, and patched.
Problem: Laptop sleeps β wakes up β Socket.io auto-reconnects, but the UI shows stale vote counts from hours ago.
Fix: On reconnect, the client fires a full re-fetch of /api/polls/[slug] to hydrate the latest authoritative data before resuming real-time updates.
Problem: 50 users vote in the same second. NaΓ―ve read count β write count+1 loses votes due to interleaved reads.
Fix: supabase.rpc('increment_vote_count') runs UPDATE SET vote_count = vote_count + 1 RETURNING vote_count β a single atomic Postgres statement. No read-modify-write. No drift.
Problem: navigator.clipboard.writeText() throws in non-HTTPS contexts (localhost, embedded iframes, older Android WebViews).
Fix: Three-tier fallback:
navigator.clipboard.writeText()β modern browsers on HTTPSnavigator.share()β mobile native share sheet- Temporary
<textarea>+document.execCommand('copy')β the "1999 approach" that still works everywhere
Problem: User with a misconfigured system clock bypasses poll expiration, or gets blocked from a still-open poll.
Fix: Server-authoritative timestamps only. The vote API uses new Date() on the server (UTC) to compare against poll.expires_at. Client-side countdown timers are display-only β cosmetic, never authoritative.
Problem: Malicious client sends optionIds: "string" instead of ["array"], crashing .map() downstream.
Fix: Input normalization before any processing:
const normalizedIds = Array.isArray(rawIds)
? rawIds.filter(id => typeof id === 'string' && id.length > 0)
: typeof rawIds === 'string' ? [rawIds] : [];Even if the app logic has a bug, Postgres triggers are the last line of defense:
vote_check_activeβ rejects votes on inactive polls at the DB levelvote_validate_optionβ rejects votes whereoption_id β poll_idUNIQUE(poll_id, voter_fingerprint)β constraint-level duplicate prevention
| Layer | Technology | Role |
|---|---|---|
| Frontend | Next.js 16 (App Router) + React 19 | SSR, routing, serverless API |
| Language | TypeScript | End-to-end type safety |
| Styling | Tailwind CSS 4 + Custom Design System | "Cyber-Organic" dark theme with emerald accents |
| Real-Time | Node.js + Express + Socket.io | Dedicated WebSocket server (Railway) |
| Database | PostgreSQL via Supabase | Persistent storage, RPC, RLS, triggers |
| Anti-Abuse | FingerprintJS + IP Sliding Window | Dual-layer vote integrity |
| Animation | Framer Motion | Page transitions, micro-interactions |
| Notifications | Sonner | Toast notifications |
| Icons | Lucide React | Consistent iconography |
| Engagement | canvas-confetti Β· use-sound Β· react-qr-code | Confetti, haptic sounds, QR sharing |
| Scheduling | react-datepicker | Custom deadline calendar |
| Deployment | Vercel (app) + Render (socket) + Supabase (db) | Split-stack, independently scalable |
Node.js 18+ Β· npm 9+ Β· Git Β· Supabase Account (free tier)
# 1. Clone
git clone https://github.com/pulkitpandey/poll-rooms.git && cd poll-rooms
# 2. Install dependencies (both apps)
npm install && cd socket-server && npm install && cd ..
# 3. Configure environment
cp .env.example .env.local # Fill in Supabase credentials
cp socket-server/.env.example socket-server/.env
# 4. Setup database
# β Go to supabase.com β SQL Editor β Paste database/schema.sql β Run
# 5. Launch (two terminals)
npm run dev # Terminal 1 β http://localhost:3000
cd socket-server && node index.js # Terminal 2 β ws://localhost:3001Env Var Checklist:
| Variable | File | Required | Description |
|---|---|---|---|
NEXT_PUBLIC_BASE_URL |
.env.local |
β | App origin (e.g. http://localhost:3000) |
NEXT_PUBLIC_SOCKET_URL |
.env.local |
β | Public WebSocket URL (client connects here) |
SOCKET_SERVER_URL |
.env.local |
β | Internal broadcast URL (API β Socket) |
NEXT_PUBLIC_SUPABASE_URL |
.env.local |
β | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
.env.local |
β | Supabase anonymous key |
PORT |
socket-server/.env |
β¬ | Socket server port (default: 3001) |
FRONTEND_URL |
socket-server/.env |
β | CORS origin whitelist |
MIT β use it, fork it, ship it.
Built with obsessive attention to detail, defensive engineering, and way too much emerald green. π



