DEV Community

Cover image for Hugo Blog Newsletter Automation for Indie Devs using Cloudflare & Autosend
Debajyati Dey
Debajyati Dey

Posted on

Hugo Blog Newsletter Automation for Indie Devs using Cloudflare & Autosend

If you are a fellow blogger or web developer looking to build a high-performance, developer-friendly, and budget-friendly newsletter system for your static Hugo blog, then you are at the right place.

In this article, we will go through how to build a complete newsletter automation setup using Hugo (our static site generator), Cloudflare Workers (our serverless API), Cloudflare D1 (our serverless SQLite database), and the AutoSend email API (for sending verification and bulk newsletter emails).

๐Ÿ’ก This tutorial is written in the purpose of giving you an idea that how can you integrate AutoSend and Cloudflare Workers with your Hugo blog and build custom workflows that are not built-in.

Prerequisites: You should be familiar with HTML/CSS, basic JavaScript, and have some experience working with Hugo. Having a Cloudflare account will be required.

The Problem with Traditional Newsletter Services

If you've ever used Mailchimp, ConvertKit, or similar platforms, you know they get incredibly expensive as soon as your subscriber list starts growing.

Moreover, for static sites, integrating them often means adding heavy third-party scripts that slow down page loads.

At this date, we can use modern serverless tools to build a custom newsletter system that is faster, cheaper, and fully under our control.

Such one fantastic email delivery tool is AutoSend.

AutoSend: Email for Developers and Marketers

AutoSend is a lightweight SendGrid alternative for transactional and marketing emails. Simple, modern, and built to scale.

favicon autosend.com

Why AutoSend?

Well now you might question, "Why use this shiny new AutoSend instead of older services like SendGrid or Resend?"

The answer is straighforward: the pricing and features!

AutoSend offers an amazing plan where for just $12/month, you get both transactional and marketing emails for 10k emails/month with dedicated IPs included.

Older tools make you pay hefty subscriptions or addons just to get a dedicated IP, which is critical if you want your newsletter to land directly in the user's inbox instead of the spam folder.

Well, enough talk about comparisons.
I hope you are getting excited, so without waiting more, let's get started!


Set Up AutoSend Account

Long story short,โ€” get a domain if you don't have one already.

Once you have a domain, create an AutoSend account and head over to [https://autosend.com/settings/domains](https://autosend.com/settings/domains) or just click on the tab domains in your autosend dashboard.

Click on the + add domain button and add your domain (or a subdomain like mail.yourdomain.com).

AutoSend will provide you with DNS records (TXT & MX). Add them to your DNS manager (like Cloudflare).

After 5โ€“10 minutes, verify that your domain has been successfully confirmed. Once verified, generate an API key from Settings -> API Keys.

Now, let's start building!

Hey Wait! Autosend skills

Now, if you happen to use any AI coding agent (Antigravity, claude code, opencode, codex , Pi or github copilot), which is most likely. I recommend you install the official autosend skill file in your project dir. It will help you overcome AI context limits, enforce strict quality standards and provide specialized workflows.

Install using -

npx skills add https://github.com/autosendhq/skills --skill AutoSend
Enter fullscreen mode Exit fullscreen mode

Step 1: Database Setup (Cloudflare D1)

We will use Cloudflare D1, which is a native serverless SQLite database, to store subscriber information.

Open your terminal and run the following command to create a new D1 database:

wrangler d1 create hugo-newsletter-db
Enter fullscreen mode Exit fullscreen mode

This command will output a database name and ID. Next, create a schema file named db/schema.sql:

-- db/schema.sql
CREATE TABLE IF NOT EXISTS subscribers (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL,
    token TEXT NOT NULL,
    status TEXT DEFAULT 'PENDING_VERIFICATION', -- PENDING_VERIFICATION, CONFIRMED, UNSUBSCRIBED
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    verified_at DATETIME,
    unsubscribed_at DATETIME
);

CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status);
Enter fullscreen mode Exit fullscreen mode

schema

Let's execute the schema locally and remotely:

# Local dev database
wrangler d1 execute hugo-newsletter-db --local --file=./db/schema.sql

