Fat Buddha Designs

Affordable, Creative, Hand-Crafted Websites

Airtable As An Eleventy CMS

Eleventy logo

Published:

Reading Time: (approx) 7 min

Tags that this post has been filed under.

The main content about Airtable As An Eleventy CMS

2024 has seen a change in the way I would like to work with some of my clients.

I have been using Eleventy for a few years now and have managed changes to client websites by locally making the updates, pushing to Github which then deploys to Netlify. Some changes are only small and although they only take a few minutes, they disrupt my workflow, they also make invoicing for the time a pain.

With this in mind I had been looking at various, no/low cost CMS solutions, this would mean that clients could make minor changes to their own websites without bothering me.

I had a look at Decap(was Netlify), Sanity, Strapi, Contentful and CloudCannon. Most of these where ruled out because of the added cost apart from Decap, which I found a bit clunky.

I then came across Airtable, which has a generous free tier, which allows for you having 1000 records.

Why did I choose Airtable?

As most of the websites I build are for start-ups or small businesses most have budget constraints, this means that if a CMS is needed, then one with a free tier is always going to be a winner.

I was also looking for a CMS that could handle a few separate record types like, pages, events, blogposts and products, Airtable seemed to be and has proven to be very adaptable.

From the website point of view Airtable offers a great API with json output, this can be paired with the Eleventy Fetch feature to retrieve the data from Airtable.

Setting up Airtable

Setting up Airtable is simple, just go to Airtable and sign up for free.

Once you have an account, make a base (which is like a database), and then add the tables that you need, say Blogposts and Pages.

Each of the tables will then contain a series of records, each record is like a separate Blogpost or Page.

Within each record you can add as many Fields as you want, for Blogposts you may want a Title, Description, Published Date, Image and then some Content. You have to choose the correct field type for each field, for the content I would choose Long Text and select the rich text option.

Once you have completed filling in all the fields in all the records you will need to create a ‘personal access token’ and then head to the Airtable API and get your ‘base token’ once you have these you are ready to setup your Eleventy build to fetch the output from Airtable.

Connecting with Eleventy Fetch

I already had a local setup that I wanted to pull the Airtable data into, but if you are starting out from new with Eleventy head here and follow the instructions on getting started.

Once you are setup you will need to install dotenv to use your tokens, to install dotenv use npm install dotenv --save. Next create an .env file in the root of your Eleventy project, this will look something like this,

AIRTABLE_PERSONAL_ACCESS_TOKEN = 'pat#################.#########################'
AIRTABLE_BASE_TOKEN = 'appa#################'

To stop this information from getting uploaded and accessed by others be sure to add .env to your .gitignore file, if you don’t have one, create it in the root of your project.

With my set up I needed to pull the data from Airtable using Eleventy Fetch, I followed the install instructions from this page to install Eleventy Fetch and then installed airtable.js using npm install airtable.

With Eleventy I would normally use collections built around locally held content, but for this setup no collections are used but instead data files are used.

For all Airtable tables that contain no images (I will explain in a minute) my data files looks like this, so say for events, I created an events.js file in my _data directory.

require("dotenv").config();
const { AssetCache } = require("@11ty/eleventy-fetch");
const Airtable = require("airtable");
const airtableTable = "Events";
const airtableTableView = "All";
const assetCacheId = "Events";
var base = new Airtable({ apiKey: process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN }).base(process.env.AIRTABLE_BASE_TOKEN);

module.exports = () => {
  let asset = new AssetCache(assetCacheId);

  if (asset.isCacheValid("1d")) {
    console.log("Serving airtable data from the cache…");
    return asset.getCachedValue();
  }

  return new Promise((resolve, reject) => {
    let allEvents = [];

    base(airtableTable)
      .select({
        view: airtableTableView,
        filterByFormula: 'IS_AFTER({ShowDate}, TODAY())',
        sort: [{ field: "ShowDate", direction: "asc" }],
      })
      .eachPage(
        function page(records, fetchNextPage) {
          records.forEach((record) => {
            allEvents.push({
              id: record._rawJson.id,
              ...record._rawJson.fields,
            });
          });
          fetchNextPage();
        },
        function done(err) {
          if (err) {
            reject(err);
          } else {
            asset.save(allEvents, "json");
            resolve(allEvents);
          }
        },
      );
  });
};

