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 waitlist table (no duplicates).
  • A confirmation email sent to each new signup.
  • The whole thing deployed at a real URL.

Before you start

You need:

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

  1. Go to https://supabase.comNew 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.
  2. In the left sidebar, open the SQL EditorNew 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.

  1. Get your keys: left sidebar → Project SettingsAPI. 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_role key. 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.


Step 3 — Get a Resend API key

  1. Go to https://resend.com → sign up → API KeysCreate API Key. Copy it (starts with re_). You only see it once.
  2. 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.dev can 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_URL etc. Astro exposes any variable prefixed with PUBLIC_ to the browser. These are server-only secrets — no prefix. Tip: add a src/env.d.ts declaring 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 = false line 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:

  • SupabaseTable Editorwaitlist → 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.comNew 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 policy SQL; confirm you’re using the anon key, not service_role.
  • POST returns 404 in production → missing export const prerender = false in waitlist.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.