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.
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.
- With the key in the page, every visitor downloads it, and anyone can call the email tool's API with it.
- With a relay, the page knows only your Ikiform endpoint, which accepts form posts and nothing else.
- Ikiform filters spam and sends a signed webhook to the relay.
- The relay, hosted outside the static site, holds the API key and adds the subscriber.
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
sourceto 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 withunknown_field. _redirectmust be anhttps: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.
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
fetchfails, for example behind a strict network filter, the script falls back toform.submit(), which posts the plain way and lands on/subscribed/. - A
429gets 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, 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.
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:
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comA 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
- Add your live domain and
localhostunder Allowed domains. - Build the site and open a post page. Both forms are there, each with its own
sourcevalue in the page source. - Subscribe from the post form with an address you own. Both forms change to the check-your-inbox line.
- Turn JavaScript off and subscribe from the footer. You land on
/subscribed/. - Open Submissions in the dashboard. The two signups show
postandfooteras theirsource. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.