When links are shared on social media, most platforms will use the og: meta tags like og:image, to generate a pretty preview. Especially when you're writing blogposts, creating these images by hand for every single post is very time consuming. I will show you how I dynamically generate these images on the fly for my own blog.

If you want to know how your pages look when shared, you can use the og-debugger tool from meta: https://developers.facebook.com/tools/debug

You can check out my final solution on https://github.com/kitsunekyo/mostviertel.og-image

I'm pretty lazy, so I dont want to manually run some image generation script. Actually I dont want to think about og images at all. All I want to do is add a meta tag in my blog posts once, and with some tinkering it magically shows up at the desired url.

The path in our meta tag will contain an api call with all of data we need to generate the image. We can additionally programmatically generate the path, so out blog page layout takes care of generating the path for us.

<meta
  property="og:image"
  content="https://ourservice/api?title=some+title&tags=redux&tags=react&createdAt=2022-09-18"
/>

This is a perfect usecase for a serverless function, since its a nicely encapsuled piece of logic that can run independently of my blog. For simplicity I'm going to host it on vercel. This allows me to use typescript without any build step and deploy it free of hassle (and cost).

prerequesites

make sure you're on node v14 as headless chromium does not work with node v16 on vercel

Lets setup our source code first

npm init -y # init the npm repository
npm i -D typescript @vercel/node # install typescript and vercels typings
npx tsc --init # init typescript
mkdir api
touch api/index.ts

setup the serverless function

The next step is pretty simple. We need an api endpoint that returns the desired contents as a html page. This allows us to use basic html and css to design our layout. For serverless functions all you need is a handler function.

// api/index.ts
import type { VercelRequest, VercelResponse } from "@vercel/node";
 
const css = `...your css`;
 
export default async function handler(req: VercelRequest, res: VercelResponse) {
  try {
    const { title, tags, createdAt } = req.query;
 
    // define your markup as you want, I dont want to use any templating package so I just use plain template literals
    const html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>OG Image</title>
        <style>${css}</style>
      </head>
      <body>
        <div class="container">
          <h1>${title}</h1>
        </div>
      </body>
      </html>`;
 
    res.statusCode = 200;
    res.setHeader("Content-Type", "text/html");
    return res.end(html);
  } catch (e) {
    res.statusCode = 500;
    res.setHeader("Content-Type", "text/html");
    res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
    console.error(e);
  }
}

To debug this locally with ease you can use the @vercel/cli package. Install it with npm i -g @vercel/cli You can then run vercel dev which will walk you through the setup and allow you to run your serverless function on localhost.

When you browse to http://localhost:3000/api?title=hello+world you should now get your html page returned from the server.

generating a screenshot

For og:images we need image files, so what we can do is generate a screenshot from our created page and serve this image instead of the html. There's two well known packages for this: puppeteer and playwright. Both of these packages allow you to open a headless browser, and do some stuff like taking screenshots with it. I initially used puppeteer but it was so slow that I ran into timeout issues when deploying on vercel. So we're gonna use playwright instead.

Usually you would install playwright directly, which downloads executables for common browsers for you. Since serverless functions are space limited, we don't want that. Thankfully vercel / and aws (which is what vercel use) have a package to interact with a preinstalled chromium instance.

Install it with npm i chrome-aws-lambda. Instead of installing playwright run npm i playwright-core, which is just a small package to interact with chromium, without the executables themselves.

In our local development we dont have chrome-aws-lambda available though, so we need some conditional to use our pre-installed chrome from our machine.

Create a api/chromium.ts file and add the following contents:

import chromium from "chrome-aws-lambda";
import playwright from "playwright-core";
 
let _page: playwright.Page | null;
 
// get the local executable path for our installed chrome
const chromiumExecutablePath =
  process.platform === "win32"
    ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
    : process.platform === "linux"
      ? "/usr/bin/google-chrome"
      : "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
 
async function getOptions(isDev: boolean) {
  let options: {
    args: string[];
    executablePath: string;
    headless: boolean;
  };
  if (isDev) {
    options = {
      args: [],
      executablePath: chromiumExecutablePath,
      headless: true,
    };
  } else {
    options = {
      args: chromium.args,
      executablePath: await chromium.executablePath,
      headless: chromium.headless,
    };
  }
  return options;
}
 
async function getPage(isDev: boolean) {
  if (_page) {
    return _page;
  }
  const options = await getOptions(isDev);
  const browser = await playwright.chromium.launch(options);
  _page = await browser.newPage();
  return _page;
}
 
export async function getScreenshot(html: string, isDev = false) {
  const page = await getPage(isDev);
  await page.setViewportSize({ width: 1200, height: 630 });
  await page.setContent(html);
  const file = await page.screenshot({ type: "png" });
  return file;
}

getPage() is our function where a new browser window will be opened.

To save some computation we dont always generate the page from scratch for every api call. By adding the global _page variable in the module, our serverless function instance will only create it once and reuse it on consecutive calls.

getScreenshot() is what we will call on our api endpoint. It generates a page, sets the viewport size to our desired aspect ratio (1200px x 630px is the recommended size for og images) and we will inject our html into this new page. This means that the browser will render our html, like it did when we browsed to it. Only now we can call page.screenshot. This will give us the screenshot as blob, which we can send as a response in our endpoint.

Lets change our handler so it uses this new function instead of returning the html

//
const screenshot = await getScreenshot(html, isDev);
res.statusCode = 200;
res.setHeader("Content-Type", "image/png"); // note that we send a different mime-type now
return res.end(screenshot);

Browse to the url again. You should now get an image instead html as response.

As a last step we can add cache-control headers. We dont need to generate new screenshots for calls we have already made. By adding the headers, our serverless function can instantly respond with the previously generated image, should multiple calls to the same url happen.

// add this after the Content-Type header
// this caches the image for 365 days
res.setHeader(
  "Cache-Control",
  `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`,
);

deployment

You can use the cli command vercel directly to deploy to vercel. Or you can create a new project in the web ui of vercel, connect it with your github repo so you get automatic deployments on push to main.

Important here is that you set node to version 14 in the settings. chrome-aws-lambda does not work with node version 16 yet.

integration to my blog layout

For my blog, i have the option to use custom og images when I define my blogposts in markdown. Should no custom image be set, I want to default to using the og image service we just created. So in my blog layout I have this function

function getOGImagePath(post) {
  if (post.image) return post.image;
 
  const ogImageUrl = new URL("https://og-image.mostviertel.tech/api");
  ogImageUrl.searchParams.set("title", post.title);
  ogImageUrl.searchParams.set("createdAt", post.date);
  post.tags.forEach((tag) => {
    ogImageUrl.searchParams.append("tags", tag);
  });
 
  return ogImageUrl;
}

I then use it in my layout like this

export default function BlogPost({ post }) {
  return (
    <Head>
      <meta property="og:image" content={getOGImagePath(post)} />
    </Head>
    // rest ...
  );
}

So whenever someone shares my blog post, it will try to resolve the ogImageUrl for the meta tag. This will generate the image and cache it for 365 days. Now my links look pretty when shared. ✌