# Production remote database
wrangler d1 execute hugo-newsletter-db --remote --file=./db/schema.sql
Enter fullscreen mode Exit fullscreen mode

Step 2: Configuration (wrangler.toml & .dev.vars)

Create a wrangler.toml file in your worker directory and paste the following configuration:

# wrangler.toml
name = "hugo-newsletter-worker"
main = "src/index.js"
compatibility_date = "2024-03-01"

[vars]
BLOG_DOMAIN = "https://yourblog.com"
SENDER_EMAIL = "newsletter@yourdomain.com"
SENDER_NAME = "My Indie Dev Blog"

[[d1_databases]]
binding = "DB"
database_name = "hugo-newsletter-db"
database_id = "YOUR_D1_DATABASE_ID"
Enter fullscreen mode Exit fullscreen mode
๐Ÿ“ NOTE
Replace YOUR_D1_DATABASE_ID with the actual D1 database ID returned by Wrangler. Also, replace newsletter@yourdomain.com with your verified AutoSend email domain.

Create a .dev.vars file for your local environment variables:

# .dev.vars
AUTOSEND_API_KEY="your_autosend_api_key_here"
BULK_SEND_SECRET="your_custom_secret_key_to_protect_bulk_dispatch"
Enter fullscreen mode Exit fullscreen mode

Store these variables as production secrets on Cloudflare:

wrangler secret put AUTOSEND_API_KEY
wrangler secret put BULK_SEND_SECRET
Enter fullscreen mode Exit fullscreen mode

Step 3: Cloudflare Worker Implementation (src/index.js)

Create a file at src/index.js. This is where all the serverless logic resides. It handles subscribing, verification redirects, unsubscribing & sending bulk emails.

// src/index.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const method = request.method;

    if (method === "OPTIONS") {
      return handleCors(request);
    }

    try {
      if (url.pathname === "/api/subscribe/start" && method === "POST") {
        return await handleSubscribeStart(request, env);
      }
      if (url.pathname === "/api/subscribe/verify" && method === "GET") {
        return await handleSubscribeVerify(request, env);
      }
      if (url.pathname === "/api/unsubscribe" && method === "GET") {
        return await handleUnsubscribe(request, env);
      }
      if (url.pathname === "/api/send-bulk" && method === "POST") {
        return await handleSendBulk(request, env);
      }

      return new Response("Not Found", { status: 404 });
    } catch (err) {
      console.error(err);
      return corsResponse(JSON.stringify({ success: false, error: err.message }), 500);
    }
  }
};

function handleCors(request) {
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      "Access-Control-Max-Age": "86400",
    }
  });
}

function corsResponse(body, status = 200, contentType = "application/json") {
  return new Response(body, {
    status,
    headers: {
      "Content-Type": contentType,
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
    }
  });
}

// 1. Initial Subscribe Endpoint
async function handleSubscribeStart(request, env) {
  const { email, name } = await request.json();

  if (!email || !name) {
    return corsResponse(JSON.stringify({ success: false, error: "Name and Email are required." }), 400);
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return corsResponse(JSON.stringify({ success: false, error: "Invalid email format." }), 400);
  }

  const existing = await env.DB.prepare(
    "SELECT id, status FROM subscribers WHERE email = ?"
  ).bind(email).first();

  if (existing && existing.status === 'CONFIRMED') {
    return corsResponse(JSON.stringify({ success: false, error: "You are already subscribed!" }), 400);
  }

  const id = crypto.randomUUID();
  const token = crypto.randomUUID();

  await env.DB.prepare(`
    INSERT INTO subscribers (id, email, name, token, status)
    VALUES (?, ?, ?, ?, 'PENDING_VERIFICATION')
    ON CONFLICT(email) DO UPDATE SET
      name = excluded.name,
      token = excluded.token,
      status = 'PENDING_VERIFICATION'
  `).bind(id, email, name, token).run();

  const workerDomain = new URL(request.url).origin;
  const verificationLink = `${workerDomain}/api/subscribe/verify?token=${token}&email=${encodeURIComponent(email)}`;

  // Send single transactional verification email
  const response = await fetch("https://api.autosend.com/v1/mails/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.AUTOSEND_API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      from: { email: env.SENDER_EMAIL, name: env.SENDER_NAME },
      to: { email, name },
      subject: "Confirm your subscription to " + env.SENDER_NAME,
      html: `
        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 8px;">
          <h2>Welcome to the Newsletter!</h2>
          <p>Hi ${name},</p>
          <p>Thank you for signing up. Please verify your email to start receiving updates.</p>
          <div style="margin: 30px 0;">
            <a href="${verificationLink}" style="background-color: #6366f1; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Verify Email Address</a>
          </div>
        </div>
      `
    })
  });

  const resJson = await response.json();
  if (!response.ok || !resJson.success) {
    throw new Error(resJson.error?.message || "Failed to send email");
  }

  return corsResponse(JSON.stringify({ success: true, message: "Verification email sent!" }));
}

