# Feedback Board with Voting and a Public Roadmap

> Let users post ideas, upvote once each and comment, while you set statuses that feed a public roadmap. Next.js + Supabase with RLS.

- Category: SaaS
- Version: 1.0
- Source: https://ownlyplans.com/bhaskar/feedback-board-with-voting/

## Overview

The tool every product team ends up paying for: a place where users suggest ideas, vote on what matters and see what you are working on. This plan builds your own, with the important rules enforced in the database: one vote per person, only admins change status, and nobody can fake a vote count.

Ready-made SQL is included, so the security model is right from the first step.

Before you start: Node.js 20+, a free Supabase project and Claude Code or Cursor. About 2.5 hours.

## Tech stack

- Next.js
- shadcn/ui
- Supabase
- Tailwind

## Build steps

### 1. Start from the official starter

Create a free Supabase project first. Then run:

npx create-next-app -e with-supabase feedback-board
cd feedback-board

Copy .env.example to .env.local with your project URL and anon key, then npm run dev. Save CLAUDE.md in the root.

Prompt:

```text
Read CLAUDE.md and the starter README. In 5 bullets, explain where the Supabase clients live and how the session is refreshed. Do not change files yet.
```

**Done when:** You can sign up and sign in at localhost:3000.

### 2. Create the database

Paste supabase-schema.sql into Supabase SQL Editor and run it. Sign up once in the app, then run the commented line at the bottom with your email to make yourself admin.

Prompt:

```text
Save the schema as supabase/schema.sql. Generate TypeScript types into lib/database.types.ts and use them in the Supabase clients. Then explain in plain words what each RLS policy allows, table by table.
```

**Done when:** Four tables and the idea_scores view exist with RLS on, and npm run build passes.

### 3. Board: list and sort ideas

The home page is the board. Read from idea_scores so vote counts always come from the database.

Prompt:

```text
Build / as the board: tabs Top (most votes), New (newest) and a status filter. Each card shows title, first line of body, status badge, vote count and comment count. Server-render the list from idea_scores. Add simple pagination (20 per page).
```

**Done when:** Seeded ideas appear sorted correctly in Top and New.

### 4. Post an idea

Signed-in users can post. New ideas always start as Under review (the database policy enforces it).

Prompt:

```text
Add a "Suggest an idea" dialog (shadcn/ui) for signed-in users: title (5 to 120 chars) and details. Save with a Server Action, then go to the new idea page. Signed-out users see "Sign in to post" instead. Before saving, search existing titles and show up to 3 similar ideas with a "Vote on this instead" link.
```

**Done when:** Posting works, and typing a title like an existing one shows the similar ideas.

### 5. Voting with optimistic UI

One vote per user is guaranteed by the primary key. The UI just has to feel instant and recover if the server says no.

Prompt:

```text
Add a vote button on cards and the idea page: toggles insert/delete on votes for the current user. Update the count optimistically, roll back on error, and show "Sign in to vote" for signed-out users. Make sure a double click cannot create two requests.
```

**Done when:** Voting feels instant; trying to insert a second vote for the same idea via the API fails.

### 6. Idea page and comments

Each idea gets its own page with the full text, votes and a simple comment thread.

Prompt:

```text
Build /ideas/[id]: title, status, author, date, full body, vote button and comments (oldest first). Signed-in users can comment and delete their own comments; admins can delete any. Show relative times like "3 days ago".
```

**Done when:** You can comment, and a second account cannot delete your comment.

### 7. Admin: statuses and the public roadmap

Admins move ideas through statuses. The roadmap shows that progress to everyone.

Prompt:

```text
For admins only, add a status select on the idea page (Server Action, check is_admin on the server too). Build /roadmap: three columns Planned, In progress and Done, each listing ideas with vote counts, newest status changes first.
```

**Done when:** Changing an idea to Planned moves it onto the roadmap; a non-admin cannot change status even with a direct API call.

### 8. Security check and deploy

Prove the rules with two normal accounts and one admin, then deploy to Vercel.

Prompt:

```text
Write a test checklist and walk me through it: user A cannot vote twice, cannot set a status, cannot delete B's idea or comment, cannot make themselves admin by updating profiles; signed-out users can read but not write. Then list the env vars for Vercel and the Supabase Auth URL settings for production.
```

**Done when:** Every check passes and the board runs on your Vercel URL.

## Assets

### CLAUDE.md — Project root. Cursor users: save as AGENTS.md.

```
# Project rules: feedback board

## What we are building
A public feedback board: people sign in, post ideas, upvote (once each) and comment. The admin sets a status
(Under review, Planned, In progress, Done) and the public roadmap shows ideas grouped by status.

## Stack
- Next.js (App Router) + TypeScript + Tailwind + shadcn/ui
- Supabase: Postgres, Auth (magic link), Row Level Security

## Rules
- Security lives in the database: RLS on every table. The UI hiding a button is not security.
- One vote per user per idea, enforced by a unique constraint, not by the UI.
- Vote counts come from the database (a view or count query), never from a number the client sends.
- Only admins (profiles.is_admin) can change status or delete.
- Server Actions for writes. Never expose the service role key to the browser.
```

### supabase-schema.sql — Tables, vote view, triggers and RLS policies. Save as supabase/schema.sql.

