Guides & Tutorials

Dynamically Generating Thousands of OG Images for a Viral Twitter Campaign

If you spend any amount of time on Twitter, you’ll notice that most links have some sort of image attached to them. For example, here’s one of Netlify’s tweets:

Netlify branded OG image

Look at that beautiful, on-brand image that stands out in everyone’s Twitter feed! The image is showing up thanks to the Open Graph protocol, which allows webpages to announce what image should be displayed alongside the URL. Twitter, Facebook, Slack, and almost every other platform respects the Open Graph protocol as a near-universal standard.

Setting up an Open Graph image (OG image) is super easy, all you need to do is add a meta tag that points to the correct image. Here’s the tag from the tweet above:

<meta content="https://jamstack.org/conf/images/teaser-og.png" property="og:image"> 

This is easy enough to do when you’re just creating a single image per page. You upload the image and then reference it from your HTML, no problem.

There are times though when you want to attach many different OG images to the same link. That way every social share shows a unique image, but all links still point back to the single page. It may sound odd, but in the right circumstances it can create a really compelling, shareable experience.

Recently we (Tuple) ran a campaign to send OSS developers on vacation. As a part of this process we solicited nominations and votes from the community to help us discover folks we weren’t aware of.

After the visitor voted, they were presented with an opportunity to share their votes on Twitter, along with a preview of the custom image created just for them. Here’s what they might see, based on their votes:

Tuple preview

Many people took us up on this, which created a pretty powerful effect on Twitter:

Throughout the course of a few days, we generated and served thousands of custom open graph images using Netlify Functions.

Let’s take a look at how we did it, but more importantly, how you can do it too!

Creating a Function and a Template

The basic idea is to use Puppeteer to launch Google Chrome, load some HTML, take a screenshot of the page, and return it as an image. We’ll be doing all of that inside a Netlify Function so that we don’t have to worry about hosting or scaling it ourselves.

The Netlify Function docs are quite helpful, so make sure you check them out too.

We’ll start by creating an generator.js file that will be responsible for generating the images. We’ll place that file in the /netlify/functions/ directory to adhere to the platform convention. We’ll also put our image template file in the functions directory, so that it is available for the function to reference.

netlify/
    functions/
        generator.js
        assets/
            image.html

Netlify makes this function available to us at the special URL /.netlify/functions/generator. While developing locally, running netlify dev will let you hit this in your browser at http://localhost:8888/.netlify/functions/generator.

Now that we have our function in place, let’s move on to generating the images.

Generating Static Images with Puppeteer and Chrome

We’ll start by installing two JavaScript packages, one contains the Chromium binary and one is Puppeteer.

npm install chrome-aws-lambda@~7.0.0 puppeteer-core@~7.0.0

Note: We had to use an older version of the Chrome and Puppeteer libraries to stay under the 50mb function limit. You can read more about that here.

With these packages in place, let’s take a look at the first iteration of our function code. In the function we create a handler to handle inbound requests, launch Chrome, set the page’s HTML, and finally take a screenshot.

const chromium = require('chrome-aws-lambda')
const puppeteer = require('puppeteer-core')
const fs = require('fs')

exports.handler = async function (event, context) {
    // Use local Chrome when testing.
    let localChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    let executable = fs.existsSync(localChrome) ? localChrome : chromium.executablePath

    // Launch Chrome.
    const browser = await puppeteer.launch({
        args: chromium.args,
        executablePath: await executable,
        headless: true,
        // The optimum size for OG images.
        defaultViewport: {height: 630, width: 1200},
    })

    let page = await browser.newPage()

    // Read the template HTML off of disk.
    let content = fs.readFileSync(__dirname + '/assets/image.html').toString()

    await page.setContent(content, {
        waitUntil: 'domcontentloaded',
    })

    return {
        statusCode: 200,
        headers: {
            'Content-Type': 'image/png',
            'Cache-Control': 's-maxage=86400',
        },
        body: (await page.screenshot()).toString('base64'),
        isBase64Encoded: true,
    }
}

Let’s break that down a little bit.

The first thing we do is figure out where the Chrome browser is located. We add a check to see if there’s a local copy of Chrome available, which we’ll use if there is. This makes local dev much easier. (The path to your Chrome may be different, so you may need to modify that part.) If there’s not, then we use the chromium.executablePath from the chrome-aws-lambda package.

// Use local Chrome when testing.
let localChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let executable = fs.existsSync(localChrome) ? localChrome : chromium.executablePath

Then we launch Chrome using Puppeteer. We leverage the chromium.args to set some arguments which are recommended for serverless environments. Most importantly, we set the viewport to 630x1200 pixels, which is the standard OG image size.

// Launch Chrome.
const browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await executable,
    headless: true,
    // The optimum size for OG images.
    defaultViewport: {height: 630, width: 1200},
})

With our browser ready, we open a new page and set the content based on an HTML file we shipped with our function. This file will serve as the base template for all our images.

let page = await browser.newPage()

// Read the template HTML off of disk.
let content = fs.readFileSync(__dirname + '/assets/image.html').toString()

await page.setContent(content, {
    waitUntil: 'domcontentloaded',
})

All that’s left is for us to take a screenshot and return it to the browser. Note that we need to inform Netlify (Lambda, really) that the response is already base64 encoded by setting the isBase64Encoded parameter to true. This prevents double encoding.

return {
    statusCode: 200,
    headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 's-maxage=86400',
    },
    body: (await page.screenshot()).toString('base64'),
    isBase64Encoded: true,
}

Since we control the generation of the image now, let’s move on to making the images dynamic instead of just generating the same image over and over, because that’s hardly interesting at all!

Generating Dynamic Images

To make our image template dynamic we used a dead-simple double mustache templating syntax. Anywhere where we have a dynamic value, we write {{ [key] }} in the HTML, and replace it in the JavaScript.

Here’s what it looks like with the title placeholder in the template.

<html lang="en">
<head>
    <link rel="stylesheet" href="https://tuple.app/css/site.css" />
</head>
<body class="flex flex-col min-h-screen text-3xl justify-center text-center">
<!-- The following line gets replaced by generator.js -->
{% raw %}{{ title }}{% endraw %}
</body>
</html>

Back in the generator.js function, we can create a function to swap out all of our placeholders.

function populateTemplate(content, data) {
    // Replace all instances of e.g. `{% raw %}{{ title }}{% endraw %}` with the title.
    for (const [key, value] of Object.entries(data)) {
        content = content.replace(new RegExp(`{% raw %}{{ ${key} }}{% endraw %}`, 'g'), value)
    }

    return content;
}

And use it to fill in our template:

// Read the template HTML off of disk.
let content = fs.readFileSync(__dirname + '/assets/image.html').toString()

content = populateTemplate(content, {
    // Get the title out of the querystring.
    title: event.queryStringParameters?.title
})

await page.setContent(content, {
    waitUntil: 'domcontentloaded',
})

To see it in action, you can load the image in your browser and pass a title param: http://localhost:8888/.netlify/functions/generator?title=Hello%20World.

You should be greeted with this beautiful image:

Hello World Example OG image

I’ll admit it’s not much, but it’s ours! So far in our example, we’ve been reading the title param out of the querystring and placing it directly into the template. We’ve seen that it does work, but unfortunately the amount of data you can transfer this way is rather limited.

For our vacation giveaway, the images we were generating contained a lot of data. We were including up to

  • 3 profile images
  • 3 first and last names
  • 3 usernames

Netlify vacation example OG image

Putting all of that data in a querystring is simply not feasible. And if it was, can you imagine how ugly the shareable URLs would have been?

Instead, we put the user’s votes in a database (Airtable, in our case) with a key and put only that key in the URL. This made for much shorter URLs with much richer data. This short key allows us to look up all the data needed: https://tuple.app/.netlify/functions/generator?key=Yi0ceiNJBP

let key = event.queryStringParameters?.key
let content = fs.readFileSync(__dirname + '/assets/image.html').toString()

// Populate the template based on the user's vote data from the database.
content = populateTemplate(content, await getUserData(key))

await page.setContent(content, {
    waitUntil: 'domcontentloaded',
})

