A newsletter is one of the most valuable things you can build alongside a technical blog. Unlike SEO traffic, which can disappear overnight with an algorithm change, an email list is direct — when you publish something, subscribers see it, regardless of what Google does.
The standard advice for adding a newsletter to a static site is “embed the form from your email provider.” MailerLite, ConvertKit, Buttondown — they all give you an iframe or a script tag to paste in. It works, but it looks exactly like what it is: a foreign element dropped into your page, usually with styling that doesn’t match, tracking pixels you didn’t ask for, and behaviour you can’t control.
The API approach is a few more lines of code but gives you a form that looks like it belongs on your site, handles errors gracefully, and doesn’t load any third-party JavaScript until the user actually submits.
This is how the newsletter form on linuxcore.dev works, with instructions for both MailerLite and Buttondown.
Choosing a Provider
Both MailerLite and Buttondown work well for a technical blog. Quick comparison:
| MailerLite | Buttondown | |
|---|---|---|
| Free tier | 1,000 subscribers, 12,000 emails/month | 100 subscribers |
| API quality | Excellent, well documented | Simple, minimal |
| Automations | Full sequences, segmentation | Basic automations |
| Markdown emails | No (HTML editor) | Yes (native) |
| Best for | Growing audience, sequences | Writers, developers |
For linuxcore.dev I use MailerLite for its automation capabilities — the welcome sequence runs automatically when someone subscribes. Buttondown is a good choice if you prefer writing emails in Markdown.
Option A: MailerLite Integration
Step 1: Get Your API Key
- Log into MailerLite
- Go to Integrations → API → Generate new token
- Give it a name (e.g.
linuxcore-dev) and copy the token
Step 2: Find Your Group ID
Subscribers can be added to groups (lists) in MailerLite. Get your group ID via the API:
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://connect.mailerlite.com/api/groups
Copy the id of the group you want subscribers added to.
Step 3: Create the Cloudflare Pages Function
Create functions/api/subscribe.ts:
// functions/api/subscribe.ts
interface Env {
MAILERLITE_API_KEY: string;
MAILERLITE_GROUP_ID: string;
}
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestOptions = () => new Response(null, { status: 204, headers: CORS });
export async function onRequestPost({ request, env }: { request: Request; env: Env }) {
const { email } = await request.json() as { email?: string };
if (!email?.trim() || !email.includes('@')) {
return new Response(
JSON.stringify({ error: 'Valid email address required' }),
{ status: 400, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
// Add subscriber to MailerLite
const res = await fetch('https://connect.mailerlite.com/api/subscribers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.MAILERLITE_API_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
email,
groups: [env.MAILERLITE_GROUP_ID],
status: 'active',
}),
});
// MailerLite returns 200 for existing subscribers, 201 for new ones
if (res.status === 200 || res.status === 201) {
return new Response(
JSON.stringify({ ok: true }),
{ status: 200, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
// Handle duplicate gracefully
if (res.status === 409) {
return new Response(
JSON.stringify({ ok: true, note: 'already_subscribed' }),
{ status: 200, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
const error = await res.json() as any;
return new Response(
JSON.stringify({ error: error?.message ?? 'Subscription failed' }),
{ status: 500, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
Add the environment variables to Cloudflare Pages:
MAILERLITE_API_KEY— your API tokenMAILERLITE_GROUP_ID— your group/list ID
Option B: Buttondown Integration
Buttondown is even simpler — no group ID needed, just an API key.
// functions/api/subscribe.ts (Buttondown version)
interface Env {
BUTTONDOWN_API_KEY: string;
}
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestOptions = () => new Response(null, { status: 204, headers: CORS });
export async function onRequestPost({ request, env }: { request: Request; env: Env }) {
const { email } = await request.json() as { email?: string };
if (!email?.trim() || !email.includes('@')) {
return new Response(
JSON.stringify({ error: 'Valid email address required' }),
{ status: 400, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
const res = await fetch('https://api.buttondown.email/v1/subscribers', {
method: 'POST',
headers: {
'Authorization': `Token ${env.BUTTONDOWN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
// 201 = new subscriber, 400 with certain codes = already subscribed
if (res.status === 201) {
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: CORS });
}
const data = await res.json() as any;
// Treat "already subscribed" as success
if (data?.code === 'email_already_exists') {
return new Response(JSON.stringify({ ok: true, note: 'already_subscribed' }), { status: 200, headers: CORS });
}
return new Response(
JSON.stringify({ error: data?.detail ?? 'Subscription failed' }),
{ status: 500, headers: { ...CORS, 'Content-Type': 'application/json' } }
);
}
The Newsletter Form Component
Create src/components/NewsletterForm.astro:
---
// src/components/NewsletterForm.astro
// Place anywhere on the site — homepage, end of articles, dedicated page
interface Props {
title?: string;
description?: string;
compact?: boolean; // smaller version for inline use
}
const {
title = 'Stay in the loop',
description = 'New articles, scripts, and homelab tips. No spam, no weekly digest noise. Just an email when something useful goes up.',
compact = false,
} = Astro.props;
---
<section class:list={['nl-section', { compact }]}>
{!compact && (
<>
<div class="nl-label">// NEWSLETTER</div>
<h2 class="nl-title">{title}</h2>
<p class="nl-desc">{description}</p>
</>
)}
{compact && <p class="nl-compact-label">Get notified when new articles land:</p>}
<form class="nl-form" id="nl-form" novalidate>
<input
type="email"
id="nl-email"
name="email"
placeholder="your@email.com"
required
class="nl-input"
autocomplete="email"
/>
<button type="submit" class="btn-primary nl-btn" id="nl-btn">
Subscribe →
</button>
</form>
<div id="nl-status" class="nl-status" role="alert" aria-live="polite"></div>
<p class="nl-note">No spam. Unsubscribe any time.</p>
</section>
<style>
.nl-section { padding: 2.5rem; background: var(--bg2); border: 1px solid var(--border); }
.nl-section.compact { padding: 1.5rem; background: transparent; border: none; border-top: 1px solid var(--border); }
.nl-label { font-family: var(--mono); font-size: 10px; letter-spacing: .2em; color: var(--amber); margin-bottom: 10px; }
.nl-label::before { content: '// '; color: var(--text-dim); }
.nl-title { font-family: var(--display); font-size: clamp(22px, 3vw, 30px); font-weight: 600; color: var(--text-bright); margin-bottom: 10px; }
.nl-desc { font-size: 16px; color: var(--text-mid); line-height: 1.7; margin-bottom: 1.5rem; max-width: 48ch; }
.nl-compact-label { font-size: 14px; color: var(--text-dim); margin-bottom: 1rem; font-family: var(--mono); }
.nl-form { display: flex; gap: 8px; flex-wrap: wrap; }
.nl-input {
flex: 1; min-width: 200px;
padding: 10px 14px;
background: var(--bg); border: 1px solid var(--border-mid);
color: var(--text); font-family: var(--mono); font-size: 13px;
outline: none; transition: border-color .15s;
}
.nl-input:focus { border-color: var(--amber); }
.nl-input::placeholder { color: var(--text-dim); }
.nl-btn { white-space: nowrap; }
.nl-status {
margin-top: 12px; font-family: var(--mono); font-size: 12px;
padding: 8px 12px; min-height: 36px; display: none;
}
.nl-status.success { display: block; background: rgba(78,202,139,.08); border: 1px solid rgba(78,202,139,.25); color: var(--green); }
.nl-status.error { display: block; background: rgba(255,107,107,.08); border: 1px solid rgba(255,107,107,.25); color: var(--red); }
.nl-note { font-family: var(--mono); font-size: 11px; color: var(--text-dim); margin-top: 8px; }
</style>
<script>
const form = document.getElementById('nl-form') as HTMLFormElement;
const input = document.getElementById('nl-email') as HTMLInputElement;
const btn = document.getElementById('nl-btn') as HTMLButtonElement;
const status = document.getElementById('nl-status') as HTMLDivElement;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const email = input.value.trim();
if (!email) return;
btn.disabled = true;
btn.textContent = 'Subscribing…';
status.className = 'nl-status';
status.textContent = '';
try {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json() as { ok?: boolean; note?: string; error?: string };
if (data.ok) {
status.className = 'nl-status success';
status.textContent = data.note === 'already_subscribed'
? '✓ You\'re already subscribed — thanks!'
: '✓ You\'re subscribed. Welcome aboard.';
form.reset();
} else {
throw new Error(data.error ?? 'Unexpected error');
}
} catch (err: any) {
status.className = 'nl-status error';
status.textContent = `✕ ${err.message}`;
} finally {
btn.disabled = false;
btn.textContent = 'Subscribe →';
}
});
</script>
Using the Component
Drop the form anywhere:
---
import NewsletterForm from '../components/NewsletterForm.astro';
---
<!-- Full version on homepage or dedicated /newsletter page -->
<NewsletterForm />
<!-- Compact version at the end of articles -->
<NewsletterForm compact={true} />
<!-- Custom title -->
<NewsletterForm
title="Want more Linux homelab content?"
description="Subscribe for new guides when they land."
/>
In PostLayout, add it after the comments section:
<div class="comments-outer">
<NewsletterForm compact={true} />
<Comments />
</div>
Setting Up a Welcome Sequence (MailerLite)
A welcome sequence — a short series of emails sent automatically when someone subscribes — is one of the highest-value things you can add to a newsletter. New subscribers are most engaged immediately after signing up. Three emails over a week does more than a single welcome message.
The linuxcore.dev welcome sequence:
Email 1 — immediately: Welcome + point to the most useful article for new readers Email 2 — day 3: Introduce the Ansible Homelab Bundle with a short explanation of what it covers Email 3 — day 7: Ask what topics they’d most like to see covered (reply-based, good for engagement)
To set this up in MailerLite:
- Go to Automations → Create new automation
- Trigger: Subscriber joins a group → select your newsletter group
- Add three Send email steps with delays between them
- Write each email in MailerLite’s editor
The automation runs automatically for every new subscriber. You write it once and it works indefinitely.
Double Opt-In Consideration
By default, MailerLite adds subscribers with status: active — no confirmation email required. This is single opt-in.
For audiences in the EU (GDPR) or California (CCPA), double opt-in — where subscribers confirm their email before being added — is better practice and reduces the legal ambiguity around consent. To enable it in MailerLite:
- Go to your group settings
- Enable Double opt-in
- Customise the confirmation email
Your API calls stay the same — MailerLite handles sending the confirmation email automatically and only activates the subscriber after they click confirm.
The End Result
A newsletter subscription form that looks native to your site, handles edge cases (already subscribed, invalid email, API errors) gracefully, and connects to a proper email platform with automation support. No iframes, no third-party JavaScript loaded on page load, no styling that fights with your design.
Combined with the welcome sequence, new subscribers immediately get useful content from you — not a month later when you happen to publish something new.
This concludes the Astro series for now. All five features — comments, contact forms, shop, SEO, and newsletter — are running in production on this site. Questions or issues? Drop a comment below.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.