// 2. Link Handler Endpoint
async function handleSubscribeVerify(request, env) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");
  const email = url.searchParams.get("email");

  if (!token || !email) {
    return new Response("Invalid request parameters.", { status: 400 });
  }

  const subscriber = await env.DB.prepare(
    "SELECT id, status FROM subscribers WHERE email = ? AND token = ?"
  ).bind(email, token).first();

  if (!subscriber) {
    return new Response("Verification failed.", { status: 403 });
  }

  await env.DB.prepare(
    "UPDATE subscribers SET status = 'CONFIRMED', verified_at = CURRENT_TIMESTAMP WHERE id = ?"
  ).bind(subscriber.id).run();

  return Response.redirect(`${env.BLOG_DOMAIN}/thank-you/`, 302);
}

// 3. Unsubscribe Handler Endpoint
async function handleUnsubscribe(request, env) {
  const url = new URL(request.url);
  const email = url.searchParams.get("email");
  const token = url.searchParams.get("token");

  if (!email || !token) {
    return new Response("Unauthorized.", { status: 401 });
  }

  const subscriber = await env.DB.prepare(
    "SELECT id FROM subscribers WHERE email = ? AND token = ?"
  ).bind(email, token).first();

  if (!subscriber) {
    return new Response("Subscriber not found.", { status: 404 });
  }

  await env.DB.prepare(
    "UPDATE subscribers SET status = 'UNSUBSCRIBED', unsubscribed_at = CURRENT_TIMESTAMP WHERE id = ?"
  ).bind(subscriber.id).run();

  return new Response(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Unsubscribed</title>
      <style>
        body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background-color: #f9fafb; }
        .card { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); text-align: center; }
      </style>
    </head>
    <body>
      <div class="card">
        <h1 style="color: #dc2626;">Unsubscribed</h1>
        <p>You have been removed from our list.</p>
        <p><a href="${env.BLOG_DOMAIN}" style="color: #6366f1; text-decoration: none;">Return to Blog</a></p>
      </div>
    </body>
    </html>
  `, { headers: { "Content-Type": "text/html" } });
}

// 4. Bulk Dispatch Endpoint
async function handleSendBulk(request, env) {
  const authHeader = request.headers.get("Authorization");
  if (!authHeader || authHeader !== `Bearer ${env.BULK_SEND_SECRET}`) {
    return corsResponse(JSON.stringify({ success: false, error: "Unauthorized" }), 401);
  }

  const { title, description, image, url } = await request.json();
  if (!title || !url) {
    return corsResponse(JSON.stringify({ success: false, error: "Missing metadata" }), 400);
  }

  // Ensure absolute URLs for the article link and image
  const fullUrl = url.startsWith("http") ? url : `${env.BLOG_DOMAIN.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
  const fullImage = image ? (image.startsWith("http") ? image : `${env.BLOG_DOMAIN.replace(/\/$/, "")}/${image.replace(/^\//, "")}`) : "";

  const { results: subscribers } = await env.DB.prepare(
    "SELECT email, name, token FROM subscribers WHERE status = 'CONFIRMED'"
  ).all();

  if (subscribers.length === 0) {
    return corsResponse(JSON.stringify({ success: true, message: "No active subscribers found." }));
  }

  const workerDomain = new URL(request.url).origin;
  const chunkSize = 100;
  let batchReports = [];

  for (let i = 0; i < subscribers.length; i += chunkSize) {
    const chunk = subscribers.slice(i, i + chunkSize);

    const recipients = chunk.map(sub => {
      const unsubLink = `${workerDomain}/api/unsubscribe?email=${encodeURIComponent(sub.email)}&token=${sub.token}`;
      return {
        email: sub.email,
        name: sub.name,
        dynamicData: { name: sub.name, unsubscribeLink: unsubLink }
      };
    });

    const htmlBody = `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
        ${fullImage ? `<img src="${fullImage}" alt="${title}" style="width: 100%; height: auto;" />` : ''}
        <div style="padding: 24px;">
          <h1 style="font-size: 22px; color: #111827; margin-bottom: 12px;">${title}</h1>
          <p style="font-size: 16px; color: #4b5563; margin-bottom: 24px;">${description || ""}</p>
          <a href="${fullUrl}" style="background-color: #6366f1; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Read Full Article</a>
        </div>
        <div style="background-color: #f9fafb; padding: 16px; text-align: center; font-size: 12px; color: #9ca3af;">
          <p>Hi {{name}}, thanks for subscribing!</p>
          <p><a href="{{unsubscribeLink}}" style="color: #ef4444;">Unsubscribe</a></p>
        </div>
      </div>
    `;

    const response = await fetch("https://api.autosend.com/v1/mails/bulk", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.AUTOSEND_API_KEY}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        from: { email: env.SENDER_EMAIL, name: env.SENDER_NAME },
        subject: `New Post: ${title}`,
        html: htmlBody,
        recipients
      })
    });

    const resJson = await response.json();
    if (!response.ok || !resJson.success) {
      throw new Error(resJson.error?.message || `Failed to send batch`);
    }

    batchReports.push(resJson.data);
  }

  return corsResponse(JSON.stringify({
    success: true,
    message: `Dispatched newsletter to ${subscribers.length} subscribers.`,
    batches: batchReports
  }));
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Hugo Frontend Integration