The ‘filterbyFormula’ function is used to only show any records that are in the future, any that have past dates will be ignored.

To use the events.js data I created a page called events.njk using this code:-

{% if events.length %}
{% for event in events -%}
<div class="block | flow">
  <div class="event--content">
    <p class="is--heading">{{ event.Title }}</p>
    <p>{{ event.StartDate | longDate }}{%- if event.FinishDate -%}-{{ event.FinishDate | longDate }}{%- endif -%}</p>
    <p>{{ event.Location }}</p>
    <p>{{ event.Content | safe }}</p>
  </div>
</div>
{%- endfor -%}
{%- endif -%}

Airtable Images

Airtable only keeps links to images for 2hours, so if you are caching data the links to images will break after this. I realised I needed a different approach for any records that contained images.

I must admit I was a bit stumped, so I asked a question on the Eleventy discord channel and was given a complete code example for this by @saneef.

My products.js looked like this:-

require("dotenv").config();
const Airtable = require("airtable");
const { AssetCache } = require("@11ty/eleventy-fetch");
const airtableTable = "Products";
const airtableTableView = "Grid";
const Image = require("@11ty/eleventy-img");

const IMAGES_URL_PATH = "/assets/content/images/";
const IMAGES_OUTPUT_DIR = `./_site${IMAGES_URL_PATH}`;

const CACHE_DURATION = "2h";

async function getProductsData() {
  // Initialize Airtable API instance
  const base = new Airtable({ apiKey: process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN }).base(process.env.AIRTABLE_BASE_TOKEN);
  let records = [];
  try {
    // Get all the records from a view.
    // This is easier than getting data page wise
    records = await base(airtableTable)
      .select({
      view: airtableTableView,
      filterByFormula: '{InStock} = 1',
      sort: [{ field: "Title", direction: "asc" }],
    })
      .all();
  } catch (e) {
    // Show error and return empty array on failures
    console.error(e);
    return [];
  }

  // Get only fields
  let fields = records.map((r) => {
    return r.fields;
  });

  // Pick URLs from Image object array
  // I'm picking the URL to the full Image, as this will be post processed
  // through Eleventy Image later in the pipeline
  fields = fields.map((f) => {
    const photo = f.Photo;
    return {
      ...f,
      Photo: photo?.map((p) => p?.url),
    };
  });

  // Remove entries with no title
  fields = fields.filter((f) => Boolean(f.Title));

  return fields;
}

async function processRemoteImages(products) {
  // Using Promise.all to wait until all product objects
  // are processed.
  return Promise.all(
    products.map(async (p) => {
      // Picking the first photo from the array
      const url = p.Photo[0];

      const metadata = await Image(url, {
        widths: [800, 600, 400],
        urlPath: IMAGES_URL_PATH,
        outputDir: IMAGES_OUTPUT_DIR,
        formats: ["webp", "jpeg"],
        cacheOptions: {
          duration: CACHE_DURATION,
        },
      });

      const pictureElement = Image.generateHTML(metadata, {
        alt: `Thumbnail for ${p.Title}`,
        sizes: "100vw",
      });

      // This is to remove 'Images' properties
      // from the object without mutating
      const { Photo, ...restOfProduct } = p;

      return {
        ...restOfProduct,
        pictureElement,
      };
    }),
  );
}

module.exports = async function () {
  const productsCache = new AssetCache("airtable-products");
  if (productsCache.isCacheValid(CACHE_DURATION)) {
    return productsCache.getCachedValue(); // This returns a promise
  }
  console.log("Cache expired. Fetching data from Airtable");
  let products = await getProductsData();
  products = await processRemoteImages(products);
  await productsCache.save(products, "json");
  return products;
};

In this example the ‘filterbyFormula’ function is used to only show records that are currently in stock, any that have no stock will be ignored.

The output from my products.js file can then be used in a product layout like this:-

