DEV Community

Fatih İlhan
Fatih İlhan

Posted on

Building LoopSignal Part 2: The Public Board, Voting, and Prioritization Without the Overhead

In part one, I talked about the very first thing a user does with LoopSignal: submit feedback anonymously, with as little friction as possible.

A quick update since that post: LoopSignal is now listed on GitHub Marketplace. That felt like a good milestone to mention before going deeper into how the product is built.

But a submission going into a void is not a product. It is a contact form.

The next piece was building the public board — the place where approved feedback becomes visible, where users vote, where statuses are tracked, and where a team can see what their users actually want.

This article is about how I built that, and the decisions that shaped it.

What the public board needed to do

Before I wrote any UI, I listed what the board actually had to accomplish.

It needed to:

  • show only approved posts, not raw submissions
  • let anonymous users vote without creating an account
  • let logged-in users have their votes persisted properly
  • support sorting by votes and by recency
  • support filtering by status and category
  • link to a changelog for completed items
  • work as both a standalone page and eventually an embeddable widget

That is a reasonable surface area for a core feature. Not small, but not bloated either.

The page is a server component

The public board page at /p/[slug] is a React Server Component. That was an easy call.

The page needs data from four places before anything renders:

  • the project itself (name, settings, categories)
  • the approved posts, filtered and sorted
  • the current user's existing votes (if they are logged in)
  • the comments attached to visible posts

Doing all of that on the server means zero loading states for the initial render. The page arrives fully hydrated. The only client-side work is the vote button itself.

I fetch the project and the auth session in parallel, then the posts and existing votes in parallel after that:

const [{ data: project }, { data: { user } }] = await Promise.all([
  admin.from("projects").select("*").eq("slug", slug).single(),
  supabase.auth.getUser(),
]);

const [{ data: posts }, userVotes] = await Promise.all([
  postsQuery,
  user
    ? admin.from("votes").select("post_id").eq("user_id", user.id)
    : Promise.resolve({ data: [] }),
]);
Enter fullscreen mode Exit fullscreen mode

The result is that on a cold load, the page shows the right content — including whether the user has already voted on something — without any client round-trips.

Moderation is what makes the board clean

This goes back to a decision made in part one.

Every submission starts as pending. A team member reviews it and either approves or rejects it before it appears publicly.

On the board page, the query is always filtered to approved posts only:

let postsQuery = admin
  .from("posts")
  .select("*")
  .eq("project_id", typedProject.id)
  .eq("moderation_status", "approved");
Enter fullscreen mode Exit fullscreen mode

This is a small thing, but it has a big product effect.

A public board without moderation becomes a mess within a week. Duplicate posts, nonsense, spam, test submissions from your own team. Moderation keeps the board worth reading, which keeps users worth engaging.

The moderation queue lives in the dashboard, which I will cover in a later article.

Voting without an account

This was the hardest design problem in the board.

On one hand, I wanted voting to work for logged-in users with proper persistence. On the other hand, I did not want anonymous users to be blocked from participating. Voting is how the board surfaces signal. Blocking anonymous votes would severely reduce the data quality.

The solution I landed on: a localStorage-based voter key for anonymous users, combined with proper user ID tracking for authenticated ones.

When the VoteButton mounts, it reads or generates a UUID from localStorage:

function getVoterKey(): string {
  let key = localStorage.getItem("ls_voter_key");
  if (!key) {
    key = crypto.randomUUID();
    localStorage.setItem("ls_voter_key", key);
  }
  return key;
}
Enter fullscreen mode Exit fullscreen mode

That key gets sent with every vote action. On the server side, the action checks whether there is an authenticated user. If there is, the vote is recorded against the user ID. If not, the voter key is used instead.

This means:

  • Anonymous users can vote, and their votes persist across page reloads on the same browser
  • Logged-in users always have their votes properly tracked
  • The same post cannot be double-voted from the same source

It is not a perfect anti-fraud system. Someone who clears localStorage can vote again. But for the scale LoopSignal is targeting — small product teams, not large open-source projects — it is the right tradeoff. Friction-free participation is worth more than bulletproof deduplication at this stage.

Optimistic updates in the vote button

The VoteButton is a client component. When a user clicks it, the count updates immediately before the server confirms anything.

