LinhGo Labs
LinhGo Labs

Set Up Newsletter Subscriptions with Brevo on Cloudflare Pages

A complete guide to implementing newsletter subscriptions using Brevo (formerly Sendinblue) on Cloudflare Pages with serverless functions.

When building a newsletter subscription system for our Hugo blog, we needed a solution that was:

  • Free for small projects with generous limits
  • Easy to integrate via API
  • Reliable and scalable
  • GDPR compliant
  • Feature-rich for future growth
FeatureBrevoSendGridMailchimpEmailOctopus
Free Tier300 emails/day100 emails/day500 contacts2,500 contacts
Contacts LimitUnlimitedUnlimited5002,500
API Accessโœ… Fullโœ… FullโŒ Limitedโœ… Full
Automationโœ… Yesโš ๏ธ Paidโš ๏ธ Paidโš ๏ธ Limited
Templatesโœ… Yesโœ… Yesโœ… Yesโš ๏ธ Basic
GDPR Compliantโœ… Yesโœ… Yesโœ… Yesโœ… Yes
Double Opt-inโœ… Yesโœ… Yesโœ… Yesโœ… Yes

Winner: Brevo offers the best balance of features, generous free tier, and ease of integration.

Our implementation uses:

  • Hugo - Static site generator
  • Cloudflare Pages - Hosting and serverless functions
  • Brevo API - Newsletter management
  • JavaScript - Serverless function runtime
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Hugo Site     โ”‚
โ”‚  (Static HTML)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚
         โ”‚ User subscribes
         โ”‚
         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Cloudflare Function    โ”‚
โ”‚  /newsletter/subscribe  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚
         โ”‚ POST to Brevo API
         โ”‚
         โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Brevo API     โ”‚
โ”‚  (v3/contacts)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
  1. Sign up at brevo.com (free account)
  2. Navigate to Settings โ†’ SMTP & API โ†’ API Keys
  3. Click Generate a new API key
  4. Copy your API key (format: xkeysib-xxxxx)
  5. Create a contact list at Contacts โ†’ Lists
  6. Note your List ID (usually shown in URL)

Create this directory structure in your Hugo project:

your-hugo-site/
โ”œโ”€โ”€ functions/
โ”‚   โ””โ”€โ”€ newsletter/
โ”‚       โ””โ”€โ”€ subscribe.js          # Cloudflare serverless function
โ”œโ”€โ”€ themes/
โ”‚   โ””โ”€โ”€ your-theme/
โ”‚       โ””โ”€โ”€ layouts/
โ”‚           โ””โ”€โ”€ partials/
โ”‚               โ”œโ”€โ”€ sidebar.html   # Newsletter form (sidebar)
โ”‚               โ””โ”€โ”€ footer/
โ”‚                   โ””โ”€โ”€ newsletter.html  # Newsletter form (footer)
โ””โ”€โ”€ wrangler.toml                 # Cloudflare configuration (optional)

Create functions/newsletter/subscribe.js:

