Skip to main content

Add a waitlist in an Astro site

Collect signups before launch and turn them into users afterwards. Each signup gets a referral code, invites go out in first-come order, and the whole list exports as CSV.

Keys

Browser calls use a publishable key (nv_pub_live_…), which is safe to ship. Reading and admin calls use a secret key (nv_live_…) on your server only. Create both under Dashboard → API keys.

PUBLIC_NORDVA_PUBLISHABLE_KEY=nv_pub_live_…  # .env
NORDVA_SECRET_KEY=nv_live_…  # .env, server only

01Browser: signup form (publishable key)

src/components/WaitlistForm.astro

---
// Rendered on the server; the script below runs in the browser.
const KEY = import.meta.env.PUBLIC_NORDVA_PUBLISHABLE_KEY;
---
<form id="waitlist">
  <input name="email" type="email" required placeholder="[email protected]" />
  <button type="submit">Join waitlist</button>
  <p id="waitlist-msg" role="status"></p>
</form>
<script define:vars={{ KEY }}>
  const form = document.getElementById("waitlist");
  const msg = document.getElementById("waitlist-msg");
  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = new FormData(form).get("email");
    const ref = new URLSearchParams(location.search).get("ref") || undefined;
    const res = await fetch("https://api.nordva.dev/v1/waitlist/signups", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, referrer_code: ref }),
    });
    const { data, error } = await res.json();
    msg.textContent = error ? error.message : (data.already_registered ? "Already on the list" : "You're in") + " — #" + data.position + ". Share: " + data.referral_link;
  });
</script>

The publishable key is safe to ship to browsers. Register your site's origin under Project → Allowed origins first, or the request is refused with ORIGIN_NOT_ALLOWED.

02Server: read stats, API route

src/pages/api/waitlist-stats.ts

import type { APIRoute } from "astro";

export const GET: APIRoute = async () => {
  const res = await fetch("https://api.nordva.dev/v1/waitlist/stats", {
    method: "GET",
    headers: {
      Authorization: `Bearer ${import.meta.env.NORDVA_SECRET_KEY}`,
    },
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  const { data } = await res.json();
  // data.total_signups, data.confirmed_count, data.invited_count, data.top_referrers
  return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } });
};

03Zero-code alternative: hosted widget

any HTML page

<script src="https://cdn.nordva.dev/v1/waitlist.js"
  data-key="nv_pub_live_…"
  data-placeholder="[email protected]"
  data-button="Join waitlist"
  data-success="You're on the list"
  data-theme="auto"></script>
<nordva-waitlist></nordva-waitlist>

04Verify from a terminal

curl -s https://api.nordva.dev/v1/waitlist/stats \
  -H "Authorization: Bearer nv_live_…" | jq .data

Behaviour worth knowing

  • Every response is { data, error, meta }. On failure error.code is a stable string such as VALIDATION_ERROR, PLAN_LIMIT_REACHED or RATE_LIMITED, with a remediation message.
  • POST and PATCH requests accept an Idempotency-Key header; the same key with the same body returns the original response for 24 hours.
  • Rate limits per key: 30 requests a minute on Free, 120 on Indie, 500 on Builder. A 429 carries Retry-After.
  • Browser calls with a publishable key must come from an origin registered on the project, otherwise the API answers ORIGIN_NOT_ALLOWED.