Adding Newsletter Subscriptions to a Static Next.js Blog with Automated Delivery

How I added email subscriptions and automated newsletter delivery to a static Next.js blog deployed on Cloudflare Workers — using Resend for email and GitHub Actions for automation.

8 min read

#The Problem with Static Blogs and Subscriptions

My blog runs as a static Next.js site deployed on Cloudflare Workers. No traditional server. No database. Content lives in MDX files, metadata in generated JSON manifests, and the whole thing deploys to the edge on every push.

That setup is great for performance — but it creates a challenge when you want readers to subscribe to new posts. Most newsletter solutions assume you have a database and a persistent backend. I had neither.

I wanted three things:

  • A subscribe form that actually works at the edge
  • Automated newsletter delivery whenever I publish a new post
  • No database maintenance or infrastructure to babysit

Here's how I built it.

#Architecture: Resend + GitHub Actions

After evaluating options, I landed on:

  • Resend to store subscribers (Audiences) and send emails (Broadcasts)
  • Two API routes on the Next.js edge runtime — one for subscribing, one for sending
  • A GitHub Action that detects new posts after deploy and triggers the send route automatically

The flow looks like this:

[User submits email] → POST /api/subscribe → Resend Audience
[Push to main with new .mdx post]
→ Deploy to Cloudflare (deploy.yml)
→ newsletter.yml detects new posts
→ Reads frontmatter (title, description, tags)
→ POST /api/newsletter/send (protected by API key)
→ Resend creates + sends Broadcast to all subscribers

No database. No cron jobs. No manual steps after publishing.

#The Subscribe Endpoint

The /api/subscribe route validates the email with Zod and adds the contact to a Resend Audience:

typescript
// src/app/api/subscribe/route.ts
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
})
export async function POST(request: Request) {
const body = await request.json()
const { email } = schema.parse(body)
const res = await fetch(`https://api.resend.com/audiences/${audienceId}/contacts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, unsubscribed: false }),
})
// 409 = already subscribed — treat as success
if (res.status === 409) return Response.json({ success: true })
if (!res.ok) return Response.json({ error: 'Subscription failed' }, { status: 500 })
return Response.json({ success: true })
}

One detail worth noting: Resend returns 409 Conflict when a contact already exists in the audience. Treating that as a success means the form feels smooth for repeat visitors — no confusing error messages if someone tries to subscribe twice.

#The Newsletter Send Endpoint

This route is not public — it's protected by a secret API key only the GitHub Action knows. It creates a Resend Broadcast and sends it immediately:

typescript
// src/app/api/newsletter/send/route.ts
// Protect with API key
const authHeader = request.headers.get('Authorization')
if (authHeader !== `Bearer ${process.env.NEWSLETTER_API_KEY}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Build and send the broadcast
const broadcastRes = await fetch('https://api.resend.com/broadcasts', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
audience_id: audienceId,
from: 'Harry Tran <newsletter@hoangtaiki.com>',
subject: `New post: ${title}`,
html,
}),
})
const { id } = await broadcastRes.json()
// Trigger send immediately
await fetch(`https://api.resend.com/broadcasts/${id}/send`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({}),
})

The two-step Resend API pattern — create broadcast, then send — lets you preview the email in the Resend dashboard before it goes out if you want to add a manual review step in the future.

#The Frontend Subscribe Form

A small 'use client' component with three states — idle, loading, and success:

tsx
// src/components/shared/NewsletterSubscribe.tsx
'use client'
import { useState } from 'react'
export default function NewsletterSubscribe() {
const [email, setEmail] = useState('')
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setState('loading')
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
setState(res.ok ? 'success' : 'error')
}
if (state === 'success') {
return <p>You're subscribed! New posts will land in your inbox.</p>
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<button type="submit" disabled={state === 'loading'}>
{state === 'loading' ? 'Subscribing…' : 'Subscribe'}
</button>
</form>
)
}

I added it to the Footer, above the copyright line — visible site-wide without being intrusive.

#Automating Newsletter Delivery with GitHub Actions

This is the part that makes it hands-free. The workflow triggers after the deploy workflow completes successfully, then detects if any new .mdx file was added to src/content/posts/ in that push:

