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.
#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:
// src/app/api/subscribe/route.tsimport { 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 successif (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:
// src/app/api/newsletter/send/route.ts// Protect with API keyconst authHeader = request.headers.get('Authorization')if (authHeader !== `Bearer ${process.env.NEWSLETTER_API_KEY}`) {return Response.json({ error: 'Unauthorized' }, { status: 401 })}// Build and send the broadcastconst 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 immediatelyawait 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:
// 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:
# .github/workflows/newsletter.ymlname: Send Newsletter for New Postson:workflow_run:workflows: ['Deploy to Cloudflare']types: [completed]branches: [main]jobs:notify:runs-on: ubuntu-latestif: ${{ github.event.workflow_run.conclusion == 'success' }}steps:- uses: actions/checkout@v4with:fetch-depth: 2- name: Detect new blog postsid: detectrun: |NEW_FILES=$(git diff --name-only --diff-filter=A HEAD~1 HEAD \-- 'src/content/posts/*.mdx' 2>/dev/null || true)if [ -z "$NEW_FILES" ]; thenecho "has_new_posts=false" >> "$GITHUB_OUTPUT"elseJSON=$(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.
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:
| Variable | Where | Purpose |
|---|---|---|
RESEND_API_KEY | Cloudflare + GitHub | Authenticate with Resend API |
RESEND_AUDIENCE_ID | Cloudflare | The audience to subscribe contacts to |
NEWSLETTER_API_KEY | Cloudflare + GitHub | Protect the /api/newsletter/send endpoint |
Here's exactly how to get each one.
#RESEND_API_KEY
- Log in to resend.com → go to API Keys in the sidebar
- Click Create API Key
- Give it a name (e.g.
hoangtaiki-blog) and select Full access - 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.
- Go to resend.com/audience/segments
- Click Create Segment (or New Audience depending on your plan)
- Give it a name like
Blog subscribersand save - Open the newly created segment — the URL will look like:
https://resend.com/audience?segmentId=5aad50b6-a1a0-4b1c-a31d-b376c9a25dcb
- The UUID after
segmentId=is yourRESEND_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:
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_xxxxxxxxxxxxxxxxxxxxRESEND_AUDIENCE_ID=5aad50b6-a1a0-4b1c-a31d-b376c9a25dcbNEWSLETTER_API_KEY=3970218c3abf3e51dd0dada90c8b0759e8beabc68f190253190938058be3a81eNEXT_PUBLIC_SITE_URL=https://yourdomain.com
For production, add as Cloudflare Worker secrets (run each and paste the value when prompted):
npx wrangler secret put RESEND_API_KEYnpx wrangler secret put RESEND_AUDIENCE_IDnpx wrangler secret put NEWSLETTER_API_KEYnpx 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:
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:
- Write a post in MDX, set
published: true - Push to
main - Cloudflare deployment runs automatically
- GitHub Action detects the new
.mdxfile - Subscribers receive an email with the post title, description, tags, and a link
- 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.