← Back to blog
BACKEND · CLOUDFLARE

Why I replaced beehiiv with my own newsletter backend on Cloudflare

I’d been running the newsletter on beehiiv for months: signup form, double opt-in, lead magnet delivery, all automatic. Until I confirmed something no documentation states in these words: on the free plan, the “Added by API” trigger never fires any automation, even though the API accepts the subscription without returning any error. The subscriber got registered. The welcome email never went out.

The obvious move was upgrading to a paid plan, or migrating to another ESP. The one I picked instead: build the backend myself on infrastructure I already know — Cloudflare Workers, D1, and Resend — and keep full control over every step of the flow, plus an actual backend portfolio piece that isn’t a claim on a résumé, it’s a URL in production.

The design

Astro with the Cloudflare adapter, in server mode to have dynamic routes alongside the site’s static pages. Four pieces:

  • D1 as the subscriber database (pending / active / unsubscribed, with a unique confirmation token and a unique unsubscribe token per person)
  • Resend for sending — free up to 3,000 emails/month, own domain, none of the friction of a generic ESP
  • Real double opt-in: email confirmation with a 48h token, without which nobody ever becomes active
  • An internal admin panel (/admin/newsletter/) to view subscribers, resend confirmations, unsubscribe, delete, and reach out to someone directly — protected with Cloudflare Access instead of writing login logic from scratch

None of this is exotic on its own. What got interesting was what broke once it was all wired together, because every one of these bugs only ever showed up in production — never locally.

Bug 1: admin routes 404ing while the rest of the site worked fine

The site uses Astro’s i18n routing (prefixDefaultLocale: true) to serve /es/ and /en/. I added /admin/newsletter/ as a new page outside that locale structure — and it returned 404, both in astro dev and in a real build served through wrangler dev. The route pattern in the manifest was correct; the actual issue is that Astro’s i18n routing, active under that config, doesn’t serve .astro pages outside the locale folders — silently.

The fix wasn’t touching the i18n setup the rest of the site depends on (31 routes rely on it). It was converting the admin page from .astro to a .ts endpoint that builds the HTML by hand and returns it as a Response — endpoints don’t go through that restriction, only pages do.

Bug 2: the same 404, with the route already fixed

With the .ts endpoint deployed, the route still 404’d — but only in production, never locally against the same build via wrangler dev. The decisive clue came from wrangler tail: zero log lines for the failing request. The Worker was never running at all.

The cause: without run_worker_first: true in the assets config, Cloudflare tries to resolve every request as a static asset before invoking the Worker. Since no literal file exists for a dynamically-rendered route, it falls straight through to the configured 404 fallback — without the code ever running. Adding that flag (forcing the Worker to always decide first) fixed it, and explains why /api/* routes never hit this: they’d already been deployed and cached differently for a while.

Bug 3: edge cache serving a 404 that no longer existed

With both routes fixed, the same 404 kept showing up intermittently — sometimes yes, sometimes no, no obvious pattern. The response headers gave it away: cf-cache-status: HIT. Cloudflare had cached the 404 response at the edge from before the route existed, and kept serving it from some data centers without ever re-checking the origin — invisible to both clearing cookies and hard-refreshing, because the problem was never in the browser.

Purging the cache fixed it in the moment; the permanent fix was an explicit Cache Rule excluding /admin/* and /api/* from any caching. Extra reason not to leave it at “it works now”: if those routes can be cached, a response carrying one subscriber’s data could in theory get served to someone else entirely.

One more, smaller but real

Cloudflare Access gates the admin routes with login before anything reaches the Worker. What it doesn’t handle gracefully: if a POST request (say, the “unsubscribe” button) arrives without a valid session yet, Access redirects to the login screen — and an HTTP redirect can’t preserve a POST method or its body. The original request gets lost and arrives at origin as an empty GET. Once a session is established (after the first login), the problem disappears — but it’s worth knowing it exists before signing off on “it works” from the first attempt.

Bug 4 (added later): installing the panel as an app, broken by the very system protecting it

Weeks after publishing this, I added a manifest and a service worker to install the admin panel as an app from my phone — I check it daily, and it beats having to remember the URL. The browser never offered to install it, with no visible error.

The cause was the same family of problem as Bug 3: something protecting a route with a side effect that wasn’t obvious. I’d placed the manifest under /admin/manifest.webmanifest — inside the same prefix Cloudflare Access protects. The browser needs to read that file directly to evaluate installability, and instead of the JSON it got redirected to Access’s login screen. An unreachable manifest, and no installable app, with no message pointing at the real cause.

The fix: move the manifest and service worker out of that protected route — they don’t hold anything sensitive, so there’s no reason for them to live there — while keeping scope: "/admin/" declared inside the manifest itself so the installed app stays limited to that section. The service worker, on top of that, deliberately caches nothing: the panel shows real subscriber data, and the last thing I want is any of it sitting in the browser’s Cache Storage.

I used the same pass to add an email alert whenever someone completes double opt-in — before that, I only found out by checking the panel manually.

The result

Signup, double opt-in confirmation, lead magnet delivery, and unsubscribe, all working end to end, verified with real emails in both languages. An admin panel gated by email, installable as an app, with an automatic alert on new signups, without a single line of custom auth code. And a list of bugs that only a real production deploy could surface — none of them would have shown up staying local.


This isn’t an agency-referred case — it’s my own infrastructure, in production, running this very site’s newsletter. If you need something similar for your project, let’s talk.