Okay. Let's build a beautiful subscription card for our Hugo templates using some glassmorphic CSS styling and pure JavaScript.

Create a new file at layouts/shortcodes/_subscription_form.html:

<!-- layouts/shortcodes/_subscription_form.html -->
<div class="newsletter-card">
  <div class="newsletter-header">
    <h3>Join the Newsletter!</h3>
    <p>Get latest updates about new articles and projects direct to your inbox.</p>
  </div>
  <form id="newsletter-form" class="newsletter-form">
    <div class="input-group">
      <input type="text" id="news-name" name="name" placeholder="Your Name" required />
    </div>
    <div class="input-group">
      <input type="email" id="news-email" name="email" placeholder="your@email.com" required />
    </div>
    <button type="submit" id="news-submit-btn">
      <span>Subscribe</span>
      <div class="btn-spinner hidden"></div>
    </button>
  </form>
  <div id="newsletter-message" class="newsletter-message hidden"></div>
</div>

<style>
  .newsletter-card {
    background: rgba(255, 255, 255, 0.05);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 16px;
    padding: 32px;
    max-width: 480px;
    margin: 40px auto;
    box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
    font-family: system-ui, -apple-system, sans-serif;
  }
  .newsletter-header h3 {
    margin: 0 0 8px 0;
    font-size: 24px;
    font-weight: 700;
    background: linear-gradient(135deg, #6366f1, #a855f7);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
  .newsletter-header p {
    margin: 0 0 24px 0;
    color: #9ca3af;
    font-size: 14px;
    line-height: 1.6;
  }
  .newsletter-form {
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  .input-group input {
    width: 100%;
    padding: 12px 16px;
    border: 1px solid rgba(255, 255, 255, 0.15);
    background: rgba(0, 0, 0, 0.2);
    border-radius: 8px;
    color: #fff;
    font-size: 14px;
    box-sizing: border-box;
    transition: all 0.3s ease;
  }
  .input-group input:focus {
    outline: none;
    border-color: #6366f1;
    box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
  }
  #news-submit-btn {
    background: linear-gradient(135deg, #6366f1, #a855f7);
    color: white;
    font-weight: 600;
    border: none;
    padding: 14px;
    border-radius: 8px;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    transition: transform 0.2s ease, opacity 0.2s ease;
  }
  #news-submit-btn:hover {
    transform: translateY(-2px);
    opacity: 0.95;
  }
  #news-submit-btn:active {
    transform: translateY(0);
  }
  .newsletter-message {
    margin-top: 16px;
    padding: 12px;
    border-radius: 8px;
    font-size: 14px;
    text-align: center;
    animation: fadeIn 0.4s ease;
  }
  .newsletter-message.success {
    background: rgba(16, 185, 129, 0.15);
    border: 1px solid rgba(16, 185, 129, 0.3);
    color: #34d399;
  }
  .newsletter-message.error {
    background: rgba(239, 68, 68, 0.15);
    border: 1px solid rgba(239, 68, 68, 0.3);
    color: #f87171;
  }
  .hidden { display: none !important; }
  .btn-spinner {
    width: 20px;
    height: 20px;
    border: 3px solid rgba(255,255,255,0.3);
    border-radius: 50%;
    border-top-color: #fff;
    animation: spin 1s ease-in-out infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>

<script>
  document.getElementById("newsletter-form").addEventListener("submit", async function(e) {
    e.preventDefault();
    const name = document.getElementById("news-name").value;
    const email = document.getElementById("news-email").value;
    const submitBtn = document.getElementById("news-submit-btn");
    const btnText = submitBtn.querySelector("span");
    const spinner = submitBtn.querySelector(".btn-spinner");
    const msgBox = document.getElementById("newsletter-message");

    btnText.classList.add("hidden");
    spinner.classList.remove("hidden");
    submitBtn.disabled = true;
    msgBox.classList.add("hidden");

    // Dynamic URL for local testing & production
    const apiBase = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
      ? 'http://localhost:8787'
      : 'https://hugo-newsletter-worker.ddebajyati.workers.dev';

    try {
      const response = await fetch(`${apiBase}/api/subscribe/start`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, email })
      });

      const data = await response.json();

      if (response.ok && data.success) {
        msgBox.textContent = "Almost there! Please check your inbox to verify your email address.";
        msgBox.className = "newsletter-message success";
        document.getElementById("newsletter-form").reset();
      } else {
        msgBox.textContent = data.error || "An error occurred. Please try again later.";
        msgBox.className = "newsletter-message error";
      }
    } catch (err) {
      msgBox.textContent = "Unable to connect to verification server.";
      msgBox.className = "newsletter-message error";
    } finally {
      btnText.classList.remove("hidden");
      spinner.classList.add("hidden");
      submitBtn.disabled = false;
      msgBox.classList.remove("hidden");
    }
  });
