Back to Blog

Building a Customer Feedback Platform: Lessons from Trackelio

Building a Customer Feedback Platform: Lessons from Trackelio

Every product team eventually faces the same question: where does user feedback go? It starts in support tickets, Slack messages, spreadsheets, and sticky notes. Then someone says “we should have a system for this.” That system is what we built with Trackelio.

Trackelio is a customer feedback platform that lets product teams collect feature requests, let users vote on them, publish a public roadmap, and maintain a changelog. Think Canny or UserVoice, but built for teams that want something leaner and more opinionated.

Here are the technical lessons from building it.

The Data Model: Feedback Is More Complex Than You Think

At first glance, a feedback board seems simple. Users submit ideas. Other users upvote them. You sort by votes. Done.

In practice, the data model needs to handle a surprising amount of complexity. A single feedback item needs to support:

  • The original submission (title, description, submitter)
  • Votes from authenticated and anonymous users
  • Status tracking (open, under review, planned, in progress, shipped, closed)
  • Internal team comments (not visible to submitters)
  • Public comments and replies
  • Categories and tags
  • Relationships to roadmap items and changelog entries
  • Merge capability (two feedback items that describe the same thing)

Here is the core schema we ended up with:

-- Core feedback items
create table feedback_items (
  id uuid primary key default gen_random_uuid(),
  board_id uuid references boards(id) not null,
  title text not null,
  description text,
  status text not null default 'open'
    check (status in ('open', 'under_review', 'planned', 'in_progress', 'shipped', 'closed')),
  category_id uuid references categories(id),
  submitter_id uuid references auth.users(id),
  submitter_email text, -- for anonymous submissions
  merged_into_id uuid references feedback_items(id),
  vote_count integer not null default 0,
  comment_count integer not null default 0,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- Votes with duplicate prevention
create table votes (
  id uuid primary key default gen_random_uuid(),
  feedback_item_id uuid references feedback_items(id) not null,
  user_id uuid references auth.users(id),
  anonymous_id text, -- fingerprint for anonymous voters
  created_at timestamptz not null default now(),
  unique (feedback_item_id, user_id),
  unique (feedback_item_id, anonymous_id)
);

-- Comments with threading
create table comments (
  id uuid primary key default gen_random_uuid(),
  feedback_item_id uuid references feedback_items(id) not null,
  parent_id uuid references comments(id),
  author_id uuid references auth.users(id) not null,
  body text not null,
  is_internal boolean not null default false,
  created_at timestamptz not null default now()
);

-- Denormalized vote counts are updated via trigger
create or replace function update_vote_count()
returns trigger as $$
begin
  if TG_OP = 'INSERT' then
    update feedback_items
    set vote_count = vote_count + 1, updated_at = now()
    where id = NEW.feedback_item_id;
  elsif TG_OP = 'DELETE' then
    update feedback_items
    set vote_count = vote_count - 1, updated_at = now()
    where id = OLD.feedback_item_id;
  end if;
  return null;
end;
$$ language plpgsql;

create trigger votes_count_trigger
after insert or delete on votes
for each row execute function update_vote_count();

The vote_count denormalization is worth calling out. We initially computed vote counts on the fly with COUNT(*) joins. This worked fine until a board had a few thousand items with tens of thousands of votes. Sorting by vote count required a full table scan and aggregate. The denormalized counter, maintained by a database trigger, reduced that query from 800ms to 12ms.

Customer review and star rating interface on a digital screen

The Voting System: Harder Than It Looks

Voting sounds trivial. User clicks a button, a row gets inserted. But there are several decisions that have real product implications.

Authenticated vs. anonymous voting. Trackelio supports both, and the board owner configures which mode to use. Authenticated voting prevents stuffing but adds friction — users need to create an account to vote. Anonymous voting is frictionless but needs abuse prevention.

For anonymous voting, we use a combination of IP address and browser fingerprint to generate a stable anonymous_id. This is not perfect — determined users can circumvent it — but it prevents casual abuse. The key insight: for a feedback board, preventing 95% of abuse is good enough. You do not need the security of a financial system.

// src/lib/server/anonymous-id.ts
import { createHash } from 'crypto';

export function generateAnonymousId(ip: string, userAgent: string): string {
  const raw = `${ip}:${userAgent}`;
  return createHash('sha256').update(raw).digest('hex').slice(0, 32);
}

Vote toggling. Users should be able to remove their vote. This means the UI needs to reflect the current vote state and the API needs to handle both creating and deleting votes idempotently.

// src/routes/api/v1/feedback/[id]/vote/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ params, locals }) => {
  const { supabase, user } = locals;
  const feedbackId = params.id;

  // Check for existing vote
  const { data: existingVote } = await supabase
    .from('votes')
    .select('id')
    .eq('feedback_item_id', feedbackId)
    .eq('user_id', user.id)
    .maybeSingle();

  if (existingVote) {
    // Remove vote (toggle off)
    await supabase.from('votes').delete().eq('id', existingVote.id);
    return json({ voted: false });
  }

  // Add vote
  const { error: insertError } = await supabase
    .from('votes')
    .insert({ feedback_item_id: feedbackId, user_id: user.id });

  if (insertError) {
    throw error(500, 'Failed to record vote');
  }

  return json({ voted: true });
};

