Automating OpenGraph Image Generation with Lume is surprisingly straight forward.

Science has shown that people are a million times more likely to click on a link if it has an image. Maybe a bazillion times. You’ve probably reached this article because you saw a tweet linking to it, and you thought to yourself: holy cow, that’s an incredible banner image! I must click it! Here’s how …

Automating OpenGraph Image Generation with Lume is surprisingly straight forward. Read More »

Science has shown that people are a million times more likely to click on a link if it has an image. Maybe a bazillion times. You’ve probably reached this article because you saw a tweet linking to it, and you thought to yourself: holy cow, that’s an incredible banner image! I must click it!

Here’s how I added them to mine: I hired a guy, and he made them for me!

Then we had a huge falling out (he didn’t like my favorite HTML color, hotpink) and I ended up having to do them myself. Damn!

Obviously, I didn’t want to make them manually, so it was time to figure out how to automate their creation.

Just a few weeks ago, Vercel announced Vercel OG Image Generation, a feature of their platform that allows you to generate these images on the fly.

But now I faced a new problem: all their cool new bits were designed for use within Next.js, their React-based web application framework, while this new blog is just a statically generated website built with Lume (which I was gushing over in an earlier post.)

At the heart of their offering is Satori, a new library that can render HTML and (a subset of) CSS to SVG. It was time to find out how I can use it from within Lume. This encompassed three things:

  • Finding out how to create a PNG file for every blog post on the site
  • Rendering the HTML and CSS to SVG via Satori
  • Converting that SVG to an actual PNG

Let’s go! 🚀

Creating a PNG for every blog post

Lume made this surprisingly easy. It has a concept of templates, special documents that can create not one, but multiple pages in the final build of the site. They are required to export a default generator function that yields a list of pages to be created.

For this site, this template started out simple, like this:

export default async function* ({ search }) {
  /* Look through all pages whose `type` attribute equals `post` */
  for (const page of search.pages("type=post")) {
    yield {
      url: `${page.dest.path}.png`;
      content: "insert actual PNG bits here",
    };
  }
}

Just this little snippet made sure that for a blog at /posts/hello-world/, there would also be a /posts/hello-world/index.png.

I also added the Metas plugin to the site and configured it to default to use a relative-linked image.png like this:

metas:
  image: ./index.png
  # ...

This basically makes it so every page will default to a local index.png file for its OpenGraph image, while still allowing me to override it where I want to.

Rendering the HTML and CSS to SVG via Satori

Now I needed to create some SVG for each post. Satori allows me to use JSX for this, which is a nice touch. It supports a subset of HTML and CSS, making heavy use of Flexbox for layout (as far as I understand, it uses Facebook’s Yoga layout engine.)

For this site, this ended up looking like this:

const svg = await satori(
  <div
    style={{
      display: "flex",
      height: "100%",
      width: "100%",
      padding: 60,

      flexDirection: "column",

      backgroundImage: "linear-gradient(to bottom, #222, #333)",
      color: "rgba(255, 255, 255, 0.8)",
      textShadow: "5px 5px 5px rgba(0, 0, 0, 0.5)",
    }}
  >
    <div style={{ fontSize: 60, fontWeight: 700 }}>hmans.co</div>
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        flexGrow: 1,
        justifyContent: "flex-end",
      }}
    >
      <div style={{ color: "hotpink", fontSize: 90, fontWeight: 700 }}>
        {page.data.title}
      </div>
      <div style={{ fontSize: 60, fontWeight: 700 }}>{page.data.subtitle}</div>
    </div>
  </div>,
  options
);

As you can see, it’s just JSX-ed HTML with inline styles.

The options object you can see at the end of that snippet configures output dimensions and available fonts. The latter were a little bit tricky because in order for Satori to even be able to render any text, you need to load the font files into memory and pass them to Satori.

I ended up downloading the Inter font from Google Fonts and loading them into memory like this:

const inter = await Deno.readFile("./src/fonts/Inter-Regular.ttf");
const interBold = await Deno.readFile("./src/fonts/Inter-Bold.ttf");

Now my Satori options looked like this:

const options: SatoriOptions = {
  width: 1200,
  height: 627,

  fonts: [
    {
      name: "Inter",
      data: inter,
      weight: 400,
      style: "normal",
    },
    {
      name: "Inter",
      data: interBold,
      weight: 700,
      style: "normal",
    },
  ],
};

Converting the SVG to a PNG

The last step was to convert the SVG to a PNG. For this, I used the Deno resvg-wasm package, a wrapper around the resvg SVG rendering library. This essentially boiled down to:

import { render } from "https://deno.land/x/[email protected]/mod.ts";

/* later */
await render(svgForPost(post));

Easy!

Putting it all together

That’s it, that’s the whole thing! I’ve put the full template source up on GitHub if you want to take a look and/or steal it. Some potential improvements for the future:

  • Caching! At the moment this will regenerate all images for all posts every time the site is built. At the moment, with only a handful of posts, this is fine, but I expect it will get slow and annoying as the site grows.
  • Randomization! I could generate a random seed from the blog post title an use that to maybe randomize the background gradients a little, or introduce some other graphical elements. This could be fun!
  • Extract into a plugin! Yeah, this is a very obvious candidate for a Lume plugin, but I want to get to know the framework a little better first.
Scroll to Top