# 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.

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/static-site

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.

Diagram: Where the email tool's API key lives on a static site

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.

**Hugo** (`layouts/partials/newsletter.html`)

```html
<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>
```

**Jekyll** (`_includes/newsletter.html`)

```html
<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="{{ include.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>
```

**Eleventy** (`_includes/newsletter.njk`)

```html
<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 or "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>
```

**Astro** (`src/components/Newsletter.astro`)

```astro
---
interface Props {
  source?: string;
}
const { source = "footer" } = Astro.props;
---

<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} />
  <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:

**Hugo** (`layouts/_default/single.html`)

```html
{{ partial "newsletter.html" (dict "source" "post") }}
```

**Jekyll** (`_layouts/post.html`)

```html
{% include newsletter.html source="post" %}
```

**Eleventy** (`_includes/post.njk`)

```html
{% set source = "post" %}
{% include "newsletter.njk" %}
```

- 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`:

```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 host | Relay options |
| --- | --- |
| GitHub Pages, S3 | No functions. Use a Cloudflare Worker on a custom domain, or an automation tool |
| Netlify | A Netlify Function on your site's domain, or any of the above |
| Cloudflare Pages | A separate Worker on a custom domain. Ikiform refuses `pages.dev` and `workers.dev` URLs |
| Vercel | A function on your domain. Static output can sit next to an API route |

The relay code for each email tool is on the [Kit](https://ikiform.com/guides/newsletter-signup/kit.md), [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md) and [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md) pages. With no code at all, point the webhook at [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) and use their email tool action. The [webhooks docs](https://ikiform.com/docs/webhooks.md) 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`:

```text
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

| Problem | What to do |
| --- | --- |
| Nothing in the dashboard, and a CSP error in the console | Add `https://ikiform.com` to `form-action` and `connect-src` in your Content-Security-Policy. |
| `422` with `unknown_field` for `source` | Add `source` to the form's field definition, or remove the hidden input. |
| `403 origin_not_allowed`, or every post lands in spam | The 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

### Can I add a newsletter signup form to a static site without a backend?

Yes. The form posts from the browser to a form backend such as Ikiform, which stores and filters each signup. A relay or automation tool hosted elsewhere passes it to your email tool, so the static site itself runs no server code.

### Can I call my email tool's API directly from the static page?

Don't. Any key in the page's JavaScript is visible to every visitor, and email tool keys can usually read or change the whole list. Keep the key in a relay that runs outside the site.

### Does this work on GitHub Pages?

Yes. The form is plain HTML and posts to Ikiform. GitHub Pages can't run the relay, so host it on a Cloudflare Worker with a custom domain, or use an automation tool such as Zapier or n8n.

### Who sends the confirmation email?

Your email tool. Ikiform never emails the person who signs up. The relay adds the reader as unconfirmed and the tool sends its own confirmation link.

## More guides

- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [beehiiv](https://ikiform.com/guides/newsletter-signup/beehiiv.md): Connect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
- [Next.js newsletter form](https://ikiform.com/guides/newsletter-signup/nextjs.md): A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
