Newsletter Signup with an Edge Function
The newsletter signup box is one of the most common embeds on static sites and one of the most expensive per pixel. The email provider's embed code loads its own script, a stylesheet, often a web font and a tracking pixel — on every page, because the box sits in the footer — and renders a form whose markup, labels and error messages the site does not control. For a feature used by well under one percent of visitors, it adds 60–120 KB and several third-party requests to every page view.
This guide replaces a provider embed with a native HTML form and a small Cloudflare Worker that validates input, checks a Turnstile token, records consent, and calls the email provider's API for double opt-in. It works without JavaScript, degrades gracefully, and adds no third-party requests to pages. It is part of Serverless Functions for Static Sites, following the pattern in Handling Form Submissions on a Static Site.
Prerequisites
- An email provider with an API for adding contacts with double opt-in (Buttondown, Mailchimp, Brevo, ConvertKit and others).
- A static site on Cloudflare (Workers static assets, Pages, or a zone in front of another host).
- A Turnstile site key and secret, and the provider API key stored as Worker secrets.
What the Embed Cost
The provider's embed on the developer blog measured here, on every page:
| Item | Cost |
|---|---|
| Third-party requests | 7 (script, CSS, font, pixel, config, 2 images) |
| Bytes (compressed) | 94 KB |
| Main-thread time (Lighthouse, 4× CPU) | 110 ms |
| Accessibility issues (axe) | 3 (unlabelled input, low-contrast button, missing error association) |
| Signups per month | 212 |
Step 1: A Native Form
The form is plain HTML in the site's footer partial, styled with the site's CSS and accessible by construction:
<form class="newsletter" action="/api/subscribe" method="post" novalidate>
<label for="nl-email">Get new articles by email</label>
<div class="newsletter-row">
<input id="nl-email" name="email" type="email" autocomplete="email" required aria-describedby="nl-help nl-status">
<button type="submit">Subscribe</button>
</div>
<p id="nl-help" class="newsletter-help">One email per article, no tracking. Unsubscribe anytime.</p>
<input type="text" name="website" tabindex="-1" autocomplete="off" class="visually-hidden" aria-hidden="true">
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA…" data-appearance="interaction-only"></div>
<p id="nl-status" role="status" aria-live="polite"></p>
</form>
The hidden website field is a honeypot: bots fill it, humans do not. Turnstile's script loads only when the form scrolls into view (an IntersectionObserver injects it), and interaction-only appearance means most readers never see a challenge.
Step 2: The Edge Function
// src/subscribe.js
export async function handleSubscribe(request, env) {
const isJson = request.headers.get('Accept')?.includes('application/json');
const form = await request.formData();
const email = String(form.get('email') ?? '').trim().toLowerCase();
const reply = (status, message, redirect) => isJson
? Response.json({ message }, { status })
: Response.redirect(new URL(redirect, request.url), 303);
if (form.get('website')) return reply(200, 'Thanks!', '/newsletter/thanks/'); // honeypot: pretend success
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || email.length > 254) return reply(400, 'Please enter a valid email address.', '/newsletter/error/');
const ip = request.headers.get('CF-Connecting-IP');
const { success } = await env.SUBSCRIBE_LIMIT.limit({ key: `sub:${ip}` });
if (!success) return reply(429, 'Too many attempts. Please try again in a minute.', '/newsletter/error/');
if (!(await verifyTurnstile(form.get('cf-turnstile-response'), ip, env.TURNSTILE_SECRET))) {
return reply(400, 'Please complete the check and try again.', '/newsletter/error/');
}
const res = await fetch('https://api.buttondown.com/v1/subscribers', {
method: 'POST',
headers: { Authorization: `Token ${env.BUTTONDOWN_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email_address: email, type: 'unactivated', referrer_url: request.headers.get('Referer') ?? '', tags: ['site-footer'] })
});
if (!res.ok && res.status !== 409) return reply(502, 'Something went wrong on our side. Please try again later.', '/newsletter/error/');
await env.CONSENT.put(`nl:${email}`, JSON.stringify({ at: new Date().toISOString(), page: request.headers.get('Referer'), text: 'Get new articles by email' }));
return reply(200, 'Almost done — check your inbox to confirm.', '/newsletter/thanks/');
}
A 409 (already subscribed) returns the same success message, so the form does not reveal whether an address is on the list. type: 'unactivated' makes the provider send its confirmation email — double opt-in — and only confirmed addresses receive newsletters. The consent record stores what the reader agreed to, when and where, in Workers KV; the rate limit follows Rate Limiting Edge Functions.
Step 3: Progressive Enhancement
Without JavaScript, the form posts, the function redirects to a thank-you or error page with a 303, and everything works. With JavaScript, a small script intercepts submission, sends it with fetch and an Accept: application/json header, and shows the message in the status region without leaving the page:
form.addEventListener('submit', async (e) => {
e.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
const res = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
const { message } = await res.json().catch(() => ({ message: 'Something went wrong. Please try again.' }));
form.querySelector('#nl-status').textContent = message;
if (res.ok) form.reset(); else button.disabled = false;
});
The status paragraph has role="status", so screen readers announce the result. The script is 0.5 KB and loads with the rest of the site's module.
Operating It
The function has three things to watch. Provider API failures return a 502 to the reader and log the provider's status; an alert fires if more than five occur in an hour, which on this site caught one provider outage and one expired API key. The honeypot and Turnstile rejection counts are graphed, because a sudden drop can mean bots have adapted and a sudden rise can mean Turnstile is misconfigured and blocking real readers. And the consent records are exported monthly to the same storage as other compliance data, so a reader's request to see or delete their data can be answered from one place. Unsubscribes are handled entirely by the provider's links in each email, and a nightly job deletes consent records for addresses the provider reports as unsubscribed, so the site does not keep data longer than needed.
Changing Providers Later
A side benefit of owning the form and function is that the email provider becomes a detail behind the site's own endpoint. When the blog later moved from one provider to another for pricing reasons, the change was a new API call in the function and an export and import of confirmed subscribers — the form, the site's markup and every page stayed exactly as they were, and readers noticed nothing. With the embed, the same move would have meant replacing markup and scripts in the footer of every template, re-checking accessibility and consent text, and re-measuring the new provider's page cost. Keeping the integration server-side turned a redesign into a small backend change.
Measured Impact
| Measure | Provider embed | Native form + edge function |
|---|---|---|
| Third-party requests per page | 7 | 0 |
| Page weight added (compressed) | 94 KB | 1.1 KB (form + script) |
| INP p75 on article pages (field) | 148 ms | 104 ms |
| Accessibility issues in the form (axe) | 3 | 0 |
| Confirmed signups per month (3-month average) | 212 | 241 |
| Fake signups reaching the provider per month | ~180 | ~3 |
Signups rose slightly — possibly because the form now looked like part of the site and loaded instantly — and fake signups nearly vanished, which improved the provider's reported deliverability.
Pitfalls & Rollback
- API keys in the page. Keep provider credentials in Worker secrets only.
- Revealing list membership. Return the same message for new and existing addresses.
- Single opt-in. Bots and typos pollute the list; use the provider's double opt-in.
- JavaScript-only forms. Keep a working
actionfor readers and browsers without the script. - Rollback: restoring the provider embed in the footer partial brings back the old form; the function and consent store can remain for later.
Conclusion
A newsletter signup does not need a third-party embed on every page. A native, accessible HTML form posting to a small edge function — with a honeypot, rate limit, Turnstile, double opt-in through the provider's API and a consent record — removed 94 KB and seven third-party requests from every page of this blog, cut fake signups by 98%, and slightly increased real ones.
FAQ
Why not use the email provider's embed code?
Provider embeds typically load their own JavaScript, CSS and fonts on every page, sometimes tracking pixels too, and render a form that does not match the site. A native HTML form posting to a small edge function keeps pages light and lets the site control markup, accessibility and consent.
Is double opt-in necessary?
It is strongly recommended and in some jurisdictions effectively required. Sending a confirmation email before adding the subscriber proves the address belongs to the person who signed up, keeps lists clean, and protects sender reputation from bots entering other people's addresses.
Where are API keys for the email provider kept?
In the edge function's secrets, never in the page. The function calls the provider's API server-side, so the browser only ever sees the site's own endpoint.
Does the form work without JavaScript?
Yes, if it is a normal HTML form with an action and method. The function responds with a redirect to a thank-you page for plain submissions, and with JSON when the page's script submits it with fetch for an inline confirmation.
Related
- Parent: Serverless Functions for Static Sites — small server code for static sites.
- Handling Form Submissions on a Static Site — the general form pattern.
- Adding a Contact Form with Cloudflare Workers — a sibling endpoint.
- Rate Limiting Edge Functions — protecting the endpoint.
- Measuring Third-Party Cost with Request Blocking — how the embed's cost was measured.
Filed underHow-tos