How to build a Canny alternative
How to build a Canny alternative with search, one vote per person, and a public status. Follow the prompts, check each step, then skip AI and CRM.
You can learn how to build a Canny alternative as a board people search before they post. Canny is that product. The catalog line is “Collect requests, count votes, close the loop.”
You pay $79–$99 a month. That is $948–$1,188 a year. The verdict is “Painfully replaceable”. The first estimate is 10 hours: a feedback board, votes, and statuses.
I already wrote the teardown, Build a Canny alternative. This guide is the walk. Prompts in order, then what to look at.
The audience is a product team collecting feedback from existing customers. If you need NPS surveys, CRM enrichment, AI deduplication, or a second workspace, keep paying. The same v1 also replaces Featurebase, Nolt, and Upvoty.
How to build a Canny alternative without AI or CRM
The Canny page splits the work into three rows.
The first row is the one you want. A public or private board. Search before a new post. One vote per person. Four statuses: planned, in progress, complete, declined. Title, detail, category, author, a count. That is the 10-hour v1.
The second row is 1 week. Comments, a roadmap, a changelog, single sign-on, email notifications. Do that after one real request moved from open to planned.
The third row is 1 month and up. AI deduplication, CRM sync, a multi-workspace SaaS. Skip it.
If you tell an agent to “clone Canny”, it aims at the third row. Pin every prompt to the first one.
Here is the shape of the data. Nothing else has to exist at 10 hours.
const board = {
slug: 'slot',
title: 'Slot feedback',
visibility: 'private',
}
const request = {
id: 'req-dark-mode',
title: 'Dark mode for the booking page',
body: 'The Slot booking page blinds people after 9pm.',
category: 'ui',
author: 'U-ada',
status: 'open',
votes: 1,
}
Ada searches “dark mode”, finds nothing, posts that row, and becomes the first vote. You later set status to planned. She can see the request is on the list without emailing you.
The card lists User, Request, Vote, Comment, and ChangelogEntry. This weekend you need the first three. Comment and ChangelogEntry wait for the week-long row.
Pick the stack you already write
Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Canny is Astro, HTMX, and D1.
This site is an Astro app on Cloudflare, so that is the path I default to. Use Rails if that is your weekday language. HTMX is on the card because search and vote refresh fragments. You can still ship that with Livewire or a Server Action. Do not invent a second product to get a vote button.
Open Build your own Canny. The guardrail is the useful sentence: prevent vote stuffing, and never expose private customer or team-note data. The non-goal sits next to it. No NPS surveys, CRM enrichment, AI deduplication, or multiple workspaces.
The stack post is which tab you copy. The scope post already uses Canny as the 10-hour example. The prompt post is how you pin the first estimate. Pick one stack and stay on it.
Prompt 1. Scaffold Wantboard and stop
Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 10-hour row only. Feedback board,
votes, statuses.
Project name: Wantboard.
Board slug: slot.
Board title: Slot feedback.
Visibility: private.
Owner is Flavio. Teammate is Jordan Lee.
Customers are Ada and Sam Rivera.
Do not add comments, a roadmap, a changelog,
single sign-on, or email notifications yet.
Do not add NPS surveys, CRM enrichment, AI
deduplication, or a second workspace.
When the plan is done, stop and wait. Do not write
implementation code until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want pages like these:
/boards/slotfor the list/boards/slot/requests/req-dark-modefor one request/boards/slot/triagefor status edits
You want tables like users, requests, votes. comments should not appear. changelog_entries should not appear. A status_events table can wait until prompt 4.
Identity comes from the app’s sign-on payload, verified on the server. One human maps to one local user. A private board shows nothing to an invalid payload: no titles, no counts, no 404 that confirms the slug.
If the plan includes HubSpot, an embeddings index, or a second product workspace, send it back. Paste the non-goal again.
When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.
What to check after the scaffold
Start the app. Sign in as Flavio. You should see an empty Slot board and a create action.
Open the migrations. votes has user_id and request_id. There is a unique constraint on (user_id, request_id). requests has an integer votes counter and a status column. If the README is a placeholder, stop and make the agent write a real one before you add logic.
Sign out. /boards/slot discloses nothing. Guessing /boards/slot/requests/req-dark-mode discloses nothing too.
Prompt 2. Search the board, then post
The mini-prompt on the card is “Search before you post, merge when they post anyway”. Tonight you need the search half. Merge waits for the week-long row.
Implement the board and the request editor
for Wantboard on
https://board.flaviocopes.com/boards/slot.
Ada and Sam can search titles and bodies
before they post. As they type a title,
show matches with vote counts. Stemmed
keyword overlap is enough. Typing
"dark" should surface Dark mode for
the booking page. "night theme" can
miss until a shared stem appears.
A new request needs title, body, and a
category. Title is required, 8 to 80
characters. Body is required, 20 to 2000
characters. Categories in v1: ui, billing,
integrations.
The author is the signed-in user from the
verified payload. Do not trust a name the
browser sent. The author becomes the first
vote in the same transaction.
Seed these requests:
- Dark mode for the booking page / ui / Ada
- Export invoices as CSV / billing / Sam
- Slack notifications for bookings /
integrations / Ada
Sort the board by top, new, or recent
activity. Filter by category and status.
Phone-sized view keeps the search box
above the list. A title wraps.
Do not add comments or merge yet.
Done when I can type "dark" as Sam, see
Dark mode for the booking page with 1 vote,
and posting "Night theme on Slot" is
something I can still do if I ignore the
match.
A second “pretty preview” board is how you ship a layout that hides the search box on a phone. The public list is the editor.
function searchRequests(requests, query) {
const stems = query
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter((word) => word.length >= 3)
.map((word) => word.replace(/ing$|ed$|s$/, ''))
return requests.filter((request) => {
const hay = `${request.title} ${request.body}`.toLowerCase()
return stems.some((stem) => hay.includes(stem))
})
}
searchRequests(
[
{
title: 'Dark mode for the booking page',
body: 'The Slot booking page blinds people after 9pm.',
},
],
'night theme dark',
)
//[{ title: 'Dark mode for the booking page', body: 'The Slot booking page blinds people after 9pm.' }]
dark survives. theme is not in the existing row, and that is fine. The match is the request Ada already posted.
What to check after search and post
Open /boards/slot as Ada. Three cards. Dark mode and Slack alerts show Ada as author. Export CSV shows Sam.
Type dark in the search box. Dark mode stays. Export CSV leaves. Refresh. The three cards are back, same authors.
Sign in as Sam. Type Night theme on Slot as a title. Dark mode appears with 1 vote before the submit button is useful.
Post it anyway. You now have four requests. Merge is next week’s job. If the agent adds a merge button tonight, delete it.
Narrow the window to 390 pixels. The search box stays on screen. Cards stack. You do not get a two-column soup.
Prompt 3. One vote per person
The first mini-prompt on the card is “One person, one vote, no matter how hard they click”.
A unique constraint on (user_id, request_id) is the guarantee. The application check is a courtesy. A double-click, a retry, or a replayed request hits the constraint, changes nothing, and returns the current state.
Build voting for Wantboard on
https://board.flaviocopes.com/boards/slot.
One vote per (user, request). The unique
constraint is the lock. Repeated vote
requests return the current request and
do not increment.
Keep the displayed total as a counter on
the request, updated in the same
transaction as the vote row. A second
write will drift.
Identity comes from the verified sign-on
payload. A name or id from the browser
is not a voter.
Unvoting deletes the row and decrements
in the same transaction. Rapid
vote-unvote-vote serializes to the last
click.
Each request carries a client-generated
operation id. A double submit with the
same id writes once.
Seed Jordan as a second browser. Do not
open a WebSocket.
Done when I hammer Vote on Dark mode as
Sam, refresh, and the count is 2, Ada
plus Sam, and a replayed POST returns
the same 2.
Keep the insert and the increment together. A counter you bump later is how “3 votes” and two rows stop matching.
async function castVote(db, { userId, requestId, opId }) {
const seen = await db.findOp(opId)
if (seen) {
return db.getRequest(requestId)
}
try {
return await db.transaction(async (tx) => {
await tx.insertVote({ userId, requestId, opId })
await tx.run(
'UPDATE requests SET votes = votes + 1 WHERE id = ?',
requestId,
)
return tx.getRequest(requestId)
})
} catch (error) {
if (error.code === 'UNIQUE') {
return db.getRequest(requestId)
}
throw error
}
}
The UNIQUE path is the double click. The opId path is the retry that never reached the insert. Both return the row you already have.
Unvote is the same shape with a delete and votes = votes - 1. Refuse a decrement that would go below the remaining row count.
What to check after the vote holds
Open Dark mode as Ada. The count is 1. She already voted when she posted. Clicking Vote again leaves it at 1.
Sign in as Sam. Vote. The count is 2. Refresh. Still 2. Replay the same POST from the network panel. Still 2.
Unvote as Sam. The count is 1. Vote again. The count is 2. Open the database. votes rows for req-dark-mode equal the counter.
Sign in as Flavio with a forged user_id=ada in the form. The server ignores it. Ada’s vote is still one row.
If the counter is 3 and the table has 2 rows, the agent wrote the increment outside the transaction. Send prompt 3 back.
Prompt 4. Set a status people can see
Statuses on the page are planned, in progress, complete, and declined. Record every transition with actor and time. The public detail page shows the current one. The history is for you.
Notifications belong on the week-long row. Do not enqueue mail this weekend.
Build status changes for Wantboard.
Flavio and Jordan can set a request to
planned, in progress, complete, or
declined. Ada and Sam can read the
current status. They cannot change it.
Record every transition as an immutable
event with actor, time, from, to, and
an optional note. The public detail
page shows the current status. The
event list is staff-only.
A declined status can carry a short
public note, 20 to 240 characters.
"We are not building a native app this
year" is a valid note.
Seed this walk:
- Dark mode for the booking page -> planned
by Flavio
- Export invoices as CSV stays open
- Slack notifications for bookings ->
declined by Jordan, with the native
app note
Do not send email. Do not add a
changelog entry. Do not open a Linear
issue.
Done when Ada opens Dark mode and sees
planned, Sam opens Slack notifications
and sees declined plus the note, and
Ada cannot POST a status change.
function moveStatus(request, next, actor) {
const allowed = ['open', 'planned', 'in progress', 'complete', 'declined']
if (!allowed.includes(next)) {
throw new Error(`Unknown status ${next}`)
}
return {
request: { ...request, status: next },
event: {
requestId: request.id,
from: request.status,
to: next,
actor,
at: '2026-10-25T09:00:00.000Z',
},
}
}
moveStatus(
{ id: 'req-dark-mode', status: 'open' },
'planned',
'flavio',
)
//{
// request: { id: 'req-dark-mode', status: 'planned' },
// event: {
// requestId: 'req-dark-mode',
// from: 'open',
// to: 'planned',
// actor: 'flavio',
// at: '2026-10-25T09:00:00.000Z',
// },
//}
Write the event in the same transaction as the new status. A status with no event is how triage history goes missing on Monday.
What to check after a status moves
Open Dark mode as Ada. The badge says planned. There is no edit control.
Open Slack notifications as Sam. The badge says declined. The note about the native app is visible.
Sign in as Jordan. Move Export CSV to in progress. Refresh as Sam. The badge updated.
Try the same POST as Ada. The server returns 403. The status is still in progress.
Open status_events. Dark mode has one row, open to planned, actor Flavio. There is no mail queue table. If the agent added notifications or changelog_entries, delete those migrations.
Failures the agent ships anyway
A courtesy check instead of a unique constraint
Hiding the Vote button after Ada clicks does not stop a second POST. Replay it. The unique index has to refuse. Same for a user id the browser invented.
A counter in a second write
INSERT INTO votes then a later UPDATE requests is how the number on the card drifts. Put both in one transaction. A cheap job that compares requests.votes to COUNT(*) from votes is worth writing once you have traffic. The mini-prompt already says that.
Search after the post
If Ada can only search the board after she submits, you will spend Saturday merging by hand. Live search is the 10-hour half of that mini-prompt. Merge, vote unions, and old-URL redirects are the week-long half. Skip merge tonight. Do not skip the search box.
Team notes as a boolean
The last mini-prompt is “Team notes the customers can never see”. That feature is on the week-long row. If the agent adds notes early, they must be a second type, not a is_internal flag on comments. A boolean is one missing WHERE away from leaking “this customer is churning” onto the public page.
Write the leak test first if notes appear. Create a note, then assert it is missing from the public API, the rendered page, the CSV, and any outbound email.
Email inside the transaction
Notifying 200 voters inside the status write is the classic rollback bug. The Canny walkthrough covers the week-long mailer. This weekend, record the event and stop.
If the agent starts a HubSpot import or an embeddings index, paste the non-goal and delete it.
Deploy the public board
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Wantboard wants Astro, HTMX, and D1. Put it on a subdomain you already own, board.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the endpoints. The hosting post is that path: the adapter, then the bindings, then the custom domain.
Attach the domain on the Worker. The board URL is the product, so board.flaviocopes.com/boards/slot has to resolve on the public internet, not only on localhost.
Ship with the seed board and the four people. Open it as Ada on a laptop. Open it as Sam on a phone on cellular, not the same Wi-Fi. Search dark. Vote. Confirm Flavio can set planned and Ada sees it after a refresh. Paste the current Canny export, or type the ten requests people already repeat in email.
If you miss comments or a changelog, those are the week-long row and the other mini-prompts on the card. Add them after a customer asks.
Take a D1 backup, then cancel Canny, or Featurebase, or Nolt, or Upvoty, whichever one you pay for.
What to skip on purpose
Leave out NPS surveys, CRM enrichment, AI deduplication, and anything that looks like a second workspace.
Comments are the honest next feature, not a v1 requirement. Same request page, a public thread under the status. The roadmap is planned, in progress, and complete columns. Changelog entries wait until something is complete. Beamer is $49–$299 a month, about 4 hours, if a public feed is the only rent you pay.
Linear is $10–$16 a person a month, about 12 hours, because the product is issues, a cycle, and an assignee. The Linear teardown is that walk. Customers cannot search Linear. They cannot see a public status. Keep Linear for the work you accepted. Keep Wantboard for the work you have not accepted yet. When a request becomes planned, file the Linear issue yourself. Do not sync the two in v1.
Typeform is $29–$99 a month, about 2 hours. The Typeform guide is a form and a table. Build this if people already have an account and they want to vote.
A board of cards you drag is the Trello guide. Lists and a position integer, not a vote count.
A public board that says whether the product is up is the status page guide. Components and incidents, not feature requests.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Canny. Copy the AHA prompt, or the stack you already write.
Tell the agent the first estimate only: feedback board, votes, statuses. Ten hours.
Publish board.flaviocopes.com/boards/slot. Search before the first post. Move one real request to planned this weekend.