Build a Waitlist Page
A working waitlist page that stores signups in a database and sends a confirmation email. Deployable in an afternoon.
You want to collect emails before you launch. The job: a page with one email field that (1) saves the address to a database and (2) sends the person a confirmation so they know it worked. No accounts, no dashboard, no fluff.
This guide uses Astro for the page, Supabase for the database, and Resend for the email. All three have free tiers that are plenty for a waitlist. By the end you have a real page you can deploy and put on the internet today.
What you’ll have at the end
- A waitlist page with a single email form.
- Every signup stored in a Supabase
waitlisttable (no duplicates). - A confirmation email sent to each new signup.
- The whole thing deployed at a real URL.
Before you start
You need:
- Node 18+ and a terminal.
- A free Supabase account → https://supabase.com
- A free Resend account → https://resend.com
- 45–90 minutes.
You do NOT need a custom domain to get this working. You can add one at the end.
Step 1 — Create the Astro project
npm create astro@latest waitlist
When prompted, pick: Empty template, Yes to TypeScript (Strict), Yes to install dependencies. Then:
cd waitlist
npm run dev
Open the URL it prints (usually http://localhost:4321). You should see a near-blank Astro page. Leave the dev server running in this terminal; open a second terminal for the rest.
Install the two SDKs you’ll need:
npm install @supabase/supabase-js resend
Step 2 — Set up the Supabase table
- Go to https://supabase.com → New project. Give it a name, pick a region close to you, set a database password (save it somewhere). Wait ~2 minutes for it to spin up.
- In the left sidebar, open the SQL Editor → New query. Paste this and click Run:
create table waitlist (
id uuid primary key default gen_random_uuid(),
email text unique not null,
created_at timestamptz not null default now()
);
-- Lock the table down, then allow ONLY inserts from the public (anon) role.
alter table waitlist enable row level security;
create policy "anon can join the waitlist"
on waitlist
for insert
to anon
with check (true);
Two things this does on purpose:
email text unique→ the same email can’t be added twice. Double-submits are handled for free by the database.- Row Level Security (RLS) is on, with an insert-only policy. The public key can add a row but can NOT read the list. Your signups stay private even though the key lives in your code.
Gotcha: if you skip the RLS policy, every insert fails with a permissions error and you’ll waste 20 minutes wondering why. Don’t skip it.
-
Get your keys: left sidebar → Project Settings → API. Copy two values:
- Project URL (looks like
https://abcd1234.supabase.co) - anon public key (a long string, labeled
anon/public)
You want the anon key, NOT the
service_rolekey. The service_role key bypasses RLS and must never leave a trusted server. The anon key is safe to use here because RLS is doing the protecting. - Project URL (looks like
Step 3 — Get a Resend API key
- Go to https://resend.com → sign up → API Keys → Create API Key. Copy it
(starts with
re_). You only see it once. - For now you’ll send from Resend’s shared test address
onboarding@resend.dev.
Gotcha: until you verify your own domain in Resend,
onboarding@resend.devcan only deliver to the email address you signed up with. That’s fine for testing. To email real strangers, you verify a domain in Step 7.
Step 4 — Add your secrets
In the project root, create a file called .env:
SUPABASE_URL=https://abcd1234.supabase.co
SUPABASE_ANON_KEY=your-anon-public-key
RESEND_API_KEY=re_your_key
Then make sure it’s never committed. Check that .gitignore contains .env (Astro’s
template adds it, but confirm):
echo ".env" >> .gitignore
Gotcha: do NOT name these
PUBLIC_SUPABASE_URLetc. Astro exposes any variable prefixed withPUBLIC_to the browser. These are server-only secrets — no prefix. Tip: add asrc/env.d.tsdeclaring these three keys so they type-check under strict TypeScript.
Step 5 — Build the form submission endpoint
This is the server code that takes an email, saves it, and sends the confirmation.
Create src/pages/api/waitlist.ts:
import type { APIRoute } from "astro";
import { createClient } from "@supabase/supabase-js";
import { Resend } from "resend";
// This route must run on the server, not be baked into static HTML.
export const prerender = false;
const supabase = createClient(
import.meta.env.SUPABASE_URL,
import.meta.env.SUPABASE_ANON_KEY,
);
const resend = new Resend(import.meta.env.RESEND_API_KEY);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const POST: APIRoute = async ({ request }) => {
// 1. Read and validate the email.
let email: string;
try {
const body = await request.json();
email = String(body.email ?? "").trim().toLowerCase();
} catch {
return json({ error: "Bad request." }, 400);
}
if (!EMAIL_RE.test(email)) {
return json({ error: "Please enter a valid email." }, 400);
}
// 2. Save it. The unique constraint turns a repeat signup into a known error.
const { error } = await supabase.from("waitlist").insert({ email });
if (error) {
if (error.code === "23505") {
// Postgres unique_violation → already on the list. Not an error to the user.
return json({ ok: true, already: true });
}
console.error("supabase insert failed:", error);
return json({ error: "Something went wrong. Please try again." }, 500);
}
// 3. Send the confirmation. If this fails, the signup is still saved —
// don't punish the user for our email problem. Log it and move on.
try {
await resend.emails.send({
from: "FounderFirst <onboarding@resend.dev>", // swap for your domain in Step 7
to: email,
subject: "You're on the list",
text: "Thanks for joining the waitlist. We'll be in touch when we launch.",
});
} catch (e) {
console.error("resend send failed:", e);
}
return json({ ok: true });
};
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
Gotcha: the
export const prerender = falseline is load-bearing. Without it, Astro tries to build this route as a static file and your POST returns 404 in production.
Step 6 — Build the page and form
Replace src/pages/index.astro with this:
---
// no server data needed here
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Join the waitlist</title>
<style>
body {
font-family: system-ui, sans-serif; /* replace with your real font later */
max-width: 32rem;
margin: 6rem auto;
padding: 0 1.5rem;
color: #18181b;
}
h1 { font-size: 1.75rem; margin-bottom: 0.5rem; }
p.sub { color: #57606a; margin-top: 0; }
form { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
input {
flex: 1; padding: 0.6rem 0.75rem; font-size: 1rem;
border: 1px solid #e5e7eb; border-radius: 6px;
}
button {
padding: 0.6rem 1rem; font-size: 1rem; border: 0; border-radius: 6px;
background: #18181b; color: #fff; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
#msg { margin-top: 1rem; min-height: 1.25rem; }
</style>
</head>
<body>
<h1>Build faster with open source.</h1>
<p class="sub">Join the waitlist. No spam, just a heads-up when we launch.</p>
<form id="waitlist" novalidate>
<label for="email" style="position:absolute;left:-9999px">Email</label>
<input id="email" name="email" type="email" required placeholder="you@example.com" />
<button type="submit">Join</button>
</form>
<p id="msg" role="status" aria-live="polite"></p>
<script>
const form = document.getElementById("waitlist") as HTMLFormElement;
const msg = document.getElementById("msg") as HTMLParagraphElement;
const btn = form.querySelector("button") as HTMLButtonElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = (form.elements.namedItem("email") as HTMLInputElement).value.trim();
btn.disabled = true;
msg.textContent = "Joining…";
try {
const res = await fetch("/api/waitlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (res.ok) {
msg.textContent = data.already
? "You're already on the list."
: "You're in. Check your inbox.";
form.reset();
} else {
msg.textContent = data.error ?? "Something went wrong.";
}
} catch {
msg.textContent = "Network error. Please try again.";
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
This handles the states a real user hits: loading (“Joining…”), success (“You’re in”), already joined (“already on the list”), invalid email, and network failure. That’s the difference between a demo and something you’d ship.
Test it locally
The dev server picks up .env automatically. Reload http://localhost:4321 and submit
your own email. You should see “You’re in. Check your inbox.” Then check:
- Supabase → Table Editor →
waitlist→ your row is there. - Your inbox → the confirmation from Resend (remember: in test mode it only delivers to your own Resend account email).
Submit the same email again → “You’re already on the list.” Submit notanemail →
“Please enter a valid email.” If all three work, the logic is done.
Step 7 — Deploy
The page is static but the /api/waitlist route needs a server. Add a host adapter.
Using Vercel (free, simplest):
npx astro add vercel
Say yes to the changes. This sets Astro to render the API route on demand while keeping the page static.
Then:
git init && git add -A && git commit -m "waitlist page"
Push to a new GitHub repo, then import it at https://vercel.com → New Project.
In the Vercel project settings → Environment Variables, add the same three values
from your .env (SUPABASE_URL, SUPABASE_ANON_KEY, RESEND_API_KEY). Deploy.
Gotcha: if you forget to add the env vars in Vercel, the page loads fine but every signup returns a 500, because the server has no keys. Add them, then redeploy.
Send to real people (verify a domain)
To email anyone other than yourself, go to Resend → Domains → add your domain and
follow the DNS steps. Then change the from: line in waitlist.ts to
"FounderFirst <hello@yourdomain.com>" and redeploy.
You now have
A live waitlist page that stores signups privately and confirms them by email. Real, deployed, yours.
Troubleshooting
- Insert fails with a permissions error → you skipped the RLS policy in Step 2, or
used the wrong key. Re-run the
create policySQL; confirm you’re using the anon key, notservice_role. - POST returns 404 in production → missing
export const prerender = falseinwaitlist.ts, or you didn’t add a host adapter (Step 7). - No confirmation email → in test mode Resend only delivers to your own account email. Verify a domain to reach others.
- Signup 500s only in production → env vars not set in the host dashboard.
- “PUBLIC_” warning / secrets in the browser → rename your env vars to drop any
PUBLIC_prefix; those are exposed to the client on purpose.