yaml
# .github/workflows/newsletter.yml
name: Send Newsletter for New Posts
on:
workflow_run:
workflows: ['Deploy to Cloudflare']
types: [completed]
branches: [main]
jobs:
notify:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect new blog posts
id: detect
run: |
NEW_FILES=$(git diff --name-only --diff-filter=A HEAD~1 HEAD \
-- 'src/content/posts/*.mdx' 2>/dev/null || true)
if [ -z "$NEW_FILES" ]; then
echo "has_new_posts=false" >> "$GITHUB_OUTPUT"
else
JSON=$(echo "$NEW_FILES" | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "files=$JSON" >> "$GITHUB_OUTPUT"
echo "has_new_posts=true" >> "$GITHUB_OUTPUT"
fi

The key here is --diff-filter=A — this only matches Added files, not modified ones. Editing an existing post won't trigger a newsletter send.

The next step reads from the pre-generated src/generated/posts-metadata.json (created during the build) to get the post metadata — no additional dependencies needed — then calls the protected send endpoint. This pre-generation approach is the same pattern used to work around Cloudflare Workers' lack of filesystem access at runtime, which I covered in MDX on Cloudflare Workers with OpenNext: Three Bugs, Three Fixes.

bash
METADATA=$(cat src/generated/posts-metadata.json)
POST=$(echo "$METADATA" | jq -r --arg slug "$SLUG" '.[$slug]')
PAYLOAD=$(echo "$POST" | jq -c '{slug: .slug, title: .title, description: .description, date: .date, tags: .tags}')
curl -s -X POST "$SITE_URL/api/newsletter/send" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NEWSLETTER_API_KEY" \
-d "$PAYLOAD"

The published: false check is handled by the metadata — posts with published: false are excluded from the manifest entirely, so they'll never be sent.

The published: false check is important — it means you can commit draft posts to the repo without accidentally sending a newsletter.

#Environment Variables

Three secrets are needed, split across different systems:

VariableWherePurpose
RESEND_API_KEYCloudflare + GitHubAuthenticate with Resend API
RESEND_AUDIENCE_IDCloudflareThe audience to subscribe contacts to
NEWSLETTER_API_KEYCloudflare + GitHubProtect the /api/newsletter/send endpoint

Here's exactly how to get each one.

#RESEND_API_KEY

  1. Log in to resend.com → go to API Keys in the sidebar
  2. Click Create API Key
  3. Give it a name (e.g. hoangtaiki-blog) and select Full access
  4. Copy the key — it starts with re_ and is only shown once

#RESEND_AUDIENCE_ID

Resend calls subscriber lists "Audiences" and refers to them as "Segments" in the URL.

  1. Go to resend.com/audience/segments
  2. Click Create Segment (or New Audience depending on your plan)
  3. Give it a name like Blog subscribers and save
  4. Open the newly created segment — the URL will look like:
https://resend.com/audience?segmentId=5aad50b6-a1a0-4b1c-a31d-b376c9a25dcb
  1. The UUID after segmentId= is your RESEND_AUDIENCE_ID

#NEWSLETTER_API_KEY

This is not a Resend key — it's a secret you generate yourself to protect the /api/newsletter/send endpoint from unauthorized calls. Use openssl to generate a cryptographically random value:

bash
openssl rand -hex 32

This outputs something like:

3970218c3abf3e51dd0dada90c8b0759e8beabc68f190253190938058be3a81e

Store that value as NEWSLETTER_API_KEY. The API route checks the Authorization: Bearer <key> header on every request and returns 401 if it doesn't match.

#Adding to your environment

For local development, add all three to .env.local:

RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
RESEND_AUDIENCE_ID=5aad50b6-a1a0-4b1c-a31d-b376c9a25dcb
NEWSLETTER_API_KEY=3970218c3abf3e51dd0dada90c8b0759e8beabc68f190253190938058be3a81e
NEXT_PUBLIC_SITE_URL=https://yourdomain.com

For production, add as Cloudflare Worker secrets (run each and paste the value when prompted):

bash
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put RESEND_AUDIENCE_ID
npx wrangler secret put NEWSLETTER_API_KEY
npx wrangler secret put NEXT_PUBLIC_SITE_URL

And add NEWSLETTER_API_KEY as a GitHub Actions secret in your repo: Settings → Secrets and variables → Actions → New repository secret.

#Domain Verification

Before Resend can send from newsletter@hoangtaiki.com, the domain needs to be verified. Resend provides SPF, DKIM, and DMARC DNS records to add in Cloudflare DNS. Once added, verification usually completes within minutes.

Until then, you can test using Resend's pre-verified onboarding@resend.dev sender by setting:

NEWSLETTER_FROM_EMAIL=onboarding@resend.dev

#Testing the Full Flow

You can simulate what the GitHub Action does without waiting for a new post to be deployed:

bash
curl -s -X POST http://localhost:3000/api/newsletter/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_NEWSLETTER_API_KEY" \
-d '{
"slug": "my-post-slug",
"title": "My Post Title",
"description": "A description of the post.",
"date": "2026-03-11",
"tags": ["nextjs", "cloudflare"]
}'

If it works, you'll see {"success":true,"broadcastId":"..."} and the broadcast will appear in the Resend dashboard.

#What This Solves

The result is a fully automated newsletter pipeline with zero ongoing maintenance:

  1. Write a post in MDX, set published: true
  2. Push to main
  3. Cloudflare deployment runs automatically
  4. GitHub Action detects the new .mdx file
  5. Subscribers receive an email with the post title, description, tags, and a link
  6. Resend handles unsubscribes automatically via {{{RESEND_UNSUBSCRIBE_URL}}} in the email template

No database. No cron jobs. No third-party newsletter platform to manage. Just a form, two API routes, and a workflow file.

Subscribe to my space 🚀

Stay updated on my Blogs about Automation Test, Swift & iOS, Software Engineering, and book reviews.

100% free. Unsubscribe at any time.