Custom Feature Voting System: Development on React and Laravel

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 alr

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1283
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1238
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    980
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    1029
  • image_website-sbh_0.webp
    Website development for SBH Partners
    1104
  • image_website-_0.webp
    Website development for Red Pear
    552

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.