How to build a waitlist
How to build a waitlist with a confirm link, a place in line, and referral credits. Follow the prompts, check each step, then skip paid jumps.
You can learn how to build a waitlist as an email, a confirm link, and a place in line. GetWaitlist is that product. The catalog line is “A line for your product, with queue-jumping as the growth loop.”
You pay $15–$50 a month. That is $180–$600 a year. The verdict is “Hype, self-hosted”. The first estimate is 3 hours: a signup form, positions, referral links, opt-in.
There is no standalone teardown yet. The Typeform post already splits a form from a waitlist. The prompt post uses GetWaitlist as the 3-hour pin. This guide is the walk.
The audience is a founder collecting early access signups before launch. If you need paid queue jumping, an embeddable widget platform, or a dashboard for several waitlists, keep paying. The same v1 also replaces LaunchList and Prefinery.
How to build a waitlist without paid queue jumps
The GetWaitlist page splits the work into three rows.
The first row is the one you want. One public form. A confirm link before anyone counts. A status page with a position and a referral link. Sharing a confirmed friend moves you up a fixed number of places. That is the 3-hour v1.
The second row is 1 day. A leaderboard, email broadcasts, an embeddable widget, spam scoring. Do that after one real friend confirmed and the position moved.
The third row is 2 weeks and up. A multi-waitlist dashboard, integrations, analytics, a public API. Skip it.
If you tell an agent to “clone GetWaitlist”, 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 3 hours.
const waitlist = {
slug: 'slot',
title: 'Slot early access',
placesPerCredit: 3,
}
const signup = {
id: 'sig-ada',
email: '[email protected]',
status: 'unconfirmed',
referrerId: null,
credits: 0,
confirmToken: '8f2c1a6e-4b19-4d3a-9c7e-1a2b3c4d5e6f',
statusToken: 'c0ffee12-3456-7890-abcd-ef0123456789',
createdAt: '2026-10-26T09:00:00.000Z',
confirmedAt: null,
}
Ada types that email on https://list.flaviocopes.com. She joins the line when she clicks the confirm mail. Then she lands on /s/c0ffee12-3456-7890-abcd-ef0123456789 with a position and a share URL.
The card lists Waitlist, Signup, Referral, and ConfirmationToken. Tonight the referral is referrerId plus credits on the signup row. ConfirmationToken is confirmToken on that same row.
Pick the stack you already write
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for GetWaitlist is Astro, HTMX, and D1.
This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. HTMX is on the card because the form and the status page refresh fragments. You can still ship that with a TanStack Start server function. Do not invent a second product to send the confirm mail.
Open Build your own GetWaitlist. The guardrail is the useful sentence: count positions and referrals only after double opt-in, ignore self-referrals, and rate-limit signups with a honeypot field. The non-goal sits next to it. No email marketing suite, multi-waitlist SaaS, embeddable widget platform, or paid queue jumping.
The stack post is which tab you copy. The prompt post is how you pin the first estimate. Pick one stack and stay on it.
Prompt 1. Scaffold Queueup and stop
Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 3-hour row only. Signup form,
positions, referral links, opt-in.
Project name: Queueup.
Waitlist slug: slot.
Title: Slot early access.
Public URL is https://list.flaviocopes.com.
Places per confirmed friend: 3.
Owner is Flavio. Visitors do not create
accounts. They confirm by email.
Do not add a leaderboard, broadcasts,
an embeddable widget, or spam scoring
yet.
Do not add an email marketing suite,
a second waitlist, a widget platform,
or paid queue jumping.
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:
/for the public form/confirm/:tokenfor the email link/s/:statusTokenfor position and share/dashboardfor Flavio
You want a waitlists table and a signups table. campaigns should not appear. widgets should not appear. A stored position integer should not appear either. Rank at read time.
If the plan includes Stripe “skip the line”, a Mailchimp clone, or ten waitlist slugs, 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 list and a public form with one email field.
Open the migrations. signups.email is unique after normalize. confirm_token and status_token are unique. There is no position column. If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say unconfirmed rows do not count.
Prompt 2. Take the email, wait for the click
The mini-prompt on the card is “The double opt-in that refuses to make duplicates”. A signup starts unconfirmed. It counts for nothing until the link is clicked.
Build signup and confirmation for Queueup
on https://list.flaviocopes.com.
Normalize emails: lowercase, trim. For
gmail.com, drop plus tags and dots in
the local part so [email protected]
is the same person as [email protected].
For every other host, drop the plus tag
and leave the dots.
A new row is unconfirmed. It has no
position. It grants no referral credit.
When the normalized address already
exists, resend the confirm mail to the
existing row. Do not insert a second
row. Rate-limit resends so five submits
do not send five mails.
Confirmation tokens are unguessable,
single-use, and die with the signup.
Purge unconfirmed rows older than seven
days. A dead token offers a fresh
signup, not a stack dump. Clicking a
valid token twice confirms once.
Locally, print the confirm URL. Do not
pretend SMTP works on localhost.
Seed these unconfirmed rows:
- [email protected]
- [email protected]
Do not assign positions yet. Do not
award referral credits yet.
Done when I submit [email protected]
five times, I still have one row, a
second click on the same link does not
double-count, and an eight-day-old
token sends me back to the form.
Agents love an “you’re in, you’re #1” screen on submit. The form should say check your inbox, because she is still unconfirmed.
function normalizeEmail(raw) {
const trimmed = raw.trim().toLowerCase()
const at = trimmed.lastIndexOf('@')
const local = trimmed.slice(0, at)
const domain = trimmed.slice(at + 1)
if (domain === 'gmail.com') {
const withoutPlus = local.split('+')[0]
return `${withoutPlus.replaceAll('.', '')}@gmail.com`
}
return `${local.split('+')[0]}@${domain}`
}
normalizeEmail('[email protected]')
//'[email protected]'
Gmail is the host where dots and plus tags are the same inbox. Other hosts keep the dots. You decided that on purpose. Write it in the README.
async function confirmSignup(db, token, now) {
const signup = await db.findByConfirmToken(token)
if (!signup) return { status: 'dead' }
const ageMs = now - Date.parse(signup.createdAt)
if (ageMs > 7 * 24 * 60 * 60 * 1000) {
return { status: 'dead' }
}
if (signup.confirmedAt) {
return { status: 'already', signup }
}
const confirmed = await db.markConfirmed(signup.id, now)
return { status: 'confirmed', signup: confirmed }
}
The second click is already. Same row. Same confirmedAt.
What to check after the confirm link works
Open /. Submit [email protected]. The page says check your inbox. The dashboard still shows 0 confirmed.
Copy the printed confirm URL. Open it. Ada is confirmed. Open it again. Still one confirmed row. confirmedAt did not move.
Submit [email protected]. One row. A resend, not an insert.
Submit [email protected], then [email protected]. One Gmail row.
Invent a token, /confirm/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee. You see the form again. No 500.
If the agent assigned position 1 on submit, send prompt 2 back before you write the rank.
Prompt 3. Rank the line, then move it
The first mini-prompt on the card is “The queue position that only moves for real people”. The third is “The referral credit that ignores your other inbox”.
Do not store position as an integer you rewrite on every jump. Two confirmations at once will corrupt that column. Rank at read time from confirmation order minus credits times 3.
Build positions and referrals for Queueup
on https://list.flaviocopes.com.
Confirmed signups only. Rank by
confirmation order, then subtract
3 places per credit, then rank
again. Tie-break with confirmedAt,
then id, so two people confirming
in the same millisecond still get
different positions.
Every confirmed signup gets a
personal referral URL:
https://list.flaviocopes.com/?ref={statusToken}
statusToken is unguessable. Do not
put the email in the URL.
A referee stores at most one
referrer, set at signup, never
overwritten. Credit is granted in
the same transaction that marks
the referee confirmed. A retried
confirm cannot add a second credit.
Ignore self-referrals. If the
normalized emails match, or the
ref token is the signup's own
token, store no referrer.
If the referrer is still
unconfirmed, bank the credit and
apply it when they confirm.
Status page numbers must agree:
position, people ahead, people
behind. CSV export uses the same
rank.
Seed this walk:
- confirm Ada first
- confirm Sam through Ada's link
- Ada moves up 3 places
- replay Sam's confirm, Ada's
credits stay 1
Done when both status pages show
the same numbers, the CSV lists
confirmed people in that order,
and Ada referring [email protected]
earns nothing.
A stored position is how two people confirm in the same second and share a number. Rank it when you read.
function rankConfirmed(signups, placesPerCredit) {
const confirmed = signups
.filter((row) => row.confirmedAt)
.sort((a, b) => {
if (a.confirmedAt !== b.confirmedAt) {
return a.confirmedAt - b.confirmedAt
}
return a.id.localeCompare(b.id)
})
return confirmed
.map((row, index) => ({
...row,
score: index - row.credits * placesPerCredit,
}))
.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score
if (a.confirmedAt !== b.confirmedAt) return a.confirmedAt - b.confirmedAt
return a.id.localeCompare(b.id)
})
.map((row, index) => ({
id: row.id,
email: row.email,
position: index + 1,
ahead: index,
behind: confirmed.length - index - 1,
}))
}
Ada confirms first. Sam confirms second through her link. Ada’s score is 0 - 1 * 3. Sam’s score is 1. Ada is position 1, Sam is position 2. With more people in the middle, one credit leaps exactly three confirmed rows.
function attachReferrer(signup, referrer) {
if (!referrer) return { ...signup, referrerId: null }
if (signup.email === referrer.email) return { ...signup, referrerId: null }
if (signup.id === referrer.id) return { ...signup, referrerId: null }
return { ...signup, referrerId: referrer.id }
}
Write the credit in the confirm transaction. A later job that “adds the credit” is how a retry doubles it.
What to check after a friend moves the line
Confirm Ada. Her status page says position 1, 0 ahead.
Open Ada’s referral URL in a second browser. Submit [email protected]. Confirm Sam. Ada’s page now has 1 credit. With only two people she stays at 1, and that is correct. Seed two more confirmed people between them if you want to watch the leap of 3.
Replay Sam’s confirm URL. Ada still has 1 credit. signups still has two confirmed rows.
As Ada, submit [email protected] on her own link. No extra credit. referrerId stays empty.
Export CSV as Flavio. Only confirmed emails. Same order as the status pages. Unconfirmed Sam-before-click is missing.
If Ada’s page says 1 ahead and Sam’s page says 0 behind, the two reads used different ranks. Send prompt 3 back.
Prompt 4. Bore the bots, then export
The last mini-prompt is “The signup form that bores the bots to death”. The owner screens on the card are a dashboard and a CSV, not a newsletter composer.
Build spam defense and the owner
dashboard for Queueup on
https://list.flaviocopes.com.
Public form: one email field. Add a
honeypot named company, hidden with
CSS. A filled honeypot gets the same
check-your-inbox page. No error that
teaches the bot.
Record a form-render timestamp.
Reject submits faster than 1500ms.
Rate-limit by IP with a sliding
window that allows an office NAT
and stops a burst of hundreds.
Refuse disposable domains. Start
with mailinator.com. Say so in
plain words. Log rejected attempts
as reason counts, not raw emails.
Dashboard for Flavio:
- confirmed and unconfirmed totals
- search by email
- top referrers
- remove a signup
- CSV of confirmed people in
current position order
Do not send a broadcast. Do not
embed a widget. Do not add a
pay-to-skip button.
Done when a filled honeypot creates
zero rows, a human signup still
lands, and the CSV I download
matches both status pages.
A CAPTCHA is how you punish Ada for a problem she did not cause. Layer the cheap checks.
function acceptSignup(body, renderedAt, now) {
if (typeof body.company === 'string' && body.company.trim() !== '') {
return { ok: true, fake: true }
}
if (now - renderedAt < 1500) {
return { ok: false, reason: 'too-fast' }
}
if (normalizeEmail(body.email).endsWith('@mailinator.com')) {
return { ok: false, reason: 'disposable' }
}
return { ok: true, fake: false }
}
acceptSignup(
{ email: '[email protected]', company: 'Acme Bots' },
0,
2000,
)
//{ ok: true, fake: true }
The fake success is the point. The bot gets the same inbox page, and you never insert a row.
What to check after the bots bounce
View source on /. There is a company input. Fill it in DevTools. Submit. Check-your-inbox. Dashboard confirmed stays the same. No new row.
Submit [email protected]. A polite refusal. Aggregate log has disposable: 1. The email is not stored.
Submit for real as a new address, wait a second, confirm. Confirmed total goes up.
Remove that signup as Flavio. Status token dies. CSV no longer lists them.
If the agent added a campaign editor or a pay-to-skip button, delete it. That is the non-goal.
Failures the agent ships anyway
A position on submit
A position on submit counts people who never confirm. Assign the rank after the inbox click. The form says check your inbox.
A mutable position column
UPDATE signups SET position = position + 3 on every credit is the trap the mini-prompt names. Two confirmations in the same second will collide. Rank from confirmedAt and credits.
Credit outside the confirm write
Insert confirmed, then later credits = credits + 1, and a retried link pays twice. Same transaction. A unique (referrer_id, referee_id) does the rest.
Self-referrals through a plus tag
[email protected] is still Ada after normalize. Compare normalized emails, not the raw string.
A guessable status URL
/s/[email protected] leaks who signed up. /s/1 lets people walk the line. statusToken is random. Same for confirm.
Mail inside the submit transaction
Sending SMTP before you commit the row is how a timeout creates a second submit and a confused inbox. Write the row, then send. Locally, print the URL.
If the agent starts a journey builder or a public API, paste the non-goal and delete it.
Deploy the public form
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Queueup wants Astro, HTMX, and D1. Put it on a subdomain you already own, list.flaviocopes.com. The Astro 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.
The catalog lists transactional email as the integration. Confirm links have to leave localhost. Use a real sender on a domain you already have. Print the URL in development. Send it in production.
Attach the domain on the Worker. The form URL is the product, so list.flaviocopes.com has to resolve on the public internet, not only on localhost. Open the confirm mail on a phone on cellular.
Ship with the Slot waitlist. Sign up as Ada. Confirm. Share the referral URL with Sam. Confirm Sam. Download the CSV. Check the two status pages against that file.
Take a D1 backup before you delete the paid account.
Use it as the real list for the launch, not as a demo. If you miss a leaderboard or a widget, those are the 1-day row and the other mini-prompts on the card. Add them after a friend jumped the line.
Then cancel GetWaitlist, or LaunchList, or Prefinery, whichever one you pay for.
What to skip on purpose
Leave out an email marketing suite, a second waitlist, an embeddable widget platform, and paid queue jumping.
A leaderboard is the honest next feature, not a v1 requirement. Same credits, a public top-ten after you have ten people. Broadcasts wait until you have something to announce.
A weekly letter is Mailchimp, $20–$350 a month, about 12 hours. Double opt-in there is for a subscriber you will email again. Do not grow Queueup into Postbird.
A longer intake is Typeform, $29–$99 a month, about 2 hours. The Typeform guide is one question at a time and a table. Build that if you need seven answers. Build this if the answer is one email and a place in line.
A landing page above the form is Carrd, $2–$4 a month, about 2 hours. The Carrd walkthrough is that post. Point the hero at list.flaviocopes.com.
Slot itself is the Calendly guide. Collect the list before the booking page opens. Do not merge the two apps.
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 GetWaitlist. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only: signup form, positions, referral links, opt-in. Three hours.
Publish list.flaviocopes.com. Confirm one real email. Send the referral link to one person you know.