// Cloudflare Pages Function for Brevo Newsletter Subscription
export async function onRequestPost(context) {
  try {
    const { request, env } = context;
    
    // Parse request body
    let email;
    try {
      const body = await request.json();
      email = body.email;
    } catch (jsonError) {
      return new Response(JSON.stringify({ 
        message: 'Invalid request format' 
      }), { 
        status: 400,
        headers: { 
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    // Get environment variables
    const BREVO_API_KEY = env.BREVO_API_KEY;
    const BREVO_LIST_ID = env.BREVO_LIST_ID || 2;

    if (!BREVO_API_KEY) {
      return new Response(JSON.stringify({ 
        message: 'Server configuration error' 
      }), { 
        status: 500,
        headers: { 
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    // Validate email
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!email || !emailRegex.test(email)) {
      return new Response(JSON.stringify({ 
        message: 'Invalid email address' 
      }), { 
        status: 400,
        headers: { 
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    // Subscribe to Brevo
    const brevoResponse = await fetch('https://api.brevo.com/v3/contacts', {
      method: 'POST',
      headers: {
        'accept': 'application/json',
        'api-key': BREVO_API_KEY,
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        email: email,
        listIds: [parseInt(BREVO_LIST_ID)],
        updateEnabled: true,
        attributes: {
          SUBSCRIBED_FROM: 'Website',
          SUBSCRIPTION_DATE: new Date().toISOString()
        }
      })
    });

    // Parse response safely
    const responseText = await brevoResponse.text();
    let data = {};
    
    if (responseText) {
      try {
        data = JSON.parse(responseText);
      } catch (parseError) {
        console.error('Failed to parse Brevo response');
      }
    }

    // Handle success
    if (brevoResponse.ok || brevoResponse.status === 201) {
      return new Response(JSON.stringify({ 
        message: 'Successfully subscribed! Please check your email.',
        success: true
      }), { 
        status: 200,
        headers: { 
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }
    
    // Handle duplicate email
    if (brevoResponse.status === 400) {
      if (data.code === 'duplicate_parameter' || 
          (responseText && responseText.includes('already exist'))) {
        return new Response(JSON.stringify({ 
          message: 'This email is already subscribed.',
          success: true
        }), { 
          status: 200,
          headers: { 
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
          }
        });
      }
    }
    
    // Other errors
    return new Response(JSON.stringify({ 
      message: 'Subscription failed. Please try again later.',
      error: data.message || 'Unknown error'
    }), { 
      status: brevoResponse.status || 500,
      headers: { 
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      }
    });
  } catch (error) {
    console.error('Subscription error:', error);
    return new Response(JSON.stringify({ 
      message: 'An error occurred. Please try again later.',
      error: error.message
    }), { 
      status: 500,
      headers: { 
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      }
    });
  }
}

// Handle CORS preflight
export async function onRequestOptions() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    }
  });
}

Add this to your theme’s sidebar or footer:

<form id="newsletter-form" class="newsletter-form">
    <input 
        type="email" 
        name="email"
        placeholder="Your email address" 
        required 
        id="newsletter-email">
    <button type="submit">Subscribe</button>
    <div id="newsletter-message" style="display: none;"></div>
</form>

<script>
(function() {
    const form = document.getElementById('newsletter-form');
    const emailInput = document.getElementById('newsletter-email');
    const messageEl = document.getElementById('newsletter-message');
    
    form.addEventListener('submit', async function(e) {
        e.preventDefault();
        
        const email = emailInput.value.trim();
        
        // Validate email
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!email || !emailRegex.test(email)) {
            showMessage('Please enter a valid email address', 'error');
            return;
        }
        
        try {
            const response = await fetch('/newsletter/subscribe', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ email: email })
            });
            
            const data = await response.json();
            
            if (response.ok || data.success) {
                showMessage(data.message, 'success');
                emailInput.value = '';
                localStorage.setItem('newsletter_subscribed', 'true');
            } else {
                showMessage(data.message, 'error');
            }
        } catch (error) {
            showMessage('Unable to connect. Please try again later.', 'error');
        }
    });
    
    function showMessage(text, type) {
        messageEl.textContent = text;
        messageEl.className = 'message-' + type;
        messageEl.style.display = 'block';
        
        if (type === 'success') {
            setTimeout(() => {
                messageEl.style.display = 'none';
            }, 5000);
        }
    }
})();
</script>
  1. Push to GitHub:
git add .
git commit -m "Add newsletter integration with Brevo"
git push origin main
  1. Connect to Cloudflare:

    • Go to Cloudflare Dashboard
    • Navigate to Workers & Pages โ†’ Create application โ†’ Pages
    • Click Connect to Git
    • Select your repository
  2. Configure Build Settings:

    • Build command: hugo --gc --minify
    • Build output directory: public
    • Root directory: / (default)
  3. Add Environment Variables:

    • Go to Settings โ†’ Environment variables
    • Add for Production:
      • BREVO_API_KEY = xkeysib-your-key-here
      • BREVO_LIST_ID = 2 (your list ID)
  4. Deploy!

# Install Wrangler
npm install -g wrangler

# Login to Cloudflare
wrangler login

# Build Hugo site
hugo --gc --minify

# Deploy
wrangler pages deploy public --project-name=your-site

# Add environment variables
wrangler pages secret put BREVO_API_KEY --project-name=your-site
wrangler pages secret put BREVO_LIST_ID --project-name=your-site

After deployment:

  1. Visit your site and try subscribing
  2. Check Brevo dashboard at Contacts โ†’ Lists to see new subscriber
  3. Test duplicate subscription with same email
  4. Monitor logs in Cloudflare Dashboard โ†’ Functions
curl -X POST https://yoursite.com/newsletter/subscribe \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com"}'

Expected response:

{
  "message": "Successfully subscribed! Please check your email.",
  "success": true
}

In Brevo dashboard:

  1. Go to Contacts โ†’ Settings
  2. Enable Double opt-in
  3. Customize confirmation email template
  1. Go to Campaigns โ†’ Templates
  2. Create new email template
  3. Set up automation at Automation โ†’ Workflows
  4. Trigger: “Contact added to list”

Add honeypot fields to your form:

<!-- Hidden fields that bots will fill -->
<input type="text" name="bot-field" 
       style="position: absolute; left: -5000px;" 
       aria-hidden="true" 
       tabindex="-1">

Check in your serverless function:

const botField = formData.get('bot-field');
if (botField) {
  return; // Silent fail for bots
}

Add UTM parameters to track where subscribers come from:

attributes: {
  SUBSCRIBED_FROM: 'Website',
  SOURCE_PAGE: window.location.pathname,
  UTM_SOURCE: new URLSearchParams(window.location.search).get('utm_source') || 'direct'
}

For testing locally, create a Go server that mimics the Cloudflare function:

// server.go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/api/newsletter/subscribe", handleSubscribe)
    http.Handle("/", http.FileServer(http.Dir("./public")))
    
    http.ListenAndServe(":8080", nil)
}

func handleSubscribe(w http.ResponseWriter, r *http.Request) {
    // Same logic as JavaScript function
    // Uses os.Getenv("BREVO_API_KEY")
}

Run with:

hugo --gc --minify
go run server.go
  1. Dashboard - Overall statistics
  2. Contacts โ†’ Lists - List growth
  3. Reports - Email performance
  1. Workers & Pages โ†’ Your project โ†’ Analytics
  2. View function invocations, errors, duration
  3. Monitor API costs (usually free under limits)
  • โœ… 300 emails per day
  • โœ… Unlimited contacts
  • โœ… Full API access
  • โœ… Email templates & automation
  • โœ… Unlimited bandwidth
  • โœ… 500 builds/month
  • โœ… 100,000 function requests/day
  • โœ… No credit card required

Total Monthly Cost: $0 ๐ŸŽ‰

  1. Never commit API keys - Use environment variables only
  2. Enable CORS properly - Set appropriate origins in production
  3. Rate limiting - Consider implementing on high-traffic sites
  4. GDPR compliance - Enable double opt-in, provide unsubscribe
  5. Privacy Policy - Update to mention Brevo as data processor
  • Verify BREVO_API_KEY is set in Cloudflare environment variables
  • Check API key is active in Brevo dashboard
  • Usually occurs with duplicate emails
  • Fixed by parsing response as text first, then JSON
  • Ensure file is at functions/newsletter/subscribe.js
  • Check Cloudflare deployment logs
  • Verify function was built and deployed
  • Add proper CORS headers to all responses
  • Implement onRequestOptions handler

Brevo + Cloudflare Pages provides a powerful, free, and scalable newsletter solution for static sites. Key benefits:

  • โœ… Zero cost for most sites
  • โœ… Serverless architecture - no servers to manage
  • โœ… Global edge deployment - fast worldwide
  • โœ… GDPR compliant out of the box
  • โœ… Scalable - handles traffic spikes automatically

The combination of Hugo’s static generation, Cloudflare’s edge functions, and Brevo’s email infrastructure creates a modern, performant newsletter system.


Questions? Leave a comment below. Happy coding!!!