How to Build a Secure Online Voting System with Next.js, SQLite & JWT
- 19 hours ago
- 10 min read

You need a quick poll for your team meeting. You open Google Forms, paste a link in Slack — and twenty minutes later someone has voted six times because the form has no authentication. Someone else complains they can't see the live results. Your poll is now useless.
This is the problem that an online voting system solves: a self-hosted web application where authenticated administrators create polls, share a public link, and voters cast exactly one ballot — enforced cryptographically, with no voter accounts required.
Real-world use cases:
Student councils running elections for class representatives
Small businesses running anonymous team preference polls
Community organisations voting on budget decisions
Event organisers collecting ranked session preferences
Developers learning full-stack Next.js with a non-trivial production scenario
This blog covers the architecture, tech stack, and implementation phases of a full-stack voting system built with Next.js 16, TypeScript, SQLite, and JWT. It explains the non-obvious engineering decisions — specifically how to prevent duplicate votes without requiring voter accounts. It does not include full source code (that is in the course).
📄 Before you dive in — grab the free PRD template that maps out this entire system: architecture, API spec, sprint plan, and system prompt. [Download the free PRD]
How It Works: Core Concept
The fundamental problem with naïve polling
The obvious approach is: store a cookie when someone votes, block them if the cookie exists. This fails in three ways. First, cookies are trivially deletable — refresh and vote again. Second, cookies are per-browser — voting from incognito or mobile gives a fresh ballot. Third, storing IP addresses is a privacy concern in many jurisdictions.
The voter token approach
The architecture uses a cryptographic voter token: a SHA-256 hash derived from the poll ID combined with the voter's IP address and user-agent string. This hash is stored in the database when a vote is cast. On every subsequent submission, the system regenerates the same hash and checks whether it already exists — if it does, the vote is rejected.
Think of it like a wax seal. Each visitor's combination of network identity and browser fingerprint produces a unique seal. The system checks whether that seal has already been stamped on the ballot box. You never store the original wax — only the imprint.
This approach has a key property: it is one-way. The stored token cannot be reversed to identify who voted. It is privacy-preserving by design.
Data-flow diagram
SETUP PHASE (Admin)
─────────────────────────────────────────────────────
[Admin browser]
│
▼
POST /api/auth/signup → Hash password (bcrypt)
POST /api/auth/login → Issue JWT → HTTP-only cookie
│
▼
POST /api/polls → Validate JWT → Insert poll + options into SQLite
│
▼
GET /dashboard → Render poll list with shareable /vote/[id] links
VOTING PHASE (Public voter)
─────────────────────────────────────────────────────
[Voter browser]
│
▼
GET /vote/[pollId] → Load poll + options (no auth required)
│ select option & submit
▼
POST /api/votes → Extract IP + User-Agent
→ SHA-256(pollId + IP + UA) = voterToken
→ SELECT from voter_tokens WHERE token = ?
→ If exists → 409 Duplicate
→ Else → INSERT vote + INSERT token → 200 OK
│
▼
GET /api/polls/[id]/results → COUNT votes per option → return JSON
│
▼
[Results page with animated bar chart]
System Architecture Deep Dive
Architecture layers
The system is split into five distinct layers, each with a clear responsibility:
Frontend (React / Next.js App Router): Server and client components handle authentication pages, the admin dashboard for poll management, and the public voting interface. Framer Motion provides animated transitions between voting states.
API Layer (Next.js Route Handlers): All business logic lives in Next.js API routes under /app/api/. There is no separate backend process — API routes run as serverless functions in the same Next.js process, making local development and deployment simple.
Authentication Middleware: A custom proxy middleware intercepts requests to protected routes, validates the JWT from the HTTP-only cookie, and either forwards the request or redirects to /login. This runs at the edge before any page renders.
Data Layer (SQLite via better-sqlite3): A single SQLite file stores users, polls, options, votes, and voter tokens. better-sqlite3 is synchronous, which eliminates async complexity while handling hundreds of concurrent readers efficiently.
Security Layer (bcryptjs + JWT + voter tokens): Passwords are bcrypt-hashed. Session state is managed with signed JWTs. Duplicate votes are blocked by the hashed voter token mechanism described above.
Component table
Component | Role | Technology Options |
Frontend framework | Server + client rendering, routing | Next.js 16 (App Router), Remix, SvelteKit |
UI styling | Responsive layout, dark mode | Tailwind CSS v4, shadcn/ui, CSS Modules |
Animations | Voting state transitions, results bars | Framer Motion, CSS transitions |
API layer | Route handlers, request/response | Next.js API Routes, Express.js, Hono |
Authentication | Session management | JWT + HTTP-only cookie, NextAuth.js, Lucia |
Password hashing | Secure credential storage | bcryptjs, argon2 |
Database driver | Query execution | better-sqlite3 (sync), Prisma, Drizzle ORM |
Database | Persistent storage | SQLite, PostgreSQL, PlanetScale |
Vote deduplication | Prevent multi-voting | SHA-256 voter token, Redis SET, IP allowlist |
Deployment | Hosting | Vercel, Railway, Fly.io, self-hosted VPS |
Data flow walkthrough
Admin signs up — POST /api/auth/signup, password is bcrypt-hashed, user record created in SQLite.
Admin logs in — POST /api/auth/login, credentials verified, JWT signed with jsonwebtoken, stored as HTTP-only token cookie with 7-day expiry.
Admin creates poll — POST /api/polls, middleware validates JWT, poll row and option rows inserted in a single SQLite transaction.
Admin shares link — Dashboard renders /vote/[pollId] URL per poll.
Voter opens link — GET /vote/[pollId], no auth required, page renders poll question and options.
Voter submits ballot — POST /api/votes, server extracts x-forwarded-for and user-agent, hashes them with poll ID using crypto.createHash('sha256').
Deduplication check — SELECT from voter_tokens table; if token found, return 409 Conflict.
Vote recorded — INSERT into votes table, INSERT token into voter_tokens table — both in one atomic SQLite transaction.
Results fetched — GET /api/polls/[id]/results, COUNT GROUP BY option_id, return sorted JSON.
Results displayed — Client renders animated percentage bars per option.
Non-obvious design decisions
Decision 1: Synchronous SQLite over async PostgreSQL. For a single-server deployment handling polls with tens to hundreds of concurrent voters, better-sqlite3's synchronous API eliminates the async/await boilerplate and Promise chain errors that plague async database code. SQLite with WAL mode handles multiple concurrent readers without blocking writers. The tradeoff is that horizontal scaling requires a migration to PostgreSQL — but most small-to-medium voting deployments never reach that threshold.
Decision 2: Voter tokens stored separately from votes. The voter_tokens table has no foreign key to the votes table. This means a vote can be deleted without also deleting the deduplication record. An admin can remove a fraudulent vote entry while the token remains, preventing the same voter from re-casting. This is a subtle but important audit invariant.
Tech Stack Recommendation
Stack A — Beginner/Prototype (build in a weekend)
Layer | Technology | Why |
Framework | Next.js 16 (App Router) | Full-stack in one repo, zero separate backend |
Language | TypeScript | Type safety catches bugs at compile time |
Styling | Tailwind CSS v4 | Utility-first, no CSS file to maintain |
Database | SQLite (better-sqlite3) | Zero-config, file-based, no server to run |
Auth | Custom JWT + bcryptjs | No third-party dependency, total control |
Animations | Framer Motion | 3 lines for professional transitions |
Deployment | Vercel (free tier) | git push to deploy, zero config |
Estimated monthly cost: $0 (Vercel hobby tier, SQLite file included)
Stack B — Production-Ready (designed to scale)
Layer | Technology | Why |
Framework | Next.js 16 (App Router) | Same DX, scales to serverless |
Language | TypeScript | Mandatory for team codebases |
Styling | Tailwind CSS + shadcn/ui | Accessible component library |
Database | PostgreSQL (Neon / Supabase) | Horizontal reads, connection pooling |
ORM | Drizzle ORM | Type-safe queries, migration history |
Auth | NextAuth.js v5 | OAuth providers, CSRF protection |
Vote dedup | Redis SET (Upstash) | Sub-millisecond lookup, no DB write contention |
Resend | Transactional voter confirmation emails | |
Monitoring | Sentry | Error tracking in production |
Deployment | Railway or Fly.io | Persistent disk for SQLite or managed Postgres |
Estimated monthly cost: $15–$40 (Neon free tier + Upstash free tier + Railway $5/month)
Implementation Phases
Phase 1: Project Setup & Database Schema
Stand up the Next.js project with TypeScript and Tailwind, configure the SQLite connection layer, and define the full database schema. The schema includes five tables: users, polls, options, votes, and voter_tokens. The voter_tokens table is the key security primitive — define its structure carefully before writing any business logic.
Key decisions: Should options be a JSON column in the polls table or a separate options table? A separate table is the correct choice — it allows per-option vote counts via simple SQL aggregation without parsing JSON at query time.
The exact SQLite schema with indexes, foreign key constraints, and WAL mode configuration is covered in detail in the full course with working, tested code.
Phase 2: Authentication Backend
Implement the signup, login, logout, and /api/auth/me endpoints. Passwords are hashed with bcryptjs using a cost factor of 12. On successful login, a JWT is signed using jsonwebtoken with a 7-day expiry and written into an HTTP-only, SameSite=Strict cookie. The /me endpoint validates the token on every protected page load.
Key decisions: Where to store the JWT secret. In development, use a .env.local file. In production, use the platform's secret manager — never commit secrets to git. Rotate the secret to force all active sessions to expire.
JWT rotation strategy and secure cookie configuration across Vercel and Railway are covered in detail in the full course with working, tested code.
Phase 3: Poll & Voting Backend
Implement the four poll API routes (create, list, delete, get by ID) and the voting route. The voting route is the most complex: it must atomically check the voter token, record the vote, and store the token in a single SQLite transaction to prevent race conditions under concurrent load.
Key decisions: Using better-sqlite3's .transaction() wrapper ensures atomicity. Without it, two simultaneous requests for the same voter can both pass the deduplication check before either inserts the token — a classic TOCTOU race condition.
The atomic transaction pattern and concurrent load testing with k6 are covered in detail in the full course with working, tested code.
Phase 4: Frontend — Dashboard & Auth Pages
Build the login/signup pages with form validation, the authenticated dashboard showing all polls with create/delete controls, and the shareable voting page. The auth context (React Context + useReducer) manages session state globally, avoiding prop drilling across deeply nested components.
Key decisions: Use Next.js Server Components for the initial dashboard render (faster first paint, no client waterfall) and Client Components only for interactive elements like the create-poll form and real-time result bars.
The Server/Client Component boundary strategy and auth context implementation are covered in detail in the full course with working, tested code.
Phase 5: Route Protection & Deployment
Add the proxy middleware that guards all /dashboard and /api/polls routes. Configure environment variables for production, build the Docker image, and deploy to Vercel or Railway. The middleware pattern in Next.js 16 uses middleware.ts at the project root — it intercepts requests before they reach any route handler.
Key decisions: Middleware runs at the edge (Vercel Edge Runtime), which means it cannot use Node.js-specific APIs. The JWT verification must use the Web Crypto API or a compatible library, not the jsonwebtoken package (which is Node-only).
The edge-compatible JWT verification and zero-downtime deployment pipeline are covered in detail in the full course with working, tested code.
Common Challenges
Building a voting system looks straightforward until you hit the production edge cases. Here are the five most common problems developers encounter and how to fix them.
Challenge 1: Race condition in vote deduplication Root cause: Two near-simultaneous POST requests for the same voter both execute the SELECT voter_token check before either has completed its INSERT. Both pass, and two votes are recorded. Fix: Wrap the check + insert in a single better-sqlite3 .transaction() call. SQLite serialises writes by default; the transaction guarantees that the second request blocks until the first is committed.
Challenge 2: JWT validation fails in Next.js middleware Root cause: jsonwebtoken uses Node.js crypto internals not available in the Vercel Edge Runtime. Importing it in middleware.ts causes a DynamicServerError at build time. Fix: Use jose instead of jsonwebtoken for edge-compatible JWT operations. jose targets the Web Crypto API and works in both Node.js and Edge runtimes.
Challenge 3: SQLite file not persisted on Vercel Root cause: Vercel's serverless filesystem is ephemeral — the SQLite file is wiped on every cold start. Fix: For Vercel deployments, migrate to a hosted database (Neon, Supabase, PlanetScale). For persistent SQLite, deploy to Railway or Fly.io with a persistent volume mount.
Challenge 4: Poll results flicker on the voting page Root cause: The results component fetches data on mount with useEffect, causing a loading → data flash on every render. Fix: Use Next.js Server Components with fetch + cache: 'no-store' for the results, or useSWR with refreshInterval for client-side polling. For real-time updates, wire up Server-Sent Events.
Challenge 5: Voter token collisions for private networks Root cause: Many corporate networks route all traffic through a single egress IP. Every employee in the building gets the same voter token, so only the first person can vote. Fix: Extend the token input to include a browser fingerprint (canvas hash, installed fonts) or switch to a UUID stored in localStorage for authenticated-voter use cases where privacy is less critical.
Challenge 6: TypeScript errors in Next.js 16 App Router params Root cause: In Next.js 16, dynamic route params are now a Promise — accessing params.id directly without await throws a type error that breaks the build. Fix: Destructure params with const { id } = await params in all dynamic route page components and route handlers.
Solving these issues took us over 40 hours of testing across different hosting environments — the course walks you through each fix with working code and explains the root cause so you can diagnose similar problems on your own projects.
Ready to Build This Yourself?
Understanding the architecture is the starting line, not the finish line. There is a significant gap between knowing how a system works and having a fully working, deployed application with clean code, tested edge cases, and a proper deployment pipeline.
The Online Voting System course on Codersarts Labs closes that gap with:
✅ Full TypeScript source code — every file, every function, production-quality
✅ 20-lesson step-by-step video walkthrough from blank repo to deployed app
✅ Docker setup for local development — single docker-compose up to start
✅ SQLite schema with all constraints, indexes, and WAL mode configuration
✅ JWT + bcrypt authentication — complete, tested, production-ready
✅ Voter token deduplication with race-condition protection
✅ Deployment walkthrough for Vercel, Railway, and self-hosted VPS
✅ Lifetime access — all future updates included
✅ Private community — ask questions, share your build, get code reviews
$30. Everything above.
Already know the stack but want to ship faster with expert guidance? Book a 1:1 guided session ($20/hour) and we will pair-program through your specific implementation, review your code, and help you deploy to production in a single session.
Conclusion
A full-stack online voting system is a deceptively rich engineering project. The core architecture — Next.js API routes, SQLite with better-sqlite3, JWT authentication, and SHA-256 voter token deduplication — fits in a single repo and deploys to a free tier with a single git push. The non-obvious complexity lives in the deduplication race condition, edge-runtime JWT compatibility, and the Server/Client Component boundary in Next.js App Router.
If you are starting today, use Stack A: Next.js + SQLite + Vercel. It runs for free, deploys in minutes, and gives you a working product you can show users by the end of the weekend. Migrate to PostgreSQL and Redis when your poll traffic justifies it.
The full course at labs.codersarts.com includes all source code, tested configurations, and a deployment walkthrough — so you can skip the 40 hours of debugging and go straight to shipping.



Comments