Minimal working public polling application.
- List seeded public polls.
- Open a poll and choose one option.
- Record one vote per anonymous browser identity per poll.
- Display aggregated vote counts and percentages.
- Reject invalid options and duplicate votes.
- Next.js and React
- TypeScript and plain CSS
- Drizzle ORM with PostgreSQL
- Zod for server-side validation
- Vercel-compatible Next.js API routes
Requirements: Node.js 20.9 or newer and PostgreSQL 14 or newer.
git clone https://github.com/basecaseworks/polls.git
cd polls
npm install
cp .env.example .env.local
npm run db:migrate
npm run db:seed
npm run db:check
npm run devOpen http://localhost:3000.
Copy .env.example to .env.local and set:
DATABASE_URL: PostgreSQL connection string used by migrations, the application, and the seed script.
The default setup uses the same PostgreSQL role for migrations and runtime. Never commit .env.local or real secrets.
The application has three tables:
polls: required question and creation timestamp.poll_options: required label, deterministic position, and a foreign key topolls.votes: poll, option, anonymous voter key, and creation timestamp.
votes has a unique constraint on (poll_id, voter_key), so PostgreSQL is the final protection against duplicate voting. It also has a composite foreign key from (poll_id, option_id) to (poll_options.poll_id, poll_options.id). This means a vote cannot point at an option belonging to another poll, even if application validation is bypassed.
Apply committed migrations to an empty database with:
npm run db:migrate
npm run db:seed
npm run db:checkThe seed script inserts three example polls and their options. It is safe to run again without duplicating seeded definitions.
db:check uses the configured runtime connection, verifies polls, poll_options, and votes, and checks select/insert/update/delete access without printing credentials.
The application uses ordinary PostgreSQL connections, so changing from Neon to another PostgreSQL provider only requires changing DATABASE_URL.
When a poll is opened or voted on, the server creates a cryptographically random 32-byte voter key when the basecase_polls_voter HTTP-only cookie is absent or invalid. The cookie is not based on an IP address, browser fingerprint, or account identity. It uses SameSite=Lax, a root path, a one-year lifetime, and the Secure flag in production.
This is a basic browser-identity mechanism. It is not suitable for elections, adversarial voting systems, high-security identity verification, or situations where users can clear or share cookies.
GET /api/polls
GET /api/polls/:id
POST /api/polls/:id/votes
Vote requests accept { "optionId": "..." }. The server validates both identifiers, verifies the option belongs to the poll, and derives the voter identity from the HTTP-only cookie. Invalid input returns 400, missing polls return 404, duplicate votes return 409, and unexpected database failures return 500.
Run the complete local static/test/build verification with:
npm run verifyDatabase-backed integration tests require DATABASE_URL or TEST_DATABASE_URL to point at a migrated PostgreSQL database. They cover listing, ordering, valid votes, aggregation, zero-vote polls, invalid cross-poll options, duplicate and concurrent votes, database constraints, and malformed API identifiers.
Deploy the repository as a Next.js project on Vercel. Set DATABASE_URL in the Vercel project environment, run the committed migrations and seed against the production PostgreSQL database, run npm run db:check, and deploy.
Neon is a suitable hosted PostgreSQL option, but no Neon-specific application code is required.
Accounts, poll creation or editing, administration, comments, sharing widgets, charts libraries, real-time updates, multi-select voting, ranked voting, analytics, tracking, and election-grade identity or security are outside this reference implementation.