Dynamic social media images with Puppeteer and Netlify on-demand builders
13 min read Published by Lee Reamsnyder PermalinkWhen you share a link to this site on social media, the preview image is a literal preview of the URL, generated on-demand. It’s super cool, and I’ll walk you through how it works.
As an example, here’s an on-demand preview image of my home page:
Having an image show up when someone shares your site on social media is a big deal. As Chris Coyier of CSS Tricks says:
they turn a regular post with a little ol’ link in it into a post with a big honkin’ attention grabbin’ image on it, with a massive clickable area. Of any image on a website, the social media image might be the #1 most viewed, most remembered, most network-requested image on the site.
While I suppose the ideal would be for every page on my site to have a bespoke image with irresistible clickbait content (“Doctors hate this one weird trick to make social media images”), I have nearly 600 pages here and I don’t have that kind of time.
Instead, I figured my social media images could be an actual preview of the link you’re about to tap, the sort of corner-cutting that looks like clever thinking that truly identifies me as a programmer.
At first I tried generating a bunch of images as part of my build process, but scraping and screenshotting 600 pages took forever, so I dropped that.
What I really wanted was something I could invoke on-demand. Thankfully, I’m on Netlify, and they have the tooling to make that possible!
Here’s what happens broadly:
- Every page on my site has a
<meta />
tag withproperty="og:image"
with a URL to an image… - …which Netlify redirects…
- …to a Netlify on-demand builder…
- …which fires up Puppeteer to navigate to my a specific page on my site…
- …and takes a screenshot and sends that back as the response.
I’ll go through those in more detail, including how I make sure they function all correctly when developing locally, which is always the frustrating part.
The meta tag
You need a handful of meta tags that I’m not going to go over in detail, but for the preview image the important one is this:
<meta
property="og:image"
content="https://www.leereamsnyder.com/og-image-generator/blog/a-bit-more/og-image.jpg"
/>
The content
must be a complete URL (not a relative path).
Here’s the breakdown of the path:
/og-image-generator
: a prefix to use a hook for the redirect. More on the redirect in a moment./blog/a-bit-more
: the next segment or segments is the path of the page we’re looking at./og-image.jpg
: finally, the final segment is a filename. This isn’t strictly necessary but it it makes the URL look more like it’ll be serving up an image to my eyes.
I use SvelteKit to build this site, so here’s the code I use to get the current page path and generate the meta tag:
<script>
import { page } from '$app/stores'
let socialImagePath
let socialImageUrl
$: {
socialImagePath = `/og-image-generator${
$page.url.pathname === '/' ? '' : $page.url.pathname
}/og-image.jpg`
socialImageUrl = `https://www.leereamsnyder.com${socialImagePath}`
}
</script>
<svelte:head>
<meta property="og:image" content="{socialImageUrl}" />
</svelte:head>
In my opinion, there’s not much reason for this markup to change between local development or branch deploys or production because it’ll only get truly used in production, so I have the URL for my public site hard-coded here. Nothing will be scraping your site running locally, and if I have to test it locally I can change the host.
The Netlify redirect
This is a one-line addition to my _redirects
file:
/og-image-generator/* /.netlify/functions/og-image-generator 200!
So now on Netlify, anything accessing /og-image-generator/
-whatever is going to hit my Netlify on-demand builder.
Setting up the Netlify on-demand builder
First, if this is your first Netlify function, you need to add a netlify/functions
folder to your workspace. This is from the root of your workspace, not in your output/build folder. (You can specify a different location in Netlify’s UI, but netlify/functions
works without additional configuration.)
Next up, we’ll add our function file at netlify/functions/og-image-generator.js
.
Once netlify builds and deploys everything (or you run netlify dev
locally), that’ll be available at ${your URL}/.netlify/functions/og-image-generator
.
Diversion: making things work with ES Module projects
If you’re not using ES Modules in your codebase, you can skip this bit.
If your project is using Node ES Modules (SvelteKit projects do; there’s "type": "module"
in the root package.json), you need to add a tiny package.json
file in that netlify
folder:
// netlify/package.json
{
"type": "commonjs"
}
You need this because Netlify’s tooling doesn’t work with the new-fangled ES Modules (import
/export
); they use the original Node CommonJS module format (require()
/module.exports
).
In Node world, a file can’t use both module formats. In an ES Modules project, if you use require()
and friends in a .js
file, Node will error out as it assumes .js
files will be using ES Module syntax. You can use CommonJS module syntax in files with a .cjs
extension, but Netlify requires function files to be .js
files and doesn’t know what to do with .cjs
files at all, it seems, because life is an unceasing struggle.
Our little netlify/package.json
file is telling Node, “Hey, everything in this folder on down is gonna use CommonJS and it’s fine. Kindly don’t freak out about it.” Now you’ll be able to run functions locally with netlify dev
.
Using puppeteer in Netlify functions or on-demand builders
All right, let’s add some dependencies:
npm install --save-dev @netlify/functions puppeteer
npm install chrome-aws-lambda puppeteer-core
You might be asking why we’re installing two versions of puppeteer but trust me. I know things.
Next up, here’s my entire function file at netlify/functions/og-image-generator.js
(we’ll go through it bit-by-bit below):
// netlify/functions/og-image-generator.js
const { builder } = require('@netlify/functions')
const chromium = require('chrome-aws-lambda')
const captureWidth = 1200
const captureHeight = 630
const clipY = 60
async function handler(event, context) {
let path = event.path.replace('/og-image-generator', '').replace('/og-image.jpg', '')
const url = `${process.env.URL}${path}`
const browser = await chromium.puppeteer.launch({
executablePath: process.env.URL.includes('localhost') ? null : await chromium.executablePath,
args: chromium.args,
defaultViewport: {
width: captureWidth,
height: captureHeight + clipY,
},
headless: chromium.headless,
})
const page = await browser.newPage()
await page.goto(url)
const screenshot = await page.screenshot({
type: 'jpeg',
// netlify functions can only return strings, so base64 it is
encoding: 'base64',
quality: 70,
clip: {
x: 0,
y: clipY,
width: captureWidth,
height: captureHeight,
},
})
await browser.close()
return {
statusCode: 200,
headers: {
'Content-Type': 'image/jpg',
},
body: screenshot,
isBase64Encoded: true,
}
}
exports.handler = builder(handler)
Let’s break that down.
const { builder } = require('@netlify/functions')
async function handler(event, context) {
/* … */
}
exports.handler = builder(handler)
That’s the skeleton of an on-demand builder, which we’re using instead of a regular Netlify function for a few reasons:
- We don’t need to handle anything other than HTTP
GET
requests. - Netlify will cache the response for each path without us having to do anything.
- Netlify will also purge that cache when you deploy without us having to do anything.
- If you rewrite a URL to a function, Netlify will give you the original URL as the
event.path
, which is something I’m gonna want real soon.
Next up:
const chromium = require('chrome-aws-lambda')
(Huge props to Ire Aderinokun for this bit.)
So, regular puppeteer
is too large to run on Netlify functions, which have to be under 50mb total once they’re bundled up. The chrome-aws-lamdba
is a lightweight version of Chrome that along with puppeteer-core
can eek under that limit. More on that soon.
const captureWidth = 1200
const captureHeight = 630
const clipY = 60
These are some constants I pulled out that we’ll use later. Right now something in the ballpark of 1200x630 is ideal for Facebook and Twitter image previews. The clipY
lets me define how much I scroll down the page before taking the screen capture.
Let’s dive into our handler
function:
async function handler(event, context) {
let path = event.path.replace('/og-image-generator', '').replace('/og-image.jpg', '')
const url = `${process.env.URL}${path}`
/* … */
}
Because I’m using Netlify’s rewrites, when you request something like /og-image-generator/blog/og-image.jpg
Netlify sends that to /.netlify/functions/og-image-generator
but puts the original request path on event.path
in your handler function. If I replace the first and last segments with empty strings, what’s left is the path of the page that I want to take a picture of, like /blog
.
Netlify functions can use the URL
build variable (the public, production URL for your site), so I’m using that here. When you use netlify dev
to test locally, that’ll be the URL of your local server (typically http://localhost:8888
), so you can test this locally without hitting your live site. When I put those together, I have a full URL to give to puppeteer.
So let’s spin that up:
const browser = await chromium.puppeteer.launch({
executablePath: process.env.URL.includes('localhost') ? null : await chromium.executablePath,
args: chromium.args,
defaultViewport: {
width: 1200,
height: captureHeight + clipY,
},
headless: chromium.headless,
})
Because we have to deal with some strict size limits, this looks a little different than a typical puppeteer.launch()
.
chromium.puppeteer
is a helper that will look for a local puppeteer
library, which you’ll probably have developing locally. However, when deployed on AWS (Netlify functions are a handy wrapper around AWS Lambda functions), chrome-aws-lamda
lists puppeteer-core
as a peer dependency (and we installed it as a production dependency) and will use puppeteer-core
instead. You can read their docs but they’re a bit sparse; I thought Ire Aderinokun’s explanation was much clearer.
executablePath: process.env.URL.includes('localhost') ? null : await chromium.executablePath
This was the secret to working locally for me. With netlify dev
, chromium
will wrongly detect that you’re running on AWS (the AWS_LAMBDA_FUNCTION_NAME
environment variable is set), so chromium.executablePath
will be to the special linux AWS Chrome, which is what you want on AWS, but it won’t work locally.
So I check the URL
environment variable for localhost
and use null
for the executablePath
if we are running locally with netlify dev
. In that case, you’ll use your local Chrome/puppeteer
and be in business for testing locally.
defaultViewport: {
width: captureWidth,
height: captureHeight + clipY
},
This uses those size constants from earlier to set the browser size large enough for my screen capture.
The rest of the launch
arguments set some useful defaults for running on AWS that don’t seem to make much difference for my purposes when running locally.
const page = await browser.newPage()
await page.goto(url)
Now that we have a browser
that’ll work locally and on Netlify, we fire up a new page and navigate to the url
that we figured earlier.
const screenshot = await page.screenshot({
type: 'jpeg',
// netlify functions can only return strings, so base64 it is
encoding: 'base64',
quality: 70,
clip: {
x: 0,
y: clipY,
width: captureWidth,
height: captureHeight,
},
})
Screenshot time! Netlify functions have to return strings instead of binary data, but base64
encoding lets us get around that and we can do that right here.
The clip
lets me chop off the top 60-odd pixels, effectively like I scrolled down the page a bit.
await browser.close()
return {
statusCode: 200,
headers: {
'Content-Type': 'image/jpg',
},
body: screenshot,
isBase64Encoded: true,
}
Now that we have the screenshot
data as a big data string, we return that (note isBase64Encoded: true
) and, boom, we’ve served up an image.
You can give one a shot here: https://www.leereamsnyder.com/og-image-generator/blog/the-medium-place/og-image.jpg, which comes from this post.
Woo-hoo!
Here’s how that looks in Facebook’s debugger:
And Twitter’s validator:
Caveats
So this is pretty new to me, but there are a couple of gotchas I’ve seen already:
- The first time you generate an image or if you’re hitting a CDN server that doesn’t have a cached response, it can take a few seconds. Facebook seems willing to wait a while for an image, but Twitter gives up pretty quickly. If one is super important to you (like you yourself are about to share it), you can hit Twitter’s card validator until it works, then it’ll be cached in their system for about a week.
- I have outrageously large text for my titles and they can go long, so fitting them into the screenshot is often a losing battle (from here). I might have to explore some alternative template I could use for these, or content myself knowing the full title of the page is right there in the card anyway.
- My site styling changes based on your current system light-mode/dark-mode preference, but the preview images are all using the light mode styling. This doesn’t really bother me.
- My on-demand builder is using my Netlify URL (the live site), not a deploy-specific URL, so if you play with this in a deploy preview it’ll be hitting the live site, not the deploy preview. Netlify functions have a pretty limited set of environment variables available to them, so you might have to use a plugin like
netlify-plugin-inline-functions-env
if you want to play with this in a deploy preview
All of those are overshadowed by the overwhelming neatness of the whole process working.
If your mind gears have been turning on something like this for a while like mine were, I hope this helped.