Adding one centralised banner to a whole portfolio of websites via the power of 'the edge'
Tags: cdn, javascript, cloud, edge
Published: 2022-09-15 23:22:40

If you work for a UK organisation or company in a web/digital role, you've probably spent at least some time in the last week adding a banner to one of your sites referencing the Queen's death and your employer's condolences.

If you live elsewhere in the world then maybe you've worked on a similar banner but relating to Covid, Russia's war against Ukraine, condemning the murder of George Floyd and/or supporting the BLM movement. If you work in a city centre for a very big company, your employer may have a plan for responding to an act of terrorism that involves pushing rapidly changing messages to your website. On the lighter side of things, you may have been asked to quickly add a banner to celebrate something joyous, such as a well-deserved prize win.

In my previous job, we did a brisk trade at the start of Covid in adding zero-deployment banners to client sites using Google Tag Manager to inject JavaScript markup into their pages, but this approach causes [layout shift](https://web.dev/cls) and is slow. In this article, we'll look at a far superior approach made possible by modern CDNs and providers like AWS, Cloudflare, Fastly, Akamai, and Netlify.

## Responding to an emergency in a unified way across a whole estate of corporate websites

When 💩 hits the fan it's important to present a unified front and to make information available everywhere as soon as it is ready, but without the overhead and risk of human error that comes from trying to coordinate too many teams. Plus, your product owners, delivery managers, developers, and everyone else probably have more valuable things to be doing than cascading content changes and babysitting production releases to tweak the text of a banner that many of your customers will end up ignoring (he says, not speaking from personal experience – honest!)

Imagine working in a large company and needing to add your message of celebration, danger, or woe to the whole estate or portfolio of different websites, including a marketing site, an e-commerce site, the company blog, a jobs site, an intranet, and so on. Or working in a group of companies, like how the Co-op (my employer) has a website for our [food/convenience store business](https://www.coop.co.uk), an [ecommerce website for our food business](https://shop.coop.co.uk), a website for our [legal services business](https://www.co-oplegalservices.co.uk), [insurance business](https://www.coop.co.uk/insurance), [funeralcare business](https://www.coop.co.uk/funeralcare), plus for our [membership proposition](https://www.coop.co.uk/membership), and so on. That's a lot of different websites to add our banner to!

Time would be of the essence, and in an emergency or [yet another 'unprecedented'](https://twitter.com/gracepetrie/status/1568544921719554056) situation there would be no time for confusion, so we wouldn't want the individual product teams that own all these sites to have to manage the content and release the changes.

It wouldn't be too hard to have a single place delivering the message banner content (a centrally managed CMS), but it's harder to inject the HTML, JavaScript, and CSS into a collection of different sites at once without requiring a bunch of different systems to be updated and deployment/release processes to be kicked off separately.

## How can a global piece of content be added frictionlessly to multiple sites?

For a demo, I used a Netlify Edge Function to make an example endpoint ([source](https://github.com/philwolstenholme/wolstenhol-11ty/blob/main/netlify/edge-functions/fake-edge-messages-endpoint.js)) at [https://wolstenhol.me/api/fake-edge-messages-endpoint](https://wolstenhol.me/api/fake-edge-messages-endpoint) that returns one of 3 possible messages:

```javascript
const imagineTheseCameFromACMS = [
  {
    theme: THEMES.emergency,
    text: "Something bad has happened, but all our staff are safe. We are posting hourly updates on our Twitter page.",
    link: "https://twitter.com/philw_",
  },
  {
    theme: THEMES.sombre,
    text: "We are saddened by X and wish Y",
  },
  {
    theme: THEMES.celebratory,
    text: `We won best place to work ${new Date().getFullYear()}!!!11`,
    link: "https://example.com/a-pr-blog-article",
  },
];
```

As the code says, imagine this endpoint comes from a CMS like Contentful, Drupal, WordPress etc and is managed by a central comms team who knows to only use it when something has happened that is either very special (yay!), very terrible (eeek…), or very important-in-the-eyes-of-PR (no comment 💂). 

I also made the endpoint return a hash of each banner's content, so we can uniquely identify each banner. This will be useful when we want to make the banners dismissable and remember if the current banner has been dismissed or not.

Here's an example of the simple JSON response it could return:

```bash
➜  ~ curl https://wolstenhol.me/api/fake-edge-messages-endpoint
{
  "theme": "celebratory",
  "text": "We won best place to work 2022!!!11",
  "link": "https://example.com/a-pr-blog-article",
  "hash": "0f8a02f8e424c95d9768ecfb8cf5ac5772c6d0ef5706fde819f540375d39d93b"
}
```

or in darker times:

```bash
➜  ~ curl https://wolstenhol.me/api/fake-edge-messages-endpoint
{
  "theme": "emergency",
  "text": "Something bad has happened, but all our staff are safe. We are posting updates on our Twitter page.",
  "link": "https://twitter.com/bigcorp",
  "hash": "d98d74b647b5eb02ce1cde89001532df2820758b11c70fa78b53d0e72080aa3e"
}
```

But how to turn this JSON into HTML, inserted across a range of different sites, all on different tech stacks and connected to their own CMSs, or even inserted into static content with no CMS at all – but all on the same CDN, or a CDN with similar functionality? Enter, ✨the edge✨…

## What is 'the edge'? ('the Edge'? 'The Edge?' 🤷‍♂️)

Let's ask Google and [Cloudflare](https://www.cloudflare.com/learning/serverless/glossary/what-is-edge-computing/):

> Edge computing is a networking philosophy focused on bringing computing as close to the source of data as possible in order to reduce latency and bandwidth use. In simpler terms, edge computing means running fewer processes in the cloud and moving those processes to local places, such as on a user’s computer, an IoT device, or an edge server [like a CDN]. Bringing computation to the network’s edge minimizes the amount of long-distance communication that has to happen between a client and server.

For the context of this article, the edge will be our CDN, and we can program it using tools like [Cloudflare Workers](https://blog.cloudflare.com/introducing-cloudflare-workers), [Netlify Edge Functions](https://www.netlify.com/blog/edge-functions-explained/), [Lambda@Edge (AWS)](https://aws.amazon.com/lambda/edge/), [Akamai EdgeWorkers](https://developer.akamai.com/akamai-edgeworkers-overview), or [Fastly Compute@Edge](https://www.fastly.com/products/edge-compute), and so on.

These tools allow us to modify the network response as it passes through the edge/CDN layer, for example modifying the headers or the body of a response. 

I use Cloudflare Workers to offer a '[no JS](https://wolstenhol.me/no-js)' and '[no CSS](https://wolstenhol.me/no-css)' version of my personal website, as a way for me to check how the site looks with no CSS (this gives you clues as to whether someone is using semantic HTML elements or not) and how the site works with JavaScript blocked or disabled (or when I've written such terrible JavaScript that the whole thing falls over). These workers remove script or style-related HTML elements and also add an `x-robots-tag` HTTP header to prevent search engines from indexing these pages.

## Adding a global messages banner via _the edge_

If I can remove CSS and `<script>` elements from my site with a Cloudflare worker, then it should be easy to also use them to _add_ in some content.

Imagine one of our company websites is [example.com](https://example.com), a reserved domain name to be used in documentation and example content so safe for us to play around with today.

Here's example.com normally:

![Screenshot of the normal view of example.com](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ykq396ekkx5wskb30ubv.png)

And here's example.com when run through our Cloudflare Worker. If we owned example.com we could stick the worker in front of it, so anyone requesting example.com would see the banner. For now, though, we can see the banners by visiting [https://global-banners-from-the-edge.philgw.workers.dev](https://global-banners-from-the-edge.philgw.workers.dev):

![Screenshot of example.com but now showing a black and white 'We are saddened by X and wish Y' banner at the top of the page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/67f05lr9daip3n0niygn.png)

## How is this is working?

I don't own example.com, I don't know how it gets updated or what kind of technology it runs on, but I've added a banner to the top of it. If you reload the page you can see the other banners appear randomly, as the example API returns different content each time.

![Screenshot of example.com but now showing a colourful 'We won best place to work 2022' banner at the top of the page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yvu5ylcvqvdh0vc5bcs1.png)

![Screenshot of example.com but now showing a red 'Something bad has happened, but all our staff are safe. We are posting updates on our Twitter page' banner at the top of the page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dqne22u17x9v5i9v9zdz.png)

We could do the exact same thing for any other site our company owned. Here I've put the worker in front of [one of those 'best mother––––ing website' websites](https://github.com/lyoshenka/awesome-motherfucking-website#readme)…

![Screenshot of mother f u c k ing website.com but now showing a colourful 'We won best place to work 2022' banner at the top of the page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f4cw72vdaf3xafi1zi1q.png)

…and in front of Wikipedia:

![Screenshot of Wikipedia but now showing a red 'Something bad has happened, but all our staff are safe. We are posting updates on our Twitter page' banner at the top of the page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cme3xhzgaa0lfbh1o1f9.png)

The point is that if all your sites were behind Cloudflare, AWS Cloudfront with Lambda@Edge, Akamai EdgeWorkers, Fastly Compute@Edge, all hosted on Netlify, or on any of the other services offering edge workers then **we could run the same worker on all of them, and one central source of banner content could update all of these different sites** without ever needing to even open their CMS or their codebase. That's the power of edge functions!

## Show me the code!

Most of the edge function providers provide an easy way to rewrite the content of the page. This is great for doing things like A/B testing or cookie banners without heavy JavaScript dependence. In Cloudflare's case we use a [`HTMLRewriter`](https://developers.cloudflare.com/workers/runtime-apis/html-rewriter) class.

### As a Cloudflare Worker

Here's a simplified version where we use a worker to retrieve some data and add some HTML, JavaScript and CSS to the page, as well as modify the `Cache-Control` header of the page:

```javascript
addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event.request));
});

const getData = async () => {
  const response = await fetch(
    "https://wolstenhol.me/api/fake-edge-messages-endpoint"
  );

  if (response.ok) {
    return response.json();
  } else {
    return null;
  }
};

class HeadHandler {
  constructor(data) {
    this.data = data;
    // Please excuse the use of a third-party origin CDN,
    // I wouldn't do this in prod…
    this.styles = `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/herald-of-the-dog@1.0.3/herald.css" integrity="sha256-zQLpc/AA/o1D8NgVLASianBlbMPs9i4carXMzf/L4mY=" crossorigin="anonymous">`;
    this.scripts = `<script src="https://cdn.jsdelivr.net/npm/herald-of-the-dog@1.0.3/herald.js" integrity="sha256-AcoJNZAkXVxpi/5ZW/CXeUadY0z5rEH7h/3OAs5HnTg=" crossorigin="anonymous"></script><script>let key = "${data.hash}"; let savedKey = localStorage.getItem("banner--cta-url"); if(savedKey === key) { document.documentElement.classList.add("banner--hide"); }</script>`;
  }

  element(element) {
    element
      .append(this.styles, { html: true })
      .append(this.scripts, { html: true });
  }
}

class BodyHandler {
  constructor(data) {
    this.data = data;

    this.bannerTemplate = `<announcement-banner data-banner-key="${
      data.hash
    }" data-theme="${data.theme}"> ${
      data.link ? `<a href="${data.link}">` : ""
    } ${data.text} ${
      data.link ? `</a>` : ""
    } <button type="button" data-banner-close>Close</button></announcement-banner>`;
  }

  element(element) {
    element
      .prepend(this.bannerTemplate, { html: true });
  }
}

async function handleRequest(request) {
  const data = await getData();
  // const originalResponse = await fetch(request);
  // In real-world usage the below line wouldn't be
  // necessary as we would work with the current request.
  // see: https://developers.cloudflare.com/workers/examples/modify-response
  const response = await fetch("https://example.com");

  if (!data || !data?.text) {
    return response;
  }

  const transformedResponse = new HTMLRewriter()
    .on("head", new HeadHandler(data))
    .on("body", new BodyHandler(data))
    .transform(response);

  // Don't cache the page so that we can update the banner easily.
  transformedResponse.headers.set("cache-control", "no-store, must-revalidate");
  return transformedRes;
}
```

### As a Netlify Edge Function

There are only a few changes needed to get the above code to work as a Netlify Edge Function.

First of all, we can import a [port of Cloudflare's HTMLRewriter](https://deno.land/x/html_rewriter@v0.1.0-pre.17) which will allow us to reuse all of our HTML rewriting/insertion code.

Secondly, we move the logic that was inside the `handleRequest` function into the default export.

And that's it! You can [see the results here](https://wolstenhol.me/examples/example-banner) and the [full code here](https://github.com/philwolstenholme/wolstenhol-11ty/blob/main/netlify/edge-functions/example-banner.js).

```javascript
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/html-rewriter/index.ts";

const getData = async () => {
  const response = await fetch(
    "https://wolstenhol.me/api/fake-edge-messages-endpoint"
  );

  if (response.ok) {
    return response.json();
  } else {
    return null;
  }
};

class HeadHandler {
  constructor(data) {
    this.data = data;
    // Please excuse the use of a third-party origin CDN,
    // I wouldn't do this in prod…
    this.styles = `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/herald-of-the-dog@1.0.3/herald.css" integrity="sha256-zQLpc/AA/o1D8NgVLASianBlbMPs9i4carXMzf/L4mY=" crossorigin="anonymous">`;
    this.scripts = `<script src="https://cdn.jsdelivr.net/npm/herald-of-the-dog@1.0.3/herald.js" integrity="sha256-AcoJNZAkXVxpi/5ZW/CXeUadY0z5rEH7h/3OAs5HnTg=" crossorigin="anonymous"></script><script>let key = "${data.hash}"; let savedKey = localStorage.getItem("banner--cta-url"); if(savedKey === key) { document.documentElement.classList.add("banner--hide"); }</script>`;
  }

  element(element) {
    element
      .append(this.styles, { html: true })
      .append(this.scripts, { html: true });
  }
}

class BodyHandler {
  constructor(data) {
    this.data = data;
    this.bannerTemplate = `<announcement-banner data-banner-key="${
      data.hash
    }" data-theme="${data.theme}"> ${
      data.link ? `<a href="${data.link}">` : ""
    } ${data.text} ${
      data.link ? `</a>` : ""
    } <button type="button" data-banner-close>Close</button> </announcement-banner>`;
  }

  element(element) {
    element
      .prepend(this.bannerTemplate, { html: true });
  }
}

export default async (request, context) => {
  const data = await getData();
  // const response = await context.next();
  // In real-world usage the below line wouldn't be
  // necessary as we would work with the current request.
  // see: https://edge-functions-examples.netlify.app/example/transform
  const response = await fetch("https://example.com");

  if (!data || !data?.text) {
    return response;
  }

  const transformedResponse = new HTMLRewriter()
    .on("head", new HeadHandler(data))
    .on("body", new BodyHandler(data))
    .transform(response);
  transformedResponse.headers.set("cache-control", "no-store, must-revalidate");
  return transformedResponse;
};
```

### A little bit about the banner itself

The focus of this demonstration was the ability to inject the banner into any page, rather than the actual banner itself, but if you've [played with the demo](https://global-banners-from-the-edge.philgw.workers.dev) you might notice a few things.

The banner appears immediately along with the rest of the page content and causes no layout shift. This is because the HTML for the banner arrives alongside the HTML of the rest of the page. If we used a React/Preact/Vue/Alpine component to load the JSON from the example endpoint and then render a banner we would see a layout shift as the banner content would appear after the rest of the page had been laid out.

The banner is dismissible, and if you reload the page a few times you won't see that banner again until you dismiss a different banner. This is because each banner is given a unique key, and [Zach Leatherman's 'herald of the dog' `<announcement-banner>` web component](https://www.zachleat.com/web/announcement-banner/) remembers the last banner to have been dismissed and won't show it again. Some JavaScript in the `<head>` of the page makes sure there is no flicker of the banner before it is hidden (JavaScript in the `<head>` runs prior to the browser rendering anything).

The banner is positioned at the top of the page. I think this is the safest option. It's injected right after the opening `<body>` tag. It'd be tempting to also try and add it at the start of `<main>` or right after `<header>` to have it appear below a site's header in a hero-ish spot, but think how that would work with the not-very-semantic markup generated by your jobs board site, or with a JavaScript-dependent SPA whose entire HTML-over-the-wire content is basically `<body><div id="root"></div><noscript>Lol, sorry!</noscript></body>`.

The banner has different themes. I think this is essential for a flexible system. You'll want a fun theme for good news, a red emergency theme for when something bad is going down, and a sad/sombre theme for moments of corporate reflection and sympathy. We manage the theme via a data attribute on the banner component and some CSS that we inject into the page. I could have worked harder on this aspect and used a more thorough way of preventing page styles from bleeding into the banner ([`all: unset`](https://elad2412.github.io/the-new-css-reset/) or [scoped CSS in a Web Component](https://web.dev/shadowdom-v1/#styling)), but that wasn't the focus of this blog post.

If you needed to make slight tweaks for different sites then that could be managed in a few ways. Content tweaks can be handled in the worker, and styling tweaks can be managed by adjusting the specificity of the banner's CSS (to allow some site-specific sites to apply), or by creating modified themes, for example `celebratory--dense` for a more compact celebratory banner to use on a webapp rather than a marketing site.

## In conclusion

I think edge workers are GREAT. We can release heavily cached or static websites, but add a layer of dynamism on top, all managed by our CDN provider. We can shape network requests as they pass through the CDN, and use this to add content to any HTML response, regardless of where that site is hosted or how it is maintained. Even a dead site on a server no one knows how to access could still have content added to it, as long as its domain name could be pointed at the CDN service. 

For a situation where multiple different sites need to all show content from a single source, this is the most performant and easiest to implement solution that I think I can think of, and definitely something I'd be happy to use in practice. 

## Share this post

{% embed https://twitter.com/philw_/status/1570680943031234564 %}

{% embed https://dev.to/philw_ %}