Newsletter signup form

Newsletter signup form6 min read

Email signup form on a static site, with no server

Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.

Preet SutharFounder, Ikiform

On a static site the form posts from the reader's browser straight to Ikiform, because there's no server of yours to post to. Keep your email tool's API key out of the site entirely: the browser only knows your public Ikiform endpoint, and a relay hosted somewhere else adds the subscriber. Write the form once as a partial or include, pass a different source per placement, and add one small script for the no-reload version.

What's different here

A static site is files on a host: GitHub Pages, Netlify, Cloudflare Pages, an S3 bucket. Nothing you write runs on a server when a reader submits a form. That rules out the usual shortcut of calling your email tool's API from the page, because anything the browser runs, anyone can read.

Email tool API keys are rarely limited to adding one subscriber. A key that can add a subscriber can usually also read, export or delete the whole list. Put it in a static site's JavaScript and it ships to every visitor, and from there to anyone who opens the developer tools.

Where the email tool's API key lives on a static sitekey in the pagekey in a relaystaticpageAPI key initsJavaScriptemail toolAPIanyone cancall ityour listreadable byanyonestaticpagepublicendpoint onlyIkiformspam check,signedwebhookrelayholds theAPI keyemail tooladds thesubscriberkeypostsignedkeyWhere the email tool's API key lives on a static sitekey in the pagekey in a relaystatic pageAPI key in its JavaScriptemail tool APIanyone can call ityour listreadable by anyonestatic pagepublic endpoint onlyIkiformspam check, signed webhookrelayholds the API keyemail tooladds the subscriberkeypostsignedkey
  1. With the key in the page, every visitor downloads it, and anyone can call the email tool's API with it.
  2. With a relay, the page knows only your Ikiform endpoint, which accepts form posts and nothing else.
  3. Ikiform filters spam and sends a signed webhook to the relay.
  4. The relay, hosted outside the static site, holds the API key and adds the subscriber.
The form endpoint is safe to publish. The API key never is.

Write the form once

A newsletter form shows up in several templates: the footer, the end of each post, a newsletter page. Make it a partial that takes a source value, so each placement records itself. The markup is identical in each generator; only the template syntax for source changes.

<form data-newsletter action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-label="Email" />
  <label><input name="consent" type="checkbox" value="true" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input type="hidden" name="source" value="{{ .source | default "footer" }}" />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed/" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p role="status" hidden></p>
</form>

Then drop it into a template with the placement's name:

{{ partial "newsletter.html" (dict "source" "post") }}
  • In Astro, use <Newsletter source="post" />. A static Astro build can't run Actions, so the component posts straight to Ikiform like the others.
  • Add source to the form's field definition in Ikiform, as text with a short maximum length. With fields defined, an input the definition doesn't list is refused with unknown_field.
  • _redirect must be an https: URL on one of the form's Allowed domains. Make the page: /subscribed/ with a line telling readers to check their inbox.

One script for every form on the page

A post page can have two newsletter forms, one after the article and one in the footer. This script handles every form marked data-newsletter, and when one succeeds it replaces all of them, so nobody is asked to subscribe again a few inches lower. Load it once from your base layout.

static/newsletter.js
for (const form of document.querySelectorAll("form[data-newsletter]")) {
  const status = form.querySelector('[role="status"]');
  const button = form.querySelector("button");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    button.disabled = true;
    status.hidden = false;
    status.textContent = "Subscribing";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.ok) {
        for (const other of document.querySelectorAll("form[data-newsletter]")) {
          other.replaceChildren(Object.assign(document.createElement("p"), {
            textContent: "Almost done. Check your inbox for a confirmation link.",
          }));
        }
        return;
      }
      if (response.status === 429 && response.headers.has("Retry-After")) {
        status.textContent = "Too many tries from this network. Wait a minute and try again.";
      } else {
        const errors = result.error.fields ?? {};
        status.textContent = errors.email?.[0] ?? errors.consent?.[0] ?? result.error.message;
      }
    } catch {
      form.submit();
      return;
    }
    button.disabled = false;
  });
}
  • If fetch fails, for example behind a strict network filter, the script falls back to form.submit(), which posts the plain way and lands on /subscribed/.
  • A 429 gets its own message. Readers on one office or campus network share an IP and can hit the per-visitor limit together.
  • The form works with JavaScript off. The script only changes how the answer is shown.

Readers with JavaScript off land on /subscribed/, so make that page do the work the script does for everyone else: say a confirmation email is on its way, name the sender to look for, mention the spam folder, and link back to the blog. It's an ordinary page in your generator, built like any other, and it's also where you can point the email tool's confirmation link afterwards.

Where the relay lives

The relay needs a public HTTPS URL that runs code. Your static host may or may not offer one:

Static hostRelay options
GitHub Pages, S3No functions. Use a Cloudflare Worker on a custom domain, or an automation tool
NetlifyA Netlify Function on your site's domain, or any of the above
Cloudflare PagesA separate Worker on a custom domain. Ikiform refuses pages.dev and workers.dev URLs
VercelA function on your domain. Static output can sit next to an API route

The relay code for each email tool is on the Kit, MailerLite and beehiiv pages. With no code at all, point the webhook at Zapier or n8n and use their email tool action. The webhooks docs list the URL rules.

Static sites often have deploy previews on a separate domain. Posts from a domain that isn't in Allowed domains are marked as spam or refused, so add the preview domain while you test, or test on the live domain and localhost.

If your site sends a Content Security Policy

Many static hosts let you set headers from a file, such as _headers on Netlify and Cloudflare Pages. If yours sends a Content-Security-Policy, the browser blocks the form unless the policy allows Ikiform. form-action covers the plain post and connect-src covers the script's fetch:

_headers
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com

A blocked post shows up as a CSP error in the browser console and never reaches Ikiform, so nothing appears in the dashboard either.

Check it works

  1. Add your live domain and localhost under Allowed domains.
  2. Build the site and open a post page. Both forms are there, each with its own source value in the page source.
  3. Subscribe from the post form with an address you own. Both forms change to the check-your-inbox line.
  4. Turn JavaScript off and subscribe from the footer. You land on /subscribed/.
  5. Open Submissions in the dashboard. The two signups show post and footer as their source.
  6. Search the built site for your email tool's API key. It isn't there.

Gotchas

ProblemWhat to do
Nothing in the dashboard, and a CSP error in the consoleAdd https://ikiform.com to form-action and connect-src in your Content-Security-Policy.
422 with unknown_field for sourceAdd source to the form's field definition, or remove the hidden input.
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.
The browser lands on Ikiform's thank-you page instead of yours_redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.