What you’re building
A version scoped to what most teams actually use in Canny:
- Boards (for example “Feature requests” and “Bugs”) that anyone can read.
- Posts that signed-in users create, with one upvote per person and comments.
- Statuses (Open, Planned, In progress, Done, Closed) and a public roadmap page.
- Admin tools: change status, merge duplicates, delete spam.
- A simple changelog page that links shipped posts.
Not in version 1: Autopilot-style AI that pulls feedback out of support tools, revenue-weighted prioritisation, Jira/Linear sync and SSO. Add them later only if you miss them.
Stack
- Supabase for Postgres, magic-link sign-in and row-level security. The free plan is enough to start; move to Pro ($25/month) once customers depend on it, so the project never pauses.
- Lovable or Claude Code to write the app (React + Tailwind is fine).
- Netlify or Vercel to host it on
feedback.yourdomain.com.
Data model
Run this in the Supabase SQL editor. It creates the tables, keeps vote counts in sync, and locks everything down with row-level security (RLS) and explicit grants before any data goes in. Visitors can read boards, posts and comments. Who voted, and everyone’s profile, stay private: posts and comments carry a copy of the author’s display name instead.
-- The app talks to Supabase over REST, not GraphQL. Turning GraphQL off closes an endpoint
-- you don't use and clears Security Advisor warnings. Skip this line if you use GraphQL.
drop extension if exists pg_graphql;
-- One profile per signed-in user, created automatically at sign-up.
create table public.profiles (
id uuid primary key references auth.users on delete cascade,
display_name text not null default 'Someone' check (char_length(display_name) between 1 and 40),
is_admin boolean not null default false,
created_at timestamptz not null default now()
);
create table public.boards (
id uuid primary key default gen_random_uuid(),
slug text not null unique check (slug ~ '^[a-z0-9-]{2,40}$'),
name text not null,
description text,
is_public boolean not null default true,
created_at timestamptz not null default now()
);
create table public.posts (
id uuid primary key default gen_random_uuid(),
board_id uuid not null references public.boards on delete cascade,
author_id uuid not null references auth.users on delete cascade default auth.uid(),
author_name text not null default 'Someone',
title text not null check (char_length(title) between 4 and 140),
body text check (char_length(body) <= 5000),
status text not null default 'open'
check (status in ('open', 'planned', 'in_progress', 'done', 'closed')),
vote_count integer not null default 0,
legacy_votes integer not null default 0,
merged_into uuid references public.posts on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index posts_board_status on public.posts (board_id, status, vote_count desc);
create index posts_author_recent on public.posts (author_id, created_at desc);
create table public.votes (
post_id uuid not null references public.posts on delete cascade,
user_id uuid not null references auth.users on delete cascade default auth.uid(),
created_at timestamptz not null default now(),
primary key (post_id, user_id)
);
create index votes_user on public.votes (user_id);
create table public.comments (
id uuid primary key default gen_random_uuid(),
post_id uuid not null references public.posts on delete cascade,
author_id uuid not null references auth.users on delete cascade default auth.uid(),
author_name text not null default 'Someone',
body text not null check (char_length(body) between 1 and 3000),
created_at timestamptz not null default now()
);
create index comments_post on public.comments (post_id, created_at);
create table public.changelog (
id uuid primary key default gen_random_uuid(),
title text not null,
body text,
post_ids uuid[] not null default '{}',
published_at timestamptz not null default now()
);
-- Helpers live in a schema the API doesn't expose.
create schema if not exists private;
grant usage on schema private to anon, authenticated;
create function private.is_admin() returns boolean
language sql stable security definer set search_path = public as $$
select coalesce((select is_admin from public.profiles where id = (select auth.uid())), false)
$$;
-- Counts outside RLS, so the posts insert policy never queries posts itself
-- (a policy that reads its own table fails with "infinite recursion").
create function private.posts_today(uid uuid) returns integer
language sql stable security definer set search_path = public as $$
select count(*)::integer from public.posts where author_id = uid and created_at > now() - interval '1 day'
$$;
revoke execute on function private.is_admin(), private.posts_today(uuid) from public;
grant execute on function private.is_admin() to anon, authenticated;
grant execute on function private.posts_today(uuid) to authenticated;
-- New user → profile.
create function private.handle_new_user() returns trigger
language plpgsql security definer set search_path = public as $$
begin
insert into public.profiles (id, display_name)
values (new.id, coalesce(nullif(split_part(new.email, '@', 1), ''), 'Someone'));
return new;
end $$;
create trigger on_auth_user_created after insert on auth.users
for each row execute function private.handle_new_user();
-- Copy the author's display name onto new posts and comments.
create function private.set_author_name() returns trigger
language plpgsql security definer set search_path = public as $$
begin
new.author_name := coalesce((select display_name from public.profiles where id = new.author_id), 'Someone');
return new;
end $$;
create trigger posts_author_name before insert on public.posts
for each row execute function private.set_author_name();
create trigger comments_author_name before insert on public.comments
for each row execute function private.set_author_name();
-- Keep vote_count in step with votes.
create function private.count_votes() returns trigger
language plpgsql security definer set search_path = public as $$
begin
if tg_op = 'INSERT' then
update public.posts set vote_count = vote_count + 1 where id = new.post_id;
return new;
end if;
update public.posts set vote_count = vote_count - 1 where id = old.post_id;
return old;
end $$;
create trigger votes_count after insert or delete on public.votes
for each row execute function private.count_votes();
-- Authors may edit their own text; only admins change status or merge.
create function private.guard_post_update() returns trigger
language plpgsql set search_path = public as $$
begin
if current_user in ('anon', 'authenticated') and not private.is_admin()
and (new.status is distinct from old.status or new.merged_into is distinct from old.merged_into) then
raise exception 'Only admins can change the status of a post or merge it';
end if;
if new.title is distinct from old.title or new.body is distinct from old.body then
new.updated_at := now();
end if;
return new;
end $$;
create trigger posts_guard before update on public.posts
for each row execute function private.guard_post_update();
revoke execute on function private.handle_new_user(), private.set_author_name(), private.count_votes(),
private.guard_post_update() from public;
-- Row-level security
alter table public.profiles enable row level security;
alter table public.boards enable row level security;
alter table public.posts enable row level security;
alter table public.votes enable row level security;
alter table public.comments enable row level security;
alter table public.changelog enable row level security;
create policy "Read your own profile, admins read all" on public.profiles for select to authenticated
using (id = (select auth.uid()) or (select private.is_admin()));
create policy "Edit your own profile" on public.profiles for update to authenticated
using (id = (select auth.uid())) with check (id = (select auth.uid()));
create policy "Public boards are readable" on public.boards for select to anon, authenticated
using (is_public or (select private.is_admin()));
create policy "Admins add boards" on public.boards for insert to authenticated
with check ((select private.is_admin()));
create policy "Admins edit boards" on public.boards for update to authenticated
using ((select private.is_admin())) with check ((select private.is_admin()));
create policy "Admins delete boards" on public.boards for delete to authenticated
using ((select private.is_admin()));
-- A post is readable when its board is (the boards policy decides that).
create policy "Posts on readable boards are readable" on public.posts for select to anon, authenticated
using (exists (select 1 from public.boards b where b.id = board_id));
create policy "Signed-in users post to public boards, 10 a day" on public.posts for insert to authenticated
with check (
author_id = (select auth.uid())
and exists (select 1 from public.boards b where b.id = board_id and b.is_public)
and private.posts_today((select auth.uid())) < 10
);
create policy "Authors edit their own open posts, admins any" on public.posts for update to authenticated
using ((author_id = (select auth.uid()) and status = 'open') or (select private.is_admin()))
with check (author_id = (select auth.uid()) or (select private.is_admin()));
create policy "Admins delete posts" on public.posts for delete to authenticated
using ((select private.is_admin()));
create policy "See your own votes" on public.votes for select to authenticated
using (user_id = (select auth.uid()));
create policy "Vote as yourself on open posts" on public.votes for insert to authenticated
with check (
user_id = (select auth.uid())
and exists (select 1 from public.posts p where p.id = post_id and p.status <> 'closed' and p.merged_into is null)
);
create policy "Remove your own vote" on public.votes for delete to authenticated
using (user_id = (select auth.uid()));
create policy "Comments on readable posts are readable" on public.comments for select to anon, authenticated
using (exists (select 1 from public.posts p where p.id = post_id));
create policy "Comment as yourself" on public.comments for insert to authenticated
with check (author_id = (select auth.uid()) and exists (select 1 from public.posts p where p.id = post_id));
create policy "Delete your own comments, admins any" on public.comments for delete to authenticated
using (author_id = (select auth.uid()) or (select private.is_admin()));
create policy "Published changelog is public" on public.changelog for select to anon, authenticated
using (published_at <= now() or (select private.is_admin()));
create policy "Admins write the changelog" on public.changelog for insert to authenticated
with check ((select private.is_admin()));
create policy "Admins edit the changelog" on public.changelog for update to authenticated
using ((select private.is_admin())) with check ((select private.is_admin()));
create policy "Admins delete changelog entries" on public.changelog for delete to authenticated
using ((select private.is_admin()));
-- Table access. New Supabase projects don't grant it automatically, and RLS still decides
-- which rows each role sees. Column lists stop users setting vote counts, authors or statuses.
revoke all on public.profiles, public.boards, public.posts, public.votes, public.comments, public.changelog
from anon, authenticated;
grant select on public.boards, public.posts, public.comments, public.changelog to anon, authenticated;
grant select, update (display_name) on public.profiles to authenticated;
grant insert, update, delete on public.boards, public.changelog to authenticated;
grant insert (board_id, title, body) on public.posts to authenticated;
grant update (title, body, status, merged_into) on public.posts to authenticated;
grant delete on public.posts to authenticated;
grant select, delete on public.votes to authenticated;
grant insert (post_id) on public.votes to authenticated;
grant insert (post_id, body) on public.comments to authenticated;
grant delete on public.comments to authenticated;
grant select, insert, update, delete on public.profiles, public.boards, public.posts, public.votes,
public.comments, public.changelog to service_role;
Then sign in to your app once and make yourself an admin in the SQL editor: update public.profiles set is_admin = true where id = (select id from auth.users where email = 'you@yourdomain.com');
Build it in 8 prompts
Paste these one at a time into Lovable or Claude Code. Check each step works before moving on; it saves credits.
1. Create a React + Tailwind app connected to my Supabase project (project URL and publishable key from Settings → API Keys). Add magic-link sign-in with Supabase Auth: a "Sign in" button in the header opens an email field, and signed-in users see their display name and "Sign out". Never put the secret (service role) key in the browser.
2. The database already exists (tables profiles, boards, posts, votes, comments and changelog, with RLS). Generate TypeScript types from it and build the home page: list public boards from `boards`, each linking to /b/:slug.
3. Build the board page /b/:slug. Show posts for that board sorted by (vote_count + legacy_votes) descending, with tabs to filter by status (All, Open, Planned, In progress, Done) and a search box that filters by title. Each row shows the vote count, title, author_name, status badge and comment count. Hide posts that have merged_into set.
4. Add voting. Each post row gets an upvote button. When a user is signed in, load their own rows from `votes` (RLS only returns their own) to know which posts they've upvoted. Clicking toggles the vote by inserting { post_id } or deleting their row. Update the count optimistically. Signed-out users see a prompt to sign in.
5. Build the post page /p/:id with the title, body (render as plain text with line breaks, never as HTML), author_name, status, vote button and comments with their author_name. Signed-in users can add a comment by inserting { post_id, body } and delete their own. Show "Merged into …" with a link when merged_into is set.
6. Add a "New post" form on each board that inserts { board_id, title, body } (4–140 character title). As the user types the title, show up to 5 existing posts with similar titles (ilike search) so they can vote instead of posting a duplicate. If the insert is rejected by row-level security, show "You've reached today's limit of 10 posts. Try again tomorrow."
7. Build /roadmap with three columns: Planned, In progress, Done (last 90 days), across all public boards, sorted by votes. Build /changelog listing `changelog` entries newest first, each linking to its posts.
8. Add an /admin area visible only when the signed-in user's own profile has is_admin = true. Admins can change a post's status or merge it into another post by updating status and merged_into on the post (the database allows only admins to change those), delete spam posts and comments, create boards and write changelog entries. Add page titles, meta descriptions, empty states and make every page work on a phone.
Security checklist
- Supabase Security Advisor shows no warnings, and RLS is on for every table.
- Only the publishable (anon) key is in the browser. No secret or service role key is in the browser code or the repo.
- Signed-out users can read boards, posts and comments but can’t post, vote or comment (try it in a private window).
- A normal user can’t change
status,vote_countoris_admin, or see other people’s votes and profiles (try it from the browser console with the Supabase client). - The 11th post in a day from one account is rejected.
- Post bodies render as text, never as raw HTML.
- Auth emails come from your domain (Supabase → Auth → SMTP), so magic links don’t land in spam.
Move off Canny
- Export your posts from Canny as CSV from the admin area, or pull them with Canny’s API.
- Create your boards, then import posts in the SQL editor or the table editor (both run with full rights). Set
author_idto your own admin user, put the original author’s name inauthor_name, map Canny’s status names to yours, and put each post’s vote total intolegacy_votes, since individual voters can’t be moved. - Run both boards side by side for a week, then link your app’s “Feedback” button to the new board and add a note on the old one.
- Cancel Canny after the last renewal you’ve paid for.
After launch
- About 30 minutes a month: update dependencies, check the Supabase advisor, clear spam.
- Watch for posts that ask for things your board can’t do yet. That’s the moment to decide whether an integration is worth building or whether a hosted tool is worth paying for again.
Before you build
Check the numbers for your own team first. Stack Scan reads a card statement in your browser and shows whether building beats what you actually pay.
Get new blueprints by email
One email when a new blueprint is out. Tell us which tool you want next.