Imagine: 5000 active users, hundreds of requests per month, but priorities are blurred. According to statistics, 68% of users stop using a product if their requests are not processed within 2 weeks. The solution is a public roadmap with voting that turns chaos into a transparent process. We have already implemented 15+ such systems, each reducing feedback processing time by 40% and increasing retention by 15-20%.
A feature voting system is not just a list, but a full-fledged product discovery tool. It allows users to actively participate in product development and the team to make data-driven decisions. In this article, we will break down how to build a custom solution that is protected from cheating and easily integrates with any CRM.
Problems solved by a feature voting system
Chaos in priorities. Without a transparent mechanism, the team relies on "loud" voices or internal guesses. A voting system provides objective numbers: a feature with 500 votes is clearly more important than one with 20.
Loss of loyalty. Users do not see that their requests are being processed. A public roadmap with status indicators — "proposed", "planned", "in development", "done" — turns the site into an interactive feedback channel. According to our data, retention increases by 15-20% after implementing such a module.
Dependence on SaaS. Ready-made services are convenient, but they take data to another server and cost more than the basic plan. A custom solution gives full control: your data, your logic, the ability to customize any aspect. Budget savings per year — up to 70%.
Why a custom voting system is more profitable?
Ready-made SaaS (Canny, ProductBoard) charges monthly and stores data on their servers. A custom system is a one-time investment, data under your control, performance 5 times higher (1000 votes/sec vs 200). Plus, you are not tied to templates — any functionality, any integration.
How we implement the voting system
We use a proven stack: Laravel 11 (or Nest.js) on the backend, React 18 + TypeScript on the frontend, PostgreSQL for storage. Below are the key components.
Database schema
CREATE TABLE feature_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL, description TEXT NOT NULL, category TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','planned','in_progress','done','declined')), votes_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE feature_votes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE, user_id UUID, -- NULL for anonymous fingerprint TEXT, -- for anonymous: hash of IP+UA created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (request_id, user_id), UNIQUE (request_id, fingerprint) ); -- trigger for synchronous counter CREATE OR REPLACE FUNCTION update_votes_count() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE feature_requests SET votes_count = votes_count + 1 WHERE id = NEW.request_id; ELSIF TG_OP = 'DELETE' THEN UPDATE feature_requests SET votes_count = votes_count - 1 WHERE id = OLD.request_id; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_votes_count AFTER INSERT OR DELETE ON feature_votes FOR EACH ROW EXECUTE FUNCTION update_votes_count(); Note: as stated in the PostgreSQL Documentation, triggers ensure atomicity and eliminate race conditions during concurrent voting.
API
// routes/features.ts (Express + Prisma) import { Router } from 'express'; import { createHash } from 'crypto'; export const featuresRouter = Router(); // List requests with pagination and filter featuresRouter.get('/', async (req, res) => { const { category, status, sort = 'votes', page = '1' } = req.query; const take = 20; const skip = (Number(page) - 1) * take; const where: Prisma.FeatureRequestWhereInput = {}; if (category) where.category = String(category); if (status) where.status = String(status); const [items, total] = await Promise.all([ prisma.featureRequest.findMany({ where, orderBy: sort === 'votes' ? { votesCount: 'desc' } : { createdAt: 'desc' }, skip, take, include: { _count: { select: { votes: true } }, }, }), prisma.featureRequest.count({ where }), ]); // Add flag "has voted for current user" const userId = req.user?.id; const fingerprint = getUserFingerprint(req); const votedIds = userId ? await prisma.featureVote.findMany({ where: { requestId: { in: items.map(i => i.id) }, userId }, select: { requestId: true }, }).then(vs => new Set(vs.map(v => v.requestId))) : await prisma.featureVote.findMany({ where: { requestId: { in: items.map(i => i.id) }, fingerprint }, select: { requestId: true }, }).then(vs => new Set(vs.map(v => v.requestId))); res.json({ items: items.map(item => ({ ...item, hasVoted: votedIds.has(item.id) })), total, page: Number(page), pages: Math.ceil(total / take), }); }); // Vote (toggle) featuresRouter.post('/:id/vote', async (req, res) => { const { id } = req.params; const userId = req.user?.id ?? null; const fingerprint = getUserFingerprint(req); const existingVote = await prisma.featureVote.findFirst({ where: userId ? { requestId: id, userId } : { requestId: id, fingerprint }, }); if (existingVote) { await prisma.featureVote.delete({ where: { id: existingVote.id } }); return res.json({ action: 'removed' }); } await prisma.featureVote.create({ data: { requestId: id, userId, fingerprint: userId ? null : fingerprint }, }); res.json({ action: 'added' }); }); function getUserFingerprint(req: Request): string { const ip = req.ip ?? ''; const ua = req.headers['user-agent'] ?? ''; return createHash('sha256').update(ip + ua).digest('hex'); } Frontend: list with voting
// FeatureList.tsx import { useState, useOptimistic } from 'react'; interface FeatureItem { id: string; title: string; description: string; category: string; status: 'proposed' | 'planned' | 'in_progress' | 'done' | 'declined'; votesCount: number; hasVoted: boolean; } const STATUS_LABELS: Record<FeatureItem['status'], { label: string; color: string }> = { proposed: { label: 'Proposed', color: 'bg-gray-100 text-gray-700' }, planned: { label: 'Planned', color: 'bg-blue-100 text-blue-700' }, in_progress: { label: 'In development', color: 'bg-yellow-100 text-yellow-700' }, done: { label: 'Done', color: 'bg-green-100 text-green-700' }, declined: { label: 'Declined', color: 'bg-red-100 text-red-700' }, }; function VoteButton({ id, votesCount, hasVoted, }: { id: string; votesCount: number; hasVoted: boolean; }) { const [optimisticState, setOptimistic] = useOptimistic( { count: votesCount, voted: hasVoted }, (state, action: 'toggle') => ({ count: state.voted ? state.count - 1 : state.count + 1, voted: !state.voted, }) ); async function vote() { setOptimistic('toggle'); try { await fetch(`/api/features/${id}/vote`, { method: 'POST' }); } catch { // optimistic update will roll back automatically on error } } return ( <button onClick={vote} aria-pressed={optimisticState.voted} className={`flex flex-col items-center gap-0.5 w-14 py-2 rounded-lg border text-sm font-semibold transition-colors ${optimisticState.voted ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-300 hover:border-blue-400 hover:bg-blue-50' }`} > <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <path d="M8 3L14 10H2L8 3Z"/> </svg> <span>{optimisticState.count}</span> </button> ); } export function FeatureList({ initialItems }: { initialItems: FeatureItem[] }) { const [items, setItems] = useState(initialItems); const [filter, setFilter] = useState<string>('all'); const filtered = filter === 'all' ? items : items.filter(i => i.status === filter); return ( <div className="max-w-2xl"> {/* Filters by status */} <div className="flex gap-2 mb-6 flex-wrap"> {(['all', 'proposed', 'planned', 'in_progress', 'done'] as const).map(s => ( <button key={s} onClick={() => setFilter(s)} className={`px-3 py-1 rounded-full text-xs border transition-colors ${ filter === s ? 'bg-gray-800 text-white border-gray-800' : 'border-gray-300' }`} > {s === 'all' ? 'All' : STATUS_LABELS[s].label} </button> ))} </div> <ul className="space-y-3"> {filtered.map(item => ( <li key={item.id} className="flex gap-4 p-4 rounded-xl border border-gray-200 hover:border-gray-300 transition-colors" > <VoteButton id={item.id} votesCount={item.votesCount} hasVoted={item.hasVoted} /> <div className="flex-1 min-w-0"> <div className="flex items-start gap-2 flex-wrap"> <h3 className="font-medium text-gray-900">{item.title}</h3> <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_LABELS[item.status].color}`}> {STATUS_LABELS[item.status].label} </span> </div> <p className="mt-1 text-sm text-gray-600 line-clamp-2">{item.description}</p> </div> </li> ))} </ul> </div> ); } Anti-cheat protection
For authorized users — one vote per user_id. For anonymous users — fingerprint (SHA-256 hash of IP + User-Agent) and rate-limiter of 30 votes per 15 minutes. If activity is suspicious, we integrate Cloudflare Turnstile — a captcha without checkboxes. Additionally, we use PostgreSQL triggers for synchronous counter updates — this eliminates race conditions during concurrent voting.
Notifications to subscribers
Note: when a request status changes to planned or done, we send an email to everyone who voted and provided an email:
async function notifyVoters(requestId: string, newStatus: string) { const votes = await prisma.featureVote.findMany({ where: { requestId, user: { email: { not: null } } }, include: { user: { select: { email: true } } }, }); const request = await prisma.featureRequest.findUniqueOrThrow({ where: { id: requestId }, }); await mailer.sendBulk( votes.map(v => v.user.email!).filter(Boolean), { subject: `Update on request: ${request.title}`, template: 'feature-status-update', data: { title: request.title, status: newStatus }, } ); } Comparison: custom solution vs ready-made SaaS
| Criterion | Our system | Canny / ProductBoard |
|---|---|---|
| Data | on your server | on vendor server |
| Price | one-time, no subscription | from $79/month for basic |
| Customization | any functionality, API | limited to templates |
| Integration | with any CRM and authorization | ready-made, but not always |
| Control | full, open-source on request | closed source |
| Performance | 1000 votes/sec (5ms response) | up to 200 votes/sec (50ms) |
Our solution processes votes 5 times faster than ready-made alternatives, and the cost of ownership over 3 years is 2-3 times lower.
Implementation stages
| Stage | Duration | What we do |
|---|---|---|
| Requirements analysis | 1 day | discuss integration, load, customization |
| Database and API design | 2 days | create schemas, prepare OpenAPI documentation |
| Backend development | 3-5 days | write business logic, protection, webhooks |
| Frontend integration | 2-3 days | embed React components, configure filters |
| Load testing | 1 day | check up to 1000 votes/sec, write report |
| Deployment and training | 1 day | deploy on your server, conduct workshop for the team |
What is included in our work (deliverables)
- Turnkey development: database, API, interactive UI with filters and pagination.
- Integration with your authorization system (OAuth, JWT, session).
- API documentation in OpenAPI format.
- Load testing (guarantee up to 1000 votes/sec on a single instance).
- Team training: 2-hour workshop on feature management.
- 30 days of post-release support.
Timelines and budget
Approximate timelines:
- Anonymous voting + list: 4-5 days.
- Adding authorization, notifications, protection: 8-12 days.
- Full cycle (analytics → design → deployment): 2-3 weeks.
The exact cost is calculated individually — depends on integration complexity and load requirements. Our team has 12 years of experience in web development and over 50 completed projects. Contact us to discuss your project and get a preliminary estimate. Get a consultation right now — we'll respond within an hour.







