SendBeam

Newsletter signup on an Astro site

An Astro component that adds a subscriber to a SendBeam list straight from the browser, with the form ID in an environment variable and no server-side code.

View as Markdown

Astro is built for content sites, and content sites want a newsletter. The friction is that a statically built Astro site has no place to put a subscriber, so people either bolt on a third-party embed with its own styles and script, or wire up an SSR endpoint and an API key to talk to a mailing service. This guide takes a third route: a small Astro component whose client script posts the signup as JSON to SendBeam's public form endpoint. The form ID is the only configuration, it lives in an environment variable, and the same list can later be mailed with campaigns or automations from SendBeam.

Prerequisites

  • An Astro project. Static output is fine; nothing here needs an adapter or SSR.
  • A SendBeam workspace with a list to add subscribers to. On the Free plan every list is double opt-in; on paid plans it is a per-list setting.
  • A signup form in SendBeam pointing at that list (created in the first step below).
  • Optional: a Cloudflare Turnstile widget if the form will sit on a busy page.

Steps

  1. Create the signup form in SendBeam. Under Forms, add a form with type Signup and choose the list under Add to List. Set the thank-you message; if the list is double opt-in, make it say "Check your inbox to confirm", because that message is exactly what the component will show. On the form's page, tick First name under Form Fields if you want to collect it, then copy the form ID from the API Endpoint line.
  2. Put the form ID in the environment. Astro only exposes variables prefixed PUBLIC_ to client code, and that is what you want here: the form ID is not a secret (it is visible to anyone who submits the form), it only identifies which form receives the post. Add the same variable in your host's dashboard for production builds.
    # .env  (PUBLIC_ variables are inlined into the client bundle by Astro)
    PUBLIC_SENDBEAM_SIGNUP_FORM_ID=00000000-0000-0000-0000-000000000000
    # Optional: only if the form has Turnstile enabled in SendBeam
    PUBLIC_TURNSTILE_SITE_KEY=
  3. Create the component. The frontmatter reads the form ID at build time and writes the endpoint onto the form as a data-endpoint attribute, so the client script stays generic. The script is an ordinary Astro <script>: it is bundled, type-checked and de-duplicated per page. It records the render time for the timing check, posts the fields, and swaps the inputs for the message SendBeam returns. The Turnstile widget and its script are only emitted when a site key is configured.
    ---
    // src/components/NewsletterForm.astro
    const formId = import.meta.env.PUBLIC_SENDBEAM_SIGNUP_FORM_ID;
    const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY || '';
    const endpoint = `https://sendbeam.io/api/forms/${formId}`;
    ---
    
    <form class="sb-signup" data-endpoint={endpoint} novalidate>
      <label>
        <span>Email</span>
        <input type="email" name="email" autocomplete="email" required placeholder="you@example.com" />
      </label>
      <label>
        <span>First name (optional)</span>
        <input type="text" name="first_name" autocomplete="given-name" />
      </label>
    
      <!-- Hidden check field. Each form has its own name: copy it from the form's Embed tab. -->
      <div style="position:absolute;left:-9999px" aria-hidden="true">
        <label>Leave this empty <input type="text" name="YOUR_FORM_CHECK_FIELD" tabindex="-1" autocomplete="off" /></label>
      </div>
    
      {siteKey && <div class="cf-turnstile" data-sitekey={siteKey} data-size="flexible"></div>}
    
      <button type="submit">Subscribe</button>
      <p class="sb-status" role="status" aria-live="polite"></p>
    </form>
    
    {siteKey && <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
    
    <script>
      // Astro bundles this once per page, however many forms are on it.
      document.querySelectorAll<HTMLFormElement>('form.sb-signup').forEach((form) => {
        const status = form.querySelector<HTMLElement>('.sb-status')!;
        const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
    
        form.addEventListener('submit', async (event) => {
          event.preventDefault();
          const data = new FormData(form);
          const payload = {
            email: String(data.get('email') || ''),
            first_name: String(data.get('first_name') || ''),
            YOUR_FORM_CHECK_FIELD: String(data.get('YOUR_FORM_CHECK_FIELD') || ''),
            turnstile_token: String(data.get('cf-turnstile-response') || ''),
          };
    
          button.disabled = true;
          status.textContent = 'Subscribing…';
          try {
            const res = await fetch(form.dataset.endpoint!, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(payload),
            });
            const json = await res.json();
            if (res.ok && json.success) {
              form.querySelectorAll('label, button, .cf-turnstile').forEach((el) => ((el as HTMLElement).hidden = true));
              status.textContent = json.message;
              if (json.redirect_url) setTimeout(() => location.assign(json.redirect_url), 1500);
            } else {
              status.textContent = json.error || 'Something went wrong. Please try again.';
              (window as any).turnstile?.reset();
            }
          } catch {
            status.textContent = 'Could not reach the server. Please try again.';
          } finally {
            button.disabled = false;
          }
        });
      });
    </script>
    
    <style>
      .sb-signup { display: grid; gap: .75rem; max-width: 28rem; position: relative; }
      .sb-signup label { display: grid; gap: .25rem; font-size: .9rem; }
      .sb-signup input { padding: .6rem .75rem; border: 1px solid #cbd5e1; border-radius: .5rem; font: inherit; }
      .sb-signup button { padding: .65rem 1rem; border: 0; border-radius: .5rem; background: #0b1020; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
      .sb-signup button:disabled { opacity: .6; cursor: default; }
      .sb-status:empty { display: none; }
    </style>
  4. Use it on a page. Drop the component wherever the signup belongs. If you place it in the layout footer, every page gets it and the script still runs once.
    ---
    // src/pages/index.astro
    import Layout from '../layouts/Layout.astro';
    import NewsletterForm from '../components/NewsletterForm.astro';
    ---
    <Layout title="Home">
      <h1>My site</h1>
      <section>
        <h2>Get the newsletter</h2>
        <NewsletterForm />
      </section>
    </Layout>
  5. Allow the requests in your CSP, if you have one. The component makes one request to sendbeam.io and, with Turnstile, loads a script and an iframe from challenges.cloudflare.com. On Cloudflare Pages that is a _headers file; on Netlify a [[headers]] block in netlify.toml; on Vercel the headers key in vercel.json.
    # public/_headers  (Cloudflare Pages)  — only needed if you send a CSP header
    /*
      Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://sendbeam.io https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:
  6. Set the protection on the form. In SendBeam, open the form's Protection section, add your site's origin under Allowed sites, and paste the Turnstile keys if you are using it. Then deploy.
lightbulb
The astro-cloudflare-pages starter is this component plus a contact form, a .env.example and the Pages headers file, ready to clone. Framework notes are on the Astro integration page.

Test it

Run npm run dev and submit the form with an address you control. If you have already set allowed sites, the local origin will be refused with a 403 and the component will display SendBeam's error text, which is the allow-list doing its job: either add http://localhost:4321 temporarily or test on the deployed site. On success the inputs disappear and the thank-you message appears. Then check three things in SendBeam: the address is under Contacts with source form; it is a member of the list (pending, if the list is double opt-in, until the confirmation link is clicked); and, if you enabled Email me about new subscribers on the form, you received a note.

To exercise the endpoint without the browser:

curl -s -X POST https://sendbeam.io/api/forms/YOUR_FORM_ID \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.example.com" \
  -d '{"email":"you@example.com","first_name":"Test"}'
# → {"success":true,"message":"Thanks for subscribing!","redirect_url":null}

Submitting the same address twice is safe. The contact is updated rather than duplicated, and if the list is double opt-in, at most one confirmation email goes to an address every ten minutes, however many times the form is sent. Each confirmation email counts towards the workspace's monthly allowance.

Dealing with spam

Signup forms attract a different kind of abuse from contact forms: address-stuffing, where a bot subscribes strangers, and list-bombing, where one address is submitted to thousands of forms. Double opt-in is the real defence against both, because nothing is mailed to an address that never confirmed, and SendBeam applies its normal layers on top: per-visitor rate limits and a daily cap per form, the Allowed sites origin check, the automated-submission checks, and Turnstile when a secret is set. Addresses that previously bounced or complained are accepted with the normal thank-you and then ignored, so a public form can never be used to re-subscribe someone who left. For an Astro site with real traffic the recommendation is simple: allowed sites, Turnstile, and a double opt-in list.

Why not a Formspree or Web3Forms endpoint?

Both are good at what they do. Web3Forms in particular is generous and works the same way as this guide, with an access key in the page instead of a form ID. The difference is what happens after the POST. A forms service emails you the submission and stops. You then need somewhere to keep the subscribers, a way to confirm them, and a way to send the newsletter, which means a second product and a way to move addresses between the two. With SendBeam the POST creates the contact, joins the list, runs the confirmation flow and fires any automation you have set up, and the campaign editor is in the same place.

The trade-off is that SendBeam is priced by contacts across your account (see plans), and on the Free plan every list is double opt-in whether you want it or not. If you only want a message in your inbox and nothing else, a forms-only service is the smaller tool.

Next steps

Starter kits

This component, wired up in a site you can deploy, is in the starter kits. The Astro one is this guide as a running project; the others do the same thing in their own templating:

Every starter covers the same five things — newsletter signup, contact form, double opt-in, unsubscribe and spam protection — reads its form IDs from environment variables or site config, and is MIT licensed. Browse them all in sendbeam-starters.

Stuck, or found a gap? Ask in the community — questions, tips and every release note, with this page as the source of truth.