Run a newsletter for free with Hugo, ButtonDown and Netlify

A witch cooking a magic potion

The Jamstack is awesome, but things get tricky when it comes to interaction, comments, contact forms or newsletters.

Let’s tackle the latter: today we’ll set up a Newsletter on a website generated with Hugo.
Note that the same technique should work with any static site generator.

You’re in a hurry? Browse the code and check out the demo (the backend is disabled to minimize requests). Note that some additional settings must be done in the UI, see below for details.

Updated in May 2022

The pricing of ButtonDown evolved, this post was updated accordingly

The outcome#

The subscription form#

The subscription form

This is a screenshot, stop clicking like hell! See the live demo instead (and enjoy the flexbox fanciness!)

Send a new mail to your subscribers#

A screenshot of Buttondown interface

Key features:#

See the documentation for details

The stack#

This solution uses ButtonDown for the newsletter and leverages the Netlify’s Forms and Serverless functions to manage the subscriptions.

Is it really free? Both services come with these generous free tiers:

We assume below that you have an account on ButtonDown and that your code is hosted at Netlify.

Let’s go!#

Here are the steps we’ll follow:

  1. Create the submission form
  2. Create a Netlify Function
  3. Add dependencies to package.json
  4. Get your Token from Buttondown
  5. Set up the Token on Netlify
  6. Customize netlify.toml
  7. Enjoy!

1. Create the submission form#

Create a file layouts/partials/newsletter.html with these lines (no edition is required):

<div class="newsletter widget">

  <div class="content newsletter-header">
    <h2 class="newsletter-title">Subscribe to our newsletter</h2>
    <p><span class="newsletter-tagline">Receive updates and insights, right in your mailbox.</span><br>
      <span class="newsletter-small"> No spam. Unsubscribe anytime.</span></p>

  <form name="newsletter"  method="POST"   data-netlify="true" netlify-honeypot="bot-field">
    class="form-control valid"
    onfocus="this.placeholder = ''"
    onblur="this.placeholder = 'Enter your Name'"
    placeholder="Enter your Name"
      class="form-control valid"
      onfocus="this.placeholder = ''"
      onblur="this.placeholder = 'Enter your Email'"
      placeholder="Enter your Email"

      placeholder="This is a spam detector, don't fill this out if you're a human."

    <button type="submit" class="button">
      Yes, I'm in !



Then load this form anywhere#

For example, in layouts/footer.html

<!-- newsletter -->
{{- partial "newsletter.html" . -}}

2. Create a Netlify Function#

Create a file netlify/functions/submission-created.js (still no customization required):


import fetch from 'node-fetch';
const { BUTTONDOWN_API_KEY } = process.env

exports.handler = async event => {
  const payload = JSON.parse(event.body).payload
  console.log(`Recieved a submission: ${}`)

  return fetch("", {
    method: "POST",
    headers: {
      Authorization: `Token ${BUTTONDOWN_API_KEY}`,
      "Content-Type": "application/json",
    body: JSON.stringify({ email:, notes: }),
    .then(response => response.json())
    .then(data => {
      console.log(`Submitted to Buttondown:\n ${data}`)
    .catch(error => ({ statusCode: 422, body: String(error) }))

Note: we’ll set up the BUTTONDOWN_API_KEY later in the UI.

3. Add dependencies to package.json#

npm install --save dotenv
npm install --save node-fetch@2

Note that using node-fetch breaks things (See below in the Troubleshooting section). Use node-fetch@2 instead.

Commit the changes made in package.json. As an example, here is how this commit looked like for

4. Get your secret Token from Buttondown#

Netlify needs a secret Token to push the data to the ButtonDown API.

You can get this Token from the ButtonDown interface:

Get the Token from the Programming sub-menu

The Token is in the API Key section:

Copy the entry under API Key

Keep this token private!

Anyone with this secret can access the list of your subscribers and publish emails on your behalf.. Do not commit this secret in a public repo!

5. Set up the Token on Netlify#

Go to Site settings → Build & deploy → Environment

The Netlify UI to set Environment variables

In the section “Environment variables”, click on “Edit variables

Netlify edit variable

Add a “New variable” called BUTTONDOWN_API_KEY and enter your Token.

(Note that a new deploy is required to activate these changes)

6. Customize netlify.toml#

The config file depends on your project, here is an example:

  command = "npm install && hugo --minify"
  publish = "public"

  node_bundler = "esbuild"

See the official documentation and the Troubleshooting section below to learn more.

7. Enjoy!#

You can now test and tweak the Buttondown settings and wait for your first subscribers.

Check out the Buttondown documentation to discover the innumerable features.


node-fetch complains: “You are not able to import it with require()”#

The original method leads to this error:

node-fetch from v3 is an ESM-only module - you are not able to import it with require()

This is fixed with node_bundler = "esbuild" and switching to node-fetch@2 - Source

Here is the full error message for indexation (click to expand) undefined ERROR Uncaught Exception { “errorType”: “Error”, “errorMessage”: “Must use import to load ES Module: /var/task/node_modules/node-fetch/src/index.js\nrequire() of ES modules is not supported.\nrequire() of /var/task/node_modules/node-fetch/src/index.js from /var/task/functions/submission-created.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.\nInstead rename index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /var/task/node_modules/node-fetch/package.json.\n”, “code”: “ERR_REQUIRE_ESM”, “stack”: [“Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /var/task/node_modules/node-fetch/src/index.js”, “require() of ES modules is not supported.”, “require() of /var/task/node_modules/node-fetch/src/index.js from /var/task/functions/submission-created.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.”, “Instead rename index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /var/task/node_modules/node-fetch/package.json.”, “”, " at Object.Module._extensions..js (internal/modules/cjs/loader.js:1015:13)", " at Module.load (internal/modules/cjs/loader.js:863:32)", " at Function.Module._load (internal/modules/cjs/loader.js:708:14)", " at Module.require (internal/modules/cjs/loader.js:887:19)", " at require (internal/modules/cjs/helpers.js:74:18)", " at Object. (/var/task/functions/submission-created.js:3:15)", " at Module._compile (internal/modules/cjs/loader.js:999:30)", " at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)", " at Module.load (internal/modules/cjs/loader.js:863:32)", " at Function.Module._load (internal/modules/cjs/loader.js:708:14)"] }

Still stucked?#

Here are a few places to get more info:


Something to improve?#

You’re stuck somewhere, or found an issue? You successfully implemented this guide?

Please take a few minutes to drop me an email or leave a comment below.
I love to receive messages, each one gets an answer and every contributors will be acknowledged right here!


Related articles: