← Volver al blog
BACKEND · CLOUDFLARE

Por qué sustituí beehiiv por un backend de newsletter propio en Cloudflare

Llevaba meses con beehiiv gestionando la newsletter: formulario, doble opt-in, entrega del lead magnet, todo automático. Hasta que confirmé algo que no aparece en ninguna documentación con esas palabras: en el plan gratuito, el trigger “Added by API” no dispara ninguna automatización, aunque la API acepte la suscripción sin devolver ningún error. El suscriptor quedaba registrado. El email de bienvenida nunca salía.

La opción obvia era subir a un plan de pago (o migrar a otro ESP). La que elegí fue otra: montar el backend yo mismo sobre infraestructura que ya conozco — Cloudflare Workers, D1 y Resend — y quedarme con control total sobre cada paso del flujo, más una pieza de portfolio backend real que no es una promesa en un CV, es una URL en producción.

El diseño

Astro con adaptador de Cloudflare, en modo server para poder tener rutas dinámicas junto a las páginas estáticas del sitio. Cuatro piezas:

  • D1 como base de datos de suscriptores (pending / active / unsubscribed, con token de confirmación y token de baja únicos por persona)
  • Resend para el envío — gratis hasta 3.000 emails/mes, dominio propio, sin la fricción de un ESP genérico
  • Doble opt-in real: confirmación por email con token de 48h, sin la cual nadie queda active
  • Panel de administración interno (/admin/newsletter/) para ver suscriptores, reenviar confirmaciones, dar de baja, eliminar y contactar a alguien puntualmente — protegido con Cloudflare Access en vez de programar login desde cero

Nada de esto es exótico por separado. Lo interesante fue lo que se rompió al juntarlo, porque cada bug solo aparecía en producción — nunca en local.

Bug 1: rutas de admin devolviendo 404 con el sitio funcionando perfectamente

El sitio usa i18n de Astro (prefixDefaultLocale: true) para servir /es/ y /en/. Añadí /admin/newsletter/ como página nueva, fuera de esa estructura de idiomas — y devolvía 404, tanto en astro dev como en un build real servido con wrangler dev. El patrón de ruta en el manifiesto era correcto; el problema es que el routing de i18n de Astro, activo con esa configuración, no sirve páginas .astro fuera de las carpetas de idioma, sin avisar de ello.

La solución no fue tocar el i18n del resto del sitio (31 rutas dependen de él). Fue convertir la página de admin de .astro a un endpoint .ts que arma el HTML a mano y lo devuelve como Response — los endpoints no pasan por esa restricción, solo las páginas.

Bug 2: el mismo 404, ya con la ruta arreglada

Con el endpoint .ts desplegado, la ruta seguía dando 404 — pero solo en producción, nunca en local con wrangler dev usando el mismo build. La pista definitiva vino de wrangler tail: cero líneas de log para la petición que fallaba. El Worker nunca se estaba ejecutando.

La causa: sin run_worker_first: true en la configuración de assets, Cloudflare intenta resolver cada petición como archivo estático antes de invocar el Worker. Como no existe ningún archivo literal para una ruta renderizada dinámicamente, cae directo al fallback de 404 configurado — sin que el código llegue a ejecutarse nunca. Añadir ese flag (forzando que el Worker decida siempre primero) lo resolvió, y explica por qué las rutas de /api/* nunca tuvieron el problema: ya llevaban tiempo desplegadas y cacheadas de forma distinta.

Bug 3: caché de borde sirviendo un 404 que ya no existía

Con las dos rutas corregidas, seguía apareciendo el mismo 404 de forma intermitente — a veces sí, a veces no, sin patrón aparente. Las cabeceras de respuesta lo delataron: cf-cache-status: HIT. Cloudflare había cacheado en el edge la respuesta 404 de antes de que la ruta existiera, y la seguía sirviendo para algunos centros de datos sin volver a consultar el origen — invisible tanto para borrar cookies como para recargar forzado del navegador, porque el problema no estaba ahí.

Purgar caché lo resolvió puntualmente; la solución permanente fue una Cache Rule explícita que excluye /admin/* y /api/* de cualquier almacenamiento en caché. Motivo extra para no dejarlo en “ya funciona”: si esas rutas se pueden cachear, en teoría una respuesta con datos de un suscriptor podría servirse a otra persona.

Un gotcha más, menor pero real

Cloudflare Access protege las rutas de admin con login antes de llegar al Worker. Lo que no hace bien: si una petición POST (por ejemplo, el formulario de “dar de baja”) llega sin sesión válida todavía, Access la redirige a la pantalla de login — y una redirección HTTP no puede preservar un método POST ni su cuerpo. La petición original se pierde y llega al origen como un GET vacío. Con la sesión ya establecida (tras el primer login), el problema desaparece — pero vale la pena saber que existe antes de firmar que “ya funciona” en el primer intento.

Bug 4 (añadido después): instalar el panel como app, roto por el propio sistema que lo protege

Semanas después de publicar esto, añadí un manifest y un service worker para poder instalar el panel de administración como app desde el móvil — lo reviso a diario y evita depender de recordar la URL. El navegador nunca ofrecía la opción de instalar, sin ningún error visible.

La causa era la misma familia de problema que el Bug 3: algo protegiendo una ruta sin que el efecto colateral fuera obvio. Había colocado el manifest bajo /admin/manifest.webmanifest — dentro del mismo prefijo que protege Cloudflare Access. El navegador necesita leer ese archivo directamente para evaluar si la app es instalable, y en vez del JSON recibía una redirección al login de Access. Un manifest inalcanzable, y ninguna app instalable, sin ningún mensaje que apuntara a la causa real.

La solución: sacar el manifest y el service worker de esa ruta protegida — no tienen ningún dato sensible, así que no hace falta que vivan ahí — dejando scope: "/admin/" declarado dentro del propio manifest para que la app instalada siga limitada a esa sección. El service worker, además, no cachea nada a propósito: el panel muestra datos reales de suscriptores, y lo último que quiero es que queden guardados en el Cache Storage del navegador.

Aproveché el mismo ciclo para añadir un aviso por email cuando alguien completa el doble opt-in — antes solo lo sabía si entraba a revisar el panel manualmente.

El resultado

Suscripción, confirmación por doble opt-in, entrega del lead magnet y baja, todo funcionando de punta a punta, verificado con emails reales en ambos idiomas. Panel de administración con acceso restringido por email, instalable como app, con aviso automático de altas nuevas, sin una sola línea de código de autenticación propia. Y una lista de bugs que solo un despliegue real a producción puede sacar a la luz — ninguno se habría detectado quedándose en local.


Esto no es un caso vía agencia — es infraestructura propia, en producción, sirviendo la newsletter de este mismo sitio. Si necesitas algo similar para tu proyecto, hablamos.