```
-- Feedback board schema. Run in Supabase -> SQL Editor.

create table public.profiles (
  id uuid primary key references auth.users on delete cascade,
  display_name text,
  is_admin boolean not null default false,
  created_at timestamptz not null default now()
);

create table public.ideas (
  id bigint generated always as identity primary key,
  author_id uuid not null references public.profiles(id) on delete cascade,
  title text not null check (char_length(title) between 5 and 120),
  body text not null default '' check (char_length(body) <= 4000),
  status text not null default 'under_review'
    check (status in ('under_review','planned','in_progress','done','closed')),
  created_at timestamptz not null default now()
);

create table public.votes (
  idea_id bigint not null references public.ideas(id) on delete cascade,
  user_id uuid not null references public.profiles(id) on delete cascade,
  created_at timestamptz not null default now(),
  primary key (idea_id, user_id)
);

create table public.comments (
  id bigint generated always as identity primary key,
  idea_id bigint not null references public.ideas(id) on delete cascade,
  author_id uuid not null references public.profiles(id) on delete cascade,
  body text not null check (char_length(body) between 1 and 2000),
  created_at timestamptz not null default now()
);

create view public.idea_scores with (security_invoker = on) as
  select i.*, (select count(*) from public.votes v where v.idea_id = i.id) as votes
  from public.ideas i;

-- New users get a profile automatically.
create function public.handle_new_user() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
  insert into public.profiles (id, display_name)
  values (new.id, split_part(new.email, '@', 1));
  return new;
end; $$;
create trigger on_auth_user_created after insert on auth.users
  for each row execute function public.handle_new_user();

create function public.is_admin() returns boolean
language sql stable security definer set search_path = '' as $$
  select coalesce((select is_admin from public.profiles where id = (select auth.uid())), false);
$$;

alter table public.profiles enable row level security;
alter table public.ideas    enable row level security;
alter table public.votes    enable row level security;
alter table public.comments enable row level security;

create policy "profiles readable" on public.profiles for select using (true);
create policy "update own profile" on public.profiles for update
  using ((select auth.uid()) = id) with check ((select auth.uid()) = id);
-- Users may only change their display name, never is_admin.
revoke update on public.profiles from authenticated, anon;
grant update (display_name) on public.profiles to authenticated;

create policy "ideas readable" on public.ideas for select using (true);
create policy "post own idea" on public.ideas for insert
  with check ((select auth.uid()) = author_id and status = 'under_review');
create policy "admin updates ideas" on public.ideas for update using (public.is_admin());
create policy "admin deletes ideas" on public.ideas for delete using (public.is_admin());

create policy "votes readable" on public.votes for select using (true);
create policy "vote as self" on public.votes for insert with check ((select auth.uid()) = user_id);
create policy "remove own vote" on public.votes for delete using ((select auth.uid()) = user_id);

create policy "comments readable" on public.comments for select using (true);
create policy "comment as self" on public.comments for insert with check ((select auth.uid()) = author_id);
create policy "delete own or admin" on public.comments for delete
  using ((select auth.uid()) = author_id or public.is_admin());

-- After you sign up once, make yourself admin:
-- update public.profiles set is_admin = true where id = (select id from auth.users where email = 'you@example.com');
```

### Next.js + Supabase starter — Official starter with cookie-based auth.

https://github.com/vercel/next.js/tree/canary/examples/with-supabase

### Supabase Row Level Security guide — Read once: it is what keeps votes honest.

https://supabase.com/docs/guides/database/postgres/row-level-security


## Build instructions by tool

### Claude Code

Open a terminal in an empty folder, run `claude`, then paste the prompt.

```text
Build the "Feedback Board with Voting and a Public Roadmap" app from the OwnlyPlans blueprint at https://ownlyplans.com/bhaskar/feedback-board-with-voting/.
Stack: Next.js, shadcn/ui, Supabase, Tailwind.
Follow the plan's steps in order: Start from the official starter → Create the database → Board: list and sort ideas → Post an idea → Voting with optimistic UI → Idea page and comments → Admin: statuses and the public roadmap → Security check and deploy.
Download the full plan (Markdown) from the link above and keep it in the project root as PLAN.md.
Ask me before adding anything not in the plan. (Prepared for Claude Code.)
```

### Cursor

Open an empty folder in Cursor, start an Agent chat, then paste the prompt.

```text
Build the "Feedback Board with Voting and a Public Roadmap" app from the OwnlyPlans blueprint at https://ownlyplans.com/bhaskar/feedback-board-with-voting/.
Stack: Next.js, shadcn/ui, Supabase, Tailwind.
Follow the plan's steps in order: Start from the official starter → Create the database → Board: list and sort ideas → Post an idea → Voting with optimistic UI → Idea page and comments → Admin: statuses and the public roadmap → Security check and deploy.
Download the full plan (Markdown) from the link above and keep it in the project root as PLAN.md.
Ask me before adding anything not in the plan. (Prepared for Cursor.)
```

### Lovable

Start a new Lovable project and paste the prompt as your first message.

```text
Build the "Feedback Board with Voting and a Public Roadmap" app from the OwnlyPlans blueprint at https://ownlyplans.com/bhaskar/feedback-board-with-voting/.
Stack: Next.js, shadcn/ui, Supabase, Tailwind.
Follow the plan's steps in order: Start from the official starter → Create the database → Board: list and sort ideas → Post an idea → Voting with optimistic UI → Idea page and comments → Admin: statuses and the public roadmap → Security check and deploy.
Download the full plan (Markdown) from the link above and keep it in the project root as PLAN.md.
Ask me before adding anything not in the plan. (Prepared for Lovable.)
```

## Definition of done

- Every build step is complete and the app runs locally.
- The main user flow works end to end.
- Share your build on OwnlyPlans with a demo link, build time and tool.
