Generating OpenGraph images with Netlify On-demand builders
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/ogtouch 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:
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/templatestouch 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
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
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,};});