Real-time polling, zero friction.
VOTE.LIVE is a high-performance, real-time polling application. Create a poll in seconds — no sign-up required — share the link, and watch votes roll in live. Built with Next.js, Express, and Redis, the app delivers instant updates via Server-Sent Events (SSE) and Redis Pub/Sub, so every connected viewer sees results the moment they change.
- Features
- Architecture
- Tech Stack
- Prerequisites
- Getting Started
- Environment Variables
- API Reference
- Project Structure
- Design System
- Contributing
- License
| Feature | Description |
|---|---|
| Instant Creation | No sign-ups or accounts. Create a poll with 2–6 options in seconds. |
| Real-Time Updates | Server-Sent Events + Redis Pub/Sub broadcast every vote to all viewers instantly. |
| Auto-Expiring Polls | Redis TTL (Time-To-Live) auto-closes polls after a configurable duration (15 min – 7 days). |
| Duplicate Vote Prevention | UUID-based voter tracking prevents the same browser from voting twice. |
| Live Countdown Timer | Client-side countdown shows remaining time with second-level precision. |
| Share & Copy Link | One-click copy-to-clipboard for sharing poll URLs. |
| Explore Page | Browse all active polls sorted by popularity. |
| Responsive Design | Fluid layout that works across desktop, tablet, and mobile. |
| Dark-Mode UI | "Kinetic Pulse" design system with glowing accents, smooth transitions, and a premium dashboard aesthetic. |
┌──────────────────────────────────────────────────────────┐
│ Client │
│ Next.js 16 (App Router) │
│ │
│ /explore ──── Server Component ──── fetch GET /api/polls│
│ /create ───── Client Component ──── fetch POST /api/polls
│ /poll/[id] ── Server + Client ───── SSE stream ─────────│─ ─ ─ ┐
└──────────────────────┬───────────────────────────────────┘ │
│ HTTP │ SSE
▼ │
┌──────────────────────────────────────────────────────────┐ │
│ Server │ │
│ Express 5 (Node.js) │ │
│ │ │
│ POST /api/polls ─────── createPoll() ───────────────────│───┐ │
│ GET /api/polls ─────── listActivePolls() ──────────────│───┤ │
│ GET /api/polls/:id ─── getPoll() ──────────────────────│───┤ │
│ POST /api/polls/:id/vote ─ castVote() + PUBLISH ────────│───┤ │
│ GET /api/polls/:id/stream ─ SSE + SUBSCRIBE ───────────│─ ─│─ ─┘
└──────────────────────┬───────────────────────────────────┘ │
│ ioredis │
▼ │
┌──────────────────────────────────────────────────────────┐ │
│ Redis (Upstash) │ │
│ │ │
│ poll:<id>:meta ─── Hash (question, options, expiry) │◄──┤
│ poll:<id>:votes ─── Hash (optionId → count) │◄──┤
│ poll:<id>:voters ─── Set (voter UUIDs for dedup) │◄──┤
│ poll:<id> ─── Pub/Sub channel (vote broadcasts) │◄──┘
│ │
│ All keys expire via native Redis TTL. │
└──────────────────────────────────────────────────────────┘
-
Poll Creation: The client sends a
POSTrequest with the question, options, and duration. The server generates a unique ID viananoid, stores poll metadata and vote counters as Redis Hashes, and sets a TTL on all keys. -
Voting: When a user casts a vote, the server checks for poll validity, expiry, and duplicate votes (via a Redis Set). It then atomically increments the vote count and publishes the updated totals to a Redis Pub/Sub channel.
-
Real-Time Delivery: Each connected client opens an SSE connection. The server subscribes to the poll's Redis channel using a dedicated
ioredissubscriber instance and forwards every vote update to the client stream. A keepalive ping fires every 25 seconds to prevent connection timeouts. -
Expiry: When the Redis TTL expires, all poll data is automatically deleted. The client detects expiry via the countdown timer and transitions to the "Poll Closed" view showing final results.
| Technology | Purpose |
|---|---|
| Next.js 16 | React framework with App Router, RSC, and streaming |
| React 19 | UI rendering with useTransition for non-blocking mutations |
| Tailwind CSS v4 | Utility-first CSS framework |
| shadcn/ui | Accessible, composable UI primitives |
| Radix UI | Headless component primitives |
| Lucide React | Icon library |
| Hanken Grotesk | Primary typeface |
| JetBrains Mono | Monospace typeface for labels and metrics |
| Technology | Purpose |
|---|---|
| Express 5 | HTTP server and routing |
| ioredis | Redis client with Pub/Sub support |
| Redis (Upstash) | Data store, TTL-based expiry, and message broker |
| Neon | Serverless PostgreSQL (schema defined via Drizzle ORM) |
| Drizzle ORM | Type-safe SQL schema and migrations |
| nanoid | Short, URL-friendly unique IDs |
| tsx | TypeScript execution for development |
| nodemon | Auto-restart on file changes |
Before you begin, make sure you have:
- Node.js v18 or later — Download
- pnpm — Install via
npm install -g pnpm - Redis instance — Upstash (free tier available) or a local Redis server
git clone https://github.com/iamdainwi/lets-vote.git
cd lets-votecd server
pnpm installCreate a .env.local file in the server/ directory:
# Redis connection (TLS-enabled Upstash URL)
UPSTASH_REDIS_REST_URL="rediss://default:YOUR_PASSWORD@your-redis-host:6379"
# PostgreSQL connection (Neon)
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
# CORS origin for the frontend
CLIENT_ORIGIN="http://localhost:3000"Start the development server:
pnpm devThe API server starts at http://localhost:4000.
cd client
pnpm installCreate a .env.local file in the client/ directory:
NEXT_PUBLIC_API_URL="http://localhost:4000"Start the development server:
pnpm devThe frontend starts at http://localhost:3000 and automatically redirects
/ → /explore.
| Variable | Required | Description |
|---|---|---|
UPSTASH_REDIS_REST_URL |
✅ | Redis connection string (TLS rediss:// for Upstash) |
DATABASE_URL |
✅ | PostgreSQL connection string (Neon serverless) |
CLIENT_ORIGIN |
❌ | Allowed CORS origin. Defaults to * (allow all). |
PORT |
❌ | Server port. Defaults to 4000. |
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_API_URL |
❌ | Backend API base URL. Defaults to http://localhost:4000. |
All endpoints are prefixed with /api/polls.
GET /api/polls
Returns an array of active polls sorted by total votes (descending).
Response 200 OK:
[
{
"id": "aBcDeFgHiJ",
"question": "Tabs or Spaces?",
"totalVotes": 42,
"expiresAt": 1717400000000
}
]POST /api/polls
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
question |
string |
✅ | The poll question |
options |
string[] |
✅ | 2–6 answer options |
duration |
number |
✅ | Duration in minutes (1–10080) |
Response 201 Created:
{ "id": "aBcDeFgHiJ" }Error Responses:
400— Invalid question, options, or duration.
GET /api/polls/:id
Returns the full poll state including options and current vote counts.
Response 200 OK:
{
"id": "aBcDeFgHiJ",
"question": "Tabs or Spaces?",
"options": [
{ "id": "abc123", "text": "Tabs" },
{ "id": "def456", "text": "Spaces" }
],
"expiresAt": 1717400000000,
"votes": { "abc123": 25, "def456": 17 },
"expired": false
}Error Responses:
404— Poll not found or expired.
POST /api/polls/:id/vote
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
optionId |
string |
✅ | The ID of the chosen option |
voterId |
string |
✅ | A unique identifier for the voter (UUID) |
Response 200 OK:
{ "votes": { "abc123": 26, "def456": 17 } }Error Responses:
400— MissingoptionIdorvoterId, invalid option, or poll expired.409— Voter has already voted on this poll.
GET /api/polls/:id/stream
Opens a Server-Sent Events stream. The server sends:
- An initial
initevent with the full poll state. - Subsequent
voteevents whenever a vote is cast. - A keepalive
: pingcomment every 25 seconds.
Event Format:
data: {"type":"init","poll":{...}}
data: {"type":"vote","votes":{"abc123":26,"def456":17}}
lets-vote/
├── client/ # Next.js Frontend
│ ├── app/
│ │ ├── layout.tsx # Root layout with nav header
│ │ ├── globals.css # Design tokens & Tailwind config
│ │ ├── create/
│ │ │ └── page.tsx # Poll creation form
│ │ ├── explore/
│ │ │ └── page.tsx # Browse active polls (SSR)
│ │ └── poll/
│ │ └── [id]/
│ │ ├── page.tsx # Poll page (SSR + metadata)
│ │ └── PollClient.tsx # Interactive voting & live results
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── spinner.tsx
│ │ └── textarea.tsx
│ ├── lib/
│ │ ├── api.ts # API client (fetch wrappers)
│ │ └── utils.ts # Utility functions (cn)
│ ├── next.config.ts # Next.js configuration
│ ├── components.json # shadcn/ui configuration
│ ├── tsconfig.json
│ └── package.json
│
├── server/ # Express Backend
│ ├── src/
│ │ ├── index.ts # Entry point, middleware, graceful shutdown
│ │ ├── routes/
│ │ │ └── poll.routes.ts # Route definitions
│ │ ├── controllers/
│ │ │ └── poll.controller.ts # Request handlers & SSE logic
│ │ ├── services/
│ │ │ └── redis.server.ts # Core business logic (Redis operations)
│ │ ├── lib/
│ │ │ └── redis.ts # Redis client setup (ioredis)
│ │ └── db/
│ │ ├── index.ts # Drizzle ORM + Neon connection
│ │ └── schema.ts # PostgreSQL schema (polls, options, votes)
│ ├── drizzle.config.ts # Drizzle Kit configuration
│ ├── nodemon.json # Dev server auto-reload config
│ ├── tsconfig.json
│ └── package.json
│
├── ui-design/ # Design reference assets
│ ├── kinetic_pulse/DESIGN.md # Full design system specification
│ ├── poll_creation/ # Create page mockup
│ ├── poll_voting/ # Voting interface mockup
│ ├── live_results/ # Live results dashboard mockup
│ └── poll_expired/ # Expired poll mockup
│
├── .gitignore
└── README.md # ← You are here
VOTE.LIVE uses the Kinetic Pulse design system — a dark-mode-first visual language engineered for real-time data clarity and high-velocity interaction.
- Electric, Precise, Agile — Every element signals action and state.
- Functional Vibrance — Color is used to communicate meaning, not decoration.
- Dashboard-first — Dense but organized information hierarchy.
| Role | Token | Hex | Usage |
|---|---|---|---|
| Primary | --primary-container |
#2E5BFF |
CTAs, active states, focus rings |
| Success | --secondary-container |
#36FFC4 |
Leading options, positive signals |
| Surface | --background |
#0F1419 |
App background |
| Text | --on-surface |
#DFE2EA |
Primary text |
| Muted | --on-surface-variant |
#C4C5D9 |
Secondary text, labels |
| Error | --error |
#FFB4AB |
Validation errors, destructive actions |
- Hanken Grotesk — Headlines, body text, metrics.
- JetBrains Mono — Labels, timestamps, status indicators.
Full design specification available in
ui-design/kinetic_pulse/DESIGN.md.
Contributions are welcome! To get started:
-
Fork the repository.
-
Create a branch for your feature or fix:
git checkout -b feature/your-feature-name
-
Make your changes and ensure they follow the existing code style.
-
Test your changes locally by running both the client and server.
-
Commit with a descriptive message following Conventional Commits:
git commit -m "feat: add multi-language support for poll questions" -
Push and open a Pull Request against
main.
- TypeScript strict mode is enabled in both client and server.
- Use
pnpmas the package manager. - Follow the existing file and folder naming conventions.
- ESLint is configured for the client — run
pnpm lintbefore submitting.
Use the GitHub Issues tab. Include:
- Steps to reproduce the bug.
- Expected vs. actual behavior.
- Browser/OS/Node.js version.
This project is licensed under the MIT License.
Built with ⚡ by @iamdainwi