function handleVote() {
  const newVoted = !hasVoted;
  setHasVoted(newVoted);
  setVoteCount((c) => (newVoted ? c + 1 : Math.max(0, c - 1)));

  startTransition(async () => {
    const result = await voteOnPost(postId, voterKey);
    if (result.error) {
      setHasVoted(!newVoted);
      setVoteCount((c) => (newVoted ? Math.max(0, c - 1) : c + 1));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

If the server action fails, the state reverts. This gives the button an instant feel while still being correct.

I use useTransition here rather than a loading spinner. The button stays interactive during the transition, which is better for something this small. A spinner on a vote button would feel like overkill.

Sorting and filtering without full-page reloads

The board supports two sort modes — most voted and newest — and two sets of filters: status and category.

I deliberately chose URL-based filtering rather than client-side state.

Every filter and sort option is a link, not a button that updates local state. Clicking "Planned" changes the URL to /p/my-project?status=planned. The server component re-runs with the new params and returns a fresh set of posts.

const { status: statusFilter, sort: sortParam, category: categoryFilter } = await searchParams;
Enter fullscreen mode Exit fullscreen mode

This approach has a few advantages that felt worth the slightly more complex URL building:

  • Filtered views are bookmarkable and shareable
  • Browser back/forward works naturally
  • No hydration mismatches
  • No client-side data re-fetching logic to maintain

The tradeoff is that filter changes do cause a full navigation. For this kind of read-heavy, low-interaction page, that is perfectly acceptable. Nobody is rapidly toggling filters on a feedback board.

Prioritization without becoming roadmap software

This was a product decision as much as a technical one.

I deliberately kept prioritization simple: vote counts, visible on the board, sortable, filterable by status. That is it.

No scoring formulas. No weighted votes. No segment breakdowns. No "effort vs. impact" matrix.

There are products that do all of that, and they are right for certain teams. But for the kind of user LoopSignal is built for — an indie developer or a small team — that kind of overhead gets in the way rather than helping.

The premise I kept coming back to: if something has 47 votes and the next item has 12, you do not need a scoring algorithm to tell you what users want.

The most useful prioritization tool at this scale is just visibility. See what people asked for, see how many people asked for it, see what you have already committed to. That is enough.

If a team grows to a size where they need weighted scoring and segment filtering, they will probably need a different product. That is fine.

Status labels bridge feedback and roadmap

Each post has a workflow_status that the team controls: open, planned, in progress, completed, closed.

These are visible on the public board as badges on each card.

const statusLabels: Record<string, { label: string; variant: ... }> = {
  open: { label: "Open", variant: "secondary" },
  planned: { label: "Planned", variant: "default" },
  in_progress: { label: "In Progress", variant: "default" },
  completed: { label: "Completed", variant: "outline" },
  closed: { label: "Closed", variant: "outline" },
};
Enter fullscreen mode Exit fullscreen mode

This is the minimum viable roadmap view.

Users can see what is being worked on and what has shipped without the team having to maintain a separate roadmap page. The feedback board is the roadmap, with statuses as the signal.

That status change is also what drives the changelog — but that is for a later article.

Admin comments as public replies

One subtle feature: team members can leave comments on posts, and those comments appear on the public board under the post they belong to.

It is rendered simply:

{(commentsByPost.get(post.id) || []).map((comment) => (
  <div className="flex gap-2 text-sm bg-muted/50 rounded-md p-2 mt-1">
    <MessageSquare className="h-3.5 w-3.5 mt-0.5 text-primary shrink-0" />
    <div>
      <p className="text-foreground">{comment.body}</p>
      <p className="text-xs text-muted-foreground mt-0.5">
        Admin reply · {new Date(comment.created_at).toLocaleDateString()}
      </p>
    </div>
  </div>
))}
Enter fullscreen mode Exit fullscreen mode

This is a small thing that has an outsized effect.

When users see a team member has replied to a request — even just to say "good idea, we will think about it" or "this is planned for next sprint" — it signals that the board is being read by real people.

Empty feedback boards feel abandoned. Boards with admin replies feel alive.

What I learned building this piece

A few things became clear while working on it.

Server components are the right default for data-heavy pages. The board does a lot of reading — project, posts, votes, comments — and doing all of it on the server gives a much better first-render experience than a loading skeleton and three client fetches.

URL-based filtering ages well. Client-side filter state always needs special handling: back button, shareability, initial load from URL. URL-based filtering just works for all of those, without the extra logic.

Anonymous voting is worth the complexity. The voter key approach adds some nuance, but the alternative — blocking anonymous votes — would have killed participation on boards for products that do not require user accounts. The friction cost is too high.

Keeping the board simple keeps it useful. Every time I thought about adding a feature to the board — trending scores, vote breakdowns by country, satisfaction ratings — I asked whether it would make the core use case better or just noisier. Most ideas got cut. The ones that stayed are the ones in this article.

What's next

In the next article, I will go into the GitHub integration: how a team can link an approved feedback post to a GitHub issue with one click, and how issue events automatically close the feedback post and notify users when the issue is resolved.

That is the "loop" in LoopSignal — and it is the part of the product I am most happy with.

If you are building something similar, my takeaway from this part is simple:

Start with the board that shows what users want.

The prioritization and roadmap features only matter after you have real signal coming in.

Top comments (0)