</script>
Enter fullscreen mode Exit fullscreen mode

You can now call this shortcode anywhere in your markdown posts:

{{< _subscription_form >}}
Enter fullscreen mode Exit fullscreen mode

which will appear like this ->

subscription form
and this ->

submitted form

Mail recieved to verify subscription

succesfully verified - redirected to thank you page


Step 5: Generate Latest Post JSON Output

Instead of parsing XML RSS feeds in our scripts,We can configure Hugo to generate a clean metadata file containing our latest post details whenever the site is built.

Add JSON to your outputs inside your hugo.toml:

# hugo.toml
[outputs]
  home = ["HTML", "RSS", "JSON"]
Enter fullscreen mode Exit fullscreen mode

Now, create a file at layouts/index.json to define how the JSON output should look:

{{- $posts := where .Site.RegularPages "Section" "posts" -}}
{{- $latest := index $posts 0 -}}
{
  "title": {{ $latest.Title | jsonify }},
  "description": {{ or $latest.Description $latest.Summary | jsonify }},
  "image": {{ with $latest.Params.images }}{{ index . 0 | absURL | jsonify }}{{ else }}{{ with $latest.Params.og_image }}{{ . | absURL | jsonify }}{{ else }}""{{ end }}{{ end }},
  "url": {{ $latest.Permalink | jsonify }},
  "date": {{ $latest.Date.Format "2006-01-02T15:04:05Z07:00" | jsonify }}
}
Enter fullscreen mode Exit fullscreen mode

This will automatically render a clean JSON payload of your newest post at /index.json.


