August 24, 2026·6 min read

#Sending Email Notifications for New Posts, for Free (Next.js + Resend + Vercel)

#nextjs#resend#vercel#email
Also published on MediumRead on Medium →

I wanted one feature on this blog that felt obvious but somehow most static blogs skip: when I publish a new post, people who subscribed should get an email about it automatically. No dashboard to open, no "send" button to click.

I also didn't want to pay for anything or keep a server running 24/7 — this whole site runs from a Next.js project deployed on Vercel, written mostly from Termux on my phone, so "always-on infrastructure" was never really the plan. Here's how the system is designed, and the reasoning behind each piece.

This post is also up on Medium if you'd rather read it there, or follow along with anything else I post to that account.

The stack

Everything here lives in one place: this blog is Next.js, posts are .mdx files sitting in content/posts/, no external CMS. Writing a post means writing a file and pushing it to GitHub; Vercel picks up the push and redeploys automatically. That workflow already existed before I thought about email at all, and it turns out it's the perfect trigger for notifications too.

  • Domain: shoudo.xyz
  • Blog: Next.js, MDX files, deployed on Vercel
  • Email sending: Resend
  • Subscriber storage: a Postgres table, also on Vercel
  • Trigger: a GitHub Action that runs whenever a new .mdx file is pushed to content/posts/

No separate backend, no server process to keep alive. GitHub's own runners do the work of sending, and they only spin up when there's actually a new post — exactly when the work needs doing.

Verifying the sending domain

Resend won't let you send from noreply@shoudo.xyz until it can confirm ownership of shoudo.xyz. That verification happens through DNS — Resend gives you a handful of TXT and MX records (SPF, DKIM, and optionally DMARC) to add wherever the domain's DNS is managed.

The one that trips people up is DKIM — it's a long cryptographic key, and copy-pasting it into a DNS provider's UI is exactly the kind of thing that goes wrong in small, invisible ways: a dropped character, a truncated value, whitespace that shouldn't be there. If Resend's dashboard says a record is "added" but verification keeps failing, the fix is almost always to delete the value and paste it fresh rather than trying to edit around it.

The subscribe form

The form itself is small — an email input, a submit button, and a POST request to an API route:

async function handleSubscribe(email) {
  const res = await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  })

  return res.ok
}

The API route on the other end does three things: validates the email looks real, checks whether it's already in the subscribers table, and if not, inserts a new row with an unsubscribe token attached.

export async function POST(request) {
  const { email } = await request.json()

  if (!isValidEmail(email)) {
    return Response.json({ error: 'Invalid email' }, { status: 400 })
  }

  const existing = await db.query(
    'select id from subscribers where email = $1',
    [email]
  )

  if (existing.rows.length > 0) {
    return Response.json({ ok: true }) // already subscribed, no error
  }

  const token = crypto.randomUUID()

  await db.query(
    'insert into subscribers (email, unsubscribe_token) values ($1, $2)',
    [email, token]
  )

  return Response.json({ ok: true })
}

That token matters more than it looks — every automated email needs a working one-click unsubscribe link, both because it's the right thing to do and because sending without one is a fast way to get flagged as spam.

The part that replaces a server: GitHub Actions

This is the actual trick. Instead of a background process watching for new posts, a GitHub Action watches the repository itself — triggered specifically on pushes to the content/posts/ folder, not on every commit to the repo.

name: Notify subscribers

on:
  push:
    branches:
      - main
    paths:
      - 'content/posts/**'

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node scripts/notify-subscribers.mjs
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}

Scoping the trigger to just the posts folder matters — without that paths filter, the workflow would fire on every single commit, including ones that have nothing to do with a new post.

The script itself reads the newest post's frontmatter, pulls every subscriber from the database, and sends a batch through Resend:

import { Resend } from 'resend'
import matter from 'gray-matter'
import fs from 'fs'

const resend = new Resend(process.env.RESEND_API_KEY)

const raw = fs.readFileSync(process.env.POST_PATH, 'utf8')
const { data } = matter(raw)

const subscribers = await db.query('select email, unsubscribe_token from subscribers')

const emails = subscribers.rows.map((sub) => ({
  from: 'Shoudo <noreply@shoudo.xyz>',
  to: sub.email,
  subject: `New post: ${data.title}`,
  html: buildEmailHtml(data, sub.unsubscribe_token),
}))

await resend.batch.send(emails)

resend.batch.send takes an array of email objects and fires them all in one request, instead of looping through subscribers one at a time and making a separate API call for each.

Turning a relative path into something an inbox can load

Posts on this blog can reference a cover image with a relative path, like /images/my-post-cover.jpg, because that's all a browser needs — it resolves the path against the site's own domain automatically. An email client has no such context. A relative path in an email is just broken, it'll show up as a missing-image icon.

The fix is a small helper that runs before the email template gets built:

function toAbsoluteUrl(path) {
  if (path.startsWith('http')) return path
  return `https://blog.shoudo.xyz${path}`
}

Run the post's cover image through that before it goes into the email template, and /images/my-post-cover.jpg becomes a real, publicly reachable URL — because the image already lives in the deployed site's public/ folder, no separate image hosting needed.

Where this stands right now

This post is describing the design more than a finished system — the subscribe form, the subscribers table, and the GitHub Action are the pieces still left to wire up on this blog specifically.

I actually built close to this exact system once before, on a completely different stack — Astro instead of Next.js, Firebase Firestore instead of Postgres, Firebase Hosting instead of Vercel. It worked, and I ran into a handful of very specific, very annoying bugs along the way (DNS records that "looked" correct but weren't, environment variables that silently did nothing, broken images in emails). That full write-up, mistakes included, is also on Medium.

The core idea carries over — a static blog, a subscribe form, and a CI trigger doing the work a server used to do — but this version is being rebuilt from scratch for this stack, not ported over. Once the subscribe form and the Action are actually live here, this post gets updated to match the real, working version instead of the plan for it.