The race condition problem. When two users vote on the same item at the exact same moment, the denormalized counter can get out of sync if you are not careful. We handled this at the database level with the trigger approach shown above. Because the trigger runs within the same transaction as the insert/delete, PostgreSQL’s MVCC guarantees correctness. An application-level counter update (read count, increment, write back) would have been vulnerable to lost updates.

Real-Time Updates with Supabase Realtime

A feedback board needs to feel alive. When someone votes on an item you are looking at, you should see the count update. When a new comment appears, it should just show up.

Supabase Realtime made this straightforward. We subscribe to changes on the relevant tables and update the UI reactively:

// src/lib/stores/feedback.ts
import { supabase } from '$lib/supabase';
import { writable } from 'svelte/store';

export function subscribeToBoardUpdates(boardId: string) {
  const channel = supabase
    .channel(`board:${boardId}`)
    .on(
      'postgres_changes',
      {
        event: '*',
        schema: 'public',
        table: 'feedback_items',
        filter: `board_id=eq.${boardId}`,
      },
      (payload) => {
        if (payload.eventType === 'INSERT') {
          addItemToStore(payload.new);
        } else if (payload.eventType === 'UPDATE') {
          updateItemInStore(payload.new);
        } else if (payload.eventType === 'DELETE') {
          removeItemFromStore(payload.old.id);
        }
      }
    )
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}

One gotcha: Supabase Realtime broadcasts the new row data, but it does not include related data (like the submitter’s name or the category label). We initially tried to enrich the payload on the client by fetching related data, but this created a waterfall of requests. The better solution was to denormalize the most-needed display fields (submitter name, category name) directly onto the feedback item row, accepting the trade-off of some data duplication for much simpler real-time updates.

Product feedback form collecting user input and feature requests

The Roadmap View

The roadmap is a Kanban-style view that groups feedback items by status. The product team drags items between columns (planned, in progress, shipped) and the public roadmap updates automatically.

The technical challenge here was not the Kanban board itself — that is a well-solved UI pattern. The challenge was permissions. The roadmap has two views:

  1. Public view: Anyone can see items in “planned,” “in progress,” and “shipped” status. Items in “open” and “under review” are not visible.
  2. Team view: Team members see all statuses plus internal comments, merge controls, and bulk actions.

We handled this with Supabase Row Level Security:

-- Public access: only show items with public-facing statuses
create policy "Public can view roadmap items"
  on feedback_items for select
  using (
    status in ('planned', 'in_progress', 'shipped')
    or auth.uid() in (
      select user_id from board_members
      where board_id = feedback_items.board_id
    )
  );

-- Team members can update status
create policy "Team can update items"
  on feedback_items for update
  using (
    auth.uid() in (
      select user_id from board_members
      where board_id = feedback_items.board_id
    )
  );

This approach means the authorization logic lives in the database, not the application. The same API endpoint serves both public and team views — the database automatically filters based on who is asking.

The Changelog

Every good product communicates what changed. Trackelio’s changelog lets teams publish updates that link back to the original feedback items that inspired them.

The changelog is public-facing and optimized for SEO. Each entry gets its own URL with proper meta tags, Open Graph data, and structured data markup. For products that publish frequent updates, the changelog becomes a legitimate traffic source — users search for product names plus “updates” or “changelog” regularly.

// Changelog entry structure
interface ChangelogEntry {
  id: string;
  title: string;
  slug: string;
  body: string; // Markdown content
  published_at: string;
  feedback_items: string[]; // IDs of related feedback items
  tags: ('feature' | 'improvement' | 'fix' | 'announcement')[];
}

We render the changelog body as Markdown on the server so the HTML is ready for search engine crawlers. No client-side rendering of Markdown content — that is wasted JavaScript for content that will never change after page load.

User satisfaction rating and feedback survey results displayed on screen

The Public API

Trackelio exposes a REST API that lets customers integrate feedback collection into their own products. A user can submit feedback and vote without ever visiting the Trackelio board directly.

The API design followed a few principles:

API keys per board. Each board gets a publishable key (for client-side use, scoped to read + vote + submit) and a secret key (for server-side use, full access). This mirrors the Supabase key model that our team is already familiar with.

Rate limiting at the edge. We implemented rate limiting using Cloudflare Workers in front of the API. Anonymous submissions are limited to 10 per minute per IP. Votes are limited to 30 per minute. Authenticated requests with a secret key get higher limits.

Webhook notifications. Instead of requiring customers to poll for updates, we fire webhooks on key events: new feedback submitted, status changed, vote threshold reached. Webhooks are delivered with retries and exponential backoff — we covered this pattern in more detail in Shipping Fast Without Breaking Things.

Lessons Learned

Building Trackelio reinforced several principles we apply to every project:

Denormalize early for read-heavy workloads. Feedback boards are 100:1 read-to-write. Spending a bit more on writes (triggers, denormalized counters) to speed up reads is almost always the right trade-off.

Database-level authorization is underrated. Row Level Security in PostgreSQL is powerful. Putting auth logic in the database instead of scattered across API routes reduces the surface area for security bugs.

Real-time does not mean real-time everything. We only subscribe to real-time updates for data the user is currently viewing. Subscribing to everything causes unnecessary load on both the client and the database.

Simple products are not simple to build. A feedback board looks like a weekend project. In reality, the edge cases around voting, permissions, merging, and notification logic add up to a substantial engineering effort.

If you are building a SaaS product and thinking through feedback loops, roadmap communication, or similar product infrastructure, we would be happy to share more of what we learned. Reach out at hello@threshline.com.