To reference our new dynamic image generator, we can update our meta tag to point to our function:

<meta content="https://tuple.app/.netlify/functions/generator" property="og:image">

We’ve gone pretty far down the rabbit hole of dynamic image generation, but there’s still one thing missing.

You’ll notice that the ?key=Yi0ceiNJBP querystring is missing from the meta tag above, meaning this image will be the generic template! How can we pass through each user’s dynamic key? If this were a server-rendered page it would be no problem, but our site is a Jekyll built static site so it’s just plain ol’ HTML.

Fortunately, Netlify’s experimental Edge Functions provide the perfect solution!

HTML Rewriting at the Edge

Edge functions allow you to modify response content, per request, at the network’s edge, making for extremely fast response times. We’re going to add an edge function to modify the OG image tag to pass along all querystring parameters from the original request.

To create an edge function, add a new JavaScript file in the netlify/edge-functions directory. We’ll call ours og-param-proxy.js:

netlify/
    edge-functions/
        og-param-proxy.js

In our netlify.toml file we need to add a section to activate the function, based on the path of incoming requests. We only wanted to apply this edge function to the URL https://tuple.app/sends-you-on-vacation/ so we updated our configuration with the following:

[[edge_functions]]
path = "/sends-you-on-vacation/"
function = "og-param-proxy"

Every request that comes into that path will now be routed through our edge function.

The edge function itself is quite simple. All it does is take the URL params from the incoming request and append it to OG image URL.

export default async (request, context) => {
    const url = new URL(request.url)
    
    // Get the page content.
    const response = await context.next()
    const page = await response.text()
    
    // Look for the OG image generator path.
    const search = '/.netlify/functions/generator'
    // Replace it with the path plus the querystring.
    const replace = `/.netlify/functions/generator?${url.searchParams.toString()}`
    
    return new Response(page.replaceAll(search, replace), response);
}

That’s it! Now an inbound request to https://tuple.app/sends-you-on-vacation/?key=Yi0ceiNJBP will return a meta tag with that same key appended:

<meta content="https://tuple.app/.netlify/functions/generator?key=Yi0ceiNJBP" property="og:image">

Every time a user votes, you can give them a unique URL to share on Twitter and the key will make it all the way through to your Netlify Function!

That’s exactly how we pulled this feature off. The “Tweet it” button contained a URL with a key param appended to the end of it.

image

When the Twitter servers loaded that URL to see if there was an OG image meta tag, the edge function injected that key into the HTML. The Twitter servers then followed that URL and hit the edge function, key in tact. In the function, we used the key to look up the user’s vote in the database and construct the image based on their data and return it to the Twitter servers. Twitter then used that custom image to construct its rich embed to show in the timeline!

And because Twitter respects querystrings when it caches images, when the next user shares their unique URL, the process starts all over again, and a new custom image is generated!

Final Thoughts

This method can be as simple or as complicated as you need it to be! You could argue we’ve made it about as complicated as it could be, but you can drastically simplify.

For example, if you have four different OG images you’d like to use based on different sections of your article, your four different share URLs could be:

  • tuple.app/sends-you-on-vacation/?image=a
  • tuple.app/sends-you-on-vacation/?image=b
  • tuple.app/sends-you-on-vacation/?image=c
  • tuple.app/sends-you-on-vacation/?image=d

And your edge function could simply swap out hardcoded images:

export default async (request, context) => {
    const url = new URL(request.url)
    
    // Get the page content.
    const response = await context.next()
    const page = await response.text()
    
    // Look for the OG image path.
    const search = '/images/og/vacation.png'
    
    const replace = url.searchParams.has('image') 
        ? `/images/og/vacation/${url.searchParams.get('image')}.png`
        : search;
            
    return new Response(page.replaceAll(search, replace), response);
}

The possibilities are endless!

By leveraging the power of the Netlify platform, we were able to create a compelling experience for our users and an effective campaign for Tuple. Because we didn’t have to worry about hosting or scaling, we could spend extra time and attention adding these touches that we otherwise wouldn’t have been able to justify.

Keep reading

Recent posts

How do the best dev and marketing teams work together?