Step 6: GitHub Actions CI/CD Trigger

Finally, we want our pipeline to trigger the Cloudflare Worker dispatch endpoint securely after a successful Hugo build and deploy.

Create a workflow file at .github/workflows/deploy.yml:

name: Deploy Hugo Blog & Dispatch Newsletter

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci || npm install

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: 'latest'
          extended: true

      - name: Build Hugo Site
        run: npm run build

      - name: Deploy to Cloudflare Pages & Workers
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          # Deploy Hugo build to Cloudflare Pages
          npx wrangler pages deploy public --project-name=indiedevblog --branch=production
          # Deploy the Newsletter Worker to Cloudflare Workers
          npm run deploy:worker

      - name: Dispatch Newsletter Bulk Send
        env:
          BULK_SEND_SECRET: ${{ secrets.BULK_SEND_SECRET }}
          WORKER_URL: ${{ secrets.WORKER_URL }}
        run: |
          # Fetch metadata from compiled JSON
          LATEST_POST_JSON=$(cat public/index.json)
          echo "Latest post meta: $LATEST_POST_JSON"

          # Trigger the Cloudflare Worker dispatch endpoint securely
          curl -X POST "$WORKER_URL/api/send-bulk" \
            -H "Authorization: Bearer $BULK_SEND_SECRET" \
            -H "Content-Type: application/json" \
            -d "$LATEST_POST_JSON"
Enter fullscreen mode Exit fullscreen mode
โš ๏ธ REMEMBER
Store the variables - WORKER_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, BULK_SEND_SECRET in your GitHub repository environment secrets so it is securely passed to the worker.


One-Command Production Deployment


To make things super awesome, we can set up a unified deploy command at the root of our project repository.

First, let's create a package.json file in the root directory:

// package.json (root)
{
  "name": "indiedevblog",
  "version": "1.0.0",
  "description": "Indie Dev Blog Hugo site and Newsletter Worker",
  "private": true,
  "scripts": {
    "build": "hugo --minify",
    "deploy:hugo": "wrangler pages deploy public --project-name=indiedevblog --branch=production",
    "deploy:worker": "npm --prefix newsletter-worker run deploy",
    "deploy": "npm run build && npm run deploy:hugo && npm run deploy:worker"
  },
  "devDependencies": {
    "wrangler": "^4.105.0"
  },
  "dependencies": {
    "hugo-extended": "^0.163.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, make sure you install dependencies in your root directory:

npm install
Enter fullscreen mode Exit fullscreen mode
๐Ÿ’ก Before deploying for the first time, run npx wrangler login in your terminal to authenticate with your Cloudflare account.

With this setup, all you need to do to compile Hugo, push your static blog to Cloudflare Pages, and deploy your newsletter worker is run this single command:

npm run deploy
Enter fullscreen mode Exit fullscreen mode

Full System Architecture in a Nutshell

SYSTEM ARCHITECTYRE


Further Walk-through

The Github Action works perfectly (sends newsletters on new article pushing to GitHub) -

Newsletter Recieved

The Unsubscribe link too -
Successfully unsubscribed


Wrapping Up

There you have it! A fully automated, edge-optimized, budget-friendly newsletter system for your static Hugo blog.

You now have a subscription form that registers users in a D1 database, sends a verification link via AutoSend, redirects them to thank you, and sends a bulk email blast every time you deploy a new article.

If you found this article helpful or if it saved you from paying a high subscription fee, please show some love by sharing it with your developer friends!

Follow me on Dev to motivate me so that I can bring more such tutorials like this on here!

Feel free to connect with me :)

My GitHub My LinkedIn My Daily.dev My Peerlist My Twitter

Happy Coding ๐Ÿง‘๐Ÿฝโ€๐Ÿ’ป๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿ’ป! Have a nice day ahead! ๐Ÿš€

Top comments (2)

Collapse
 
swapnoneel123 profile image
Swapnoneel Saha

Great blog โค๏ธ Autosend is pretty amazing!!

Collapse
 
ddebajyati profile image
Debajyati Dey • Edited

Thank you and yes autosend is amazing but so is cloudflare and hugo. ๐Ÿงก๐Ÿฉท