Generating OpenGraph images with Netlify On-demand builders

01 May 2021 in Tech

A little while back I built an OpenGraph image generator on AWS Lambda for my Action Spotlight and TIL categories. I toyed with the idea of making them generate the images on demand, but decided that I didn’t want the potential traffic to a Lamba function and opted to pre-generate and store in S3 instead.

Then this week, I saw that Netlify On-demand builders are available in beta which solve all my problems. I don’t need to pre-generate any images, but they’re also only generated once per deploy and distributed via the Netlify CDN.

My requirements for the project were:

  • Each image should only be generated once (once per deploy is fine)
  • The function should support multiple templates for my OpenGraph image
  • It should be possible to pass in any data required

Overall, it was a success 🎉

If you’re looking for the code without an explanation, it’s available on GitHub

Structuring your function

Netlify functions live in the netlify/functions directory in your project. You can create files directly in there, or create a folder named after your function. I chose to create a folder as I have some additional files to package up with my JS file:

bash
mkdir -p netlify/functions/og
touch netlify/functions/og/og.js

I used my image generation via Netlify script as a starting point as I knew it worked to generate an image.

The first requirement is that each image should only be generated once. To enable this you use the builder decorator from @netlify/functions:

js
const { builder } = require("@netlify/functions");
exports.handler = builder(async function (event, context) {
// Code
});

Make sure to install it into your project with npm install --save-dev @netlify/functions.

At this point the image will be generated on the first execution and then cached for any future requests.

The image so far looks like this:

Initial image

Template support

Next, I wanted to add template support so that I didn’t have to hard-code the entire template in my function.

To do this I created a folder named templates and a template for my #til category:

bash
mkdir -p netlify/functions/og/templates
touch netlify/functions/og/templates/til.html

To load this file I imported the fs module and read the file contents into a variable, then passed that string to page.setContent():

js
const fs = require("fs").promises;
exports.handler = builder(async function (event, context) {
// ...snip...
const template = "til";
let htmlPage = (
await fs.readFile(require.resolve(`./templates/${template}.html`))
).toString();
const page = await browser.newPage();
await page.setContent(htmlPage);

The contents of til.html can be anything you like. For testing purposes, I set it to the following:

js
<style>
h1 { color: red }
</style>
<body>
<h1>{title}</h1>
<p>{description}</p>
</body>

The title and description placeholders don’t do anything yet, but we’ll use those later. For now, it’ll just render the page exactly as it is.

At this point you can deploy the function and it will render the HTML template provided as-is

Image using a tempalte

Passing in data

Usually we’d use GET parameters to pass in the title and description to use in the image, but on-demand builders _ don’t provide access to HTTP headers or query parameters from incoming requests_.

This means that we have to make the parameters a part of the URI. I thought about the best way to achieve this, and decided on a URI structure that looks like the following:

js
/.netlify/functions/og/template=til/title=Demo Title/description=This is an example description that is a little longer to see how it works out

Everything after /og will be ignored, but it allows us to map over each segment in the URI and treat it as a key/value pair.

To build this mapping, add the following code to your function:

js
exports.handler = builder(async function (event, context) {
const { template, ...params } = Object.fromEntries(
event.path
.split("/")
.filter((p) => p.includes("="))
.map(decodeURIComponent)
.map((s) => s.split("=", 2))
);
// snip...
});

You’ll notice that we also read template from the path, so you can delete const template = "til"; from your function too.

Finally, we need to swap out the placeholders in the template for the values in params:

javascript
let htmlPage = (
await fs.readFile(require.resolve(`./templates/${template}.html`))
).toString();
for (const k in params) {
htmlPage = htmlPage.replace(`{${k}}`, params[k]);
}

After committing and deploying that, I can visit /.netlify/functions/og/template=til/title=Demo Title/description=This is an example description that is a little longer to see how it works out and see that it renders the image as expected

Rendered image using passed in data

Conclusion

Under 45 lines of code and we have a Netlify function that generates OpenGraph images on the fly and caches them on a CDN for future requests. On-demand builders seem pretty great, and I’m looking forward to working with them more in the future.

If you're looking for the full code, it's available below or on GitHub

javascript
const chromium = require("chrome-aws-lambda");
const puppeteer = require("puppeteer-core");
const { builder } = require("@netlify/functions");
const fs = require("fs").promises;
exports.handler = builder(async function (event, context) {
const { template, ...params } = Object.fromEntries(
event.path
.split("/")
.filter((p) => p.includes("="))
.map(decodeURIComponent)
.map((s) => s.split("=", 2))
);
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: { height: 630, width: 1200 },
executablePath: await chromium.executablePath,
headless: chromium.headless,
});
let htmlPage = (
await fs.readFile(require.resolve(`./templates/${template}.html`))
).toString();
for (const k in params) {
htmlPage = htmlPage.replace(`{${k}}`, params[k]);
}
const page = await browser.newPage();
await page.setContent(htmlPage);
await page.waitForTimeout(1000);
const buffer = await page.screenshot();
return {
statusCode: 200,
headers: {
"Content-Type": "image/png",
},
body: buffer.toString("base64"),
isBase64Encoded: true,
};
});