<article class="product--detail--block">
  <h1>{{ product.Title }}</h1>
  <p>{{ product.Excerpt }}</p>
  <div class="product--item--block">
  <div class="product--image">
    {{ product.pictureElement | safe }}
  </div>
  <div class="product--details">
    {{ product.Content | toHTML | safe }}
    <p class="is--bold">{{ product.Price | stripmarks | addDecimals }}</p>
    <button class="snipcart-add-item"
    data-item-id="{{ product.SKU }}"
    data-item-name="{{ product.Title }}"
    data-item-price="{{ product.Price | addDecimals }}"
    data-item-weight="{{ product.Weight }}"
    data-item-quantity="1"
    data-item-image="{{ meta.url }}{{ product.pictureElement | safe | stripimage }}"
    data-item-url="{{ meta.url }}/products/{{ product.Title | slugify }}"
    >Add To Cart</button>
  </div>
  </div>
</article>

Adding Filters

As you can see from the code section above there are a few filters in use.

I found that the output from Airtable as I had it was causing me a few issues.

I have all my filters in a file called filters.js in folder off the root directory called config.
In my eleventy.config.js I then call the filters like this:-

const filters = require('./config/filters.js');

module.exports = function (eleventyConfig) {

  // 	------------------ Filters ---------------------
  Object.keys(filters).forEach((filterName) => {
    eleventyConfig.addFilter(filterName, filters[filterName])
  }
};

The first issue that needed correcting was the ‘Price’ element, although set to a currency in Airtable the output was either a whole number or rounded to one decimal place i.e. ‘£5.00’ is output as ‘5’ and ‘£5.50’ is output as ‘5.5’.

For Snipcart(the Ecommerce app used in this store) to work correctly currency values need to be to 2 decimal places, hence the filter ‘addDecimals’ which looks like this:-

module.exports = {
  addDecimals: function (value) {
    value = (value / 1).toFixed(2);
    return value;
  },
};

This takes the input value, divides it by 1 and then outputs that value to 2 decimal places.

Just prior to this I use another filter called ‘stripmarks’.

This filter simply removes any quotation marks around a value.

stripmarks: function (value) {
  value = lodash.replace(value, /\"$/, ' ');
  return value;
},

And lastly, for the image that is shown in the checkout of Snipcart, which is represented by this line of code in the layout.

 data-item-image="{{ meta.url }}{{ product.pictureElement | safe | stripimage }}"

I used another filter called ‘stripimage’, this takes the output from the {{ product.pictureElement | safe }} which looks like this:-

<picture>
  <source type="image/webp" srcset="/assets/content/images/u1tlE1807R-375.webp 375w" sizes="100vw">
  <img alt="Thumbnail for £5 Gift Voucher" src="/assets/content/images/u1tlE1807R-375.jpeg" width="375" height="500">
</picture>

And instead it outputs like this:-

data-item-image="https://myshop.co.uk/assets/content/images/u1tlE1807R-375.jpeg"

The filter itself which uses regex, strips away everything before/assets and everything after .jpeg looks like this:-

stripimage: function (value) {
  value = lodash.replace(value, /.*\bsrc="(\/assets[^"]*\.jpeg)".*/, '$1');
  return value;
},

Deploying With Netlify

With this particular website, to keep costs to a minimum the client is using the free tier of Airtable, unfortunately this means that deploys to Netlify cannot be triggered by a script once a record is modified, scripts are only available in the paid tiers.

So I needed to find a way to trigger a build, my solution came via a quick online search. I found this article which explains every detail, and with minimal effort allowed me to trigger builds weekly, this could be modified quite easily to build daily or even hourly.

For my build I added daily-build.yml to .github/workflows directory in the root of my website, which looked like this:-

name: Scheduled build
on:
  schedule:
    - cron: '15 4 * * 5'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger our build webhook on Netlify
        run: curl -s -X POST "https://api.netlify.com/build_hooks/${TOKEN}"
        env:
          TOKEN: $

The rest of the setup was implemented by following the article above.

Conclusion

Having now overcome the challenges above I feel that Airtable and it’s free tier is a viable CMS for Eleventy.

My client is now able to update records without me having to stop my workflow. You can see her finished website here.

Footnote

I have now converted another website over to use Airtable as the CMS, it’s for a little side hustle business of mine but this time there’s no call for Snipcart so it’s a bit simpler to implement.

Journal Index

Next Article: How Green Is Your Website?