Create Dynamic Open Graph Images from HTML and CSS

It's kind of a bummer when linking to an article you wrote and there's no fancy unfurl on sites like Mastodon, LinkedIn, Discord, etc.

No problem, we just need to add a set of <meta propert="og:*"> tags. Including og:image gives us a nice embed on many social sites/apps.

But we've got to generate images, preferably unique, for each piece of content on our site. I barely muster the motivation to write, I really don't want to craft images for each post.

So I do it automatically! Right now it's just an image of text, but it's already ten times better than a boring text link.

undefined

I deploy tbeseda.com to AWS with Architect. It's made up of a series of Lambdas (this portion of the site is powered by an Enhance Lambda); most are HTTP handlers, and all have a connection to DynamoDB out of the box.

I've created a new HTTP endpoint that takes an article slug and returns a 1200x630px image containing the post title, description, and canonical URL. Defined in my app.arc, the route looks like

@http
get /og-img/:slug

This Node.js function uses Satori to convert "HTML"* and CSS to SVG and then resvg-js (via WASM) to convert the SVG to PNG. This is essentially how Vercel's OG Image Generator service works under the hood.

* "HTML" is in quotes because it actually converts JSX + CSS to SVG

Here's the Lambda's handler function. I've simplified the function by removing the actual JSX/React.createElement code (`createMarkup`) and replaced the database query with some pseudo code:

import { Buffer } from 'node:buffer'
import fs from 'node:fs'

import { Resvg, initWasm } from '@resvg/resvg-wasm'
import satori from 'satori'

import { createMarkup } from './article-og-img.js'
import db from './my-db-connection.js'

// load the .wasm outside the handler
await initWasm(fs.readFileSync('./node_modules/@resvg/resvg-wasm/index_bg.wasm'))

export async function handler({ pathParameters }) {
  const { slug } = pathParameters

  const article = await db.getArticleBySlug(slug)

  if (!article) return { statusCode: 404 }

  const svg = await satori(
    createMarkup(article), // returns compiled JSX
    {
      fonts: [
        { // you need to provide at least one font
          name: 'Roboto',
          weight: 400,
          data: fs.readFileSync(
            new URL('./Roboto-Regular.ttf', import.meta.url).pathname,
          ),
          style: 'normal',
        },
      ],
      height: 630, // these dimensions are standard og:image
      width: 1200,
    },
  )

  const resvg = new Resvg(svg, {
    fitTo: {
      mode: 'width',
      value: 1200,
    },
  })
  const pngData = await resvg.render()
  const pngBuffer = pngData.asPng()

  return {
    statusCode: 200,
    headers: {
      'content-type': 'image/png',
      'cache-control': 'public, max-age=86400', // 1 day cache
    },
    body: Buffer.from(pngBuffer),
  }
}

Calling this endpoint with an article slug like: https://tbeseda.com/og-img/automating-my-robots-txt-to-block-ai-user-agents will return an image in just a few ms. The first call may take 1-2s, but subsequent requests are answered by the CloudFront cache in < 50ms!

Now, in the head of each blog article, my site has meta tags that look like this:

<meta property="og:title" content="${article.title}" />
<meta property="og:description" content="${article.description}" />
<meta property="og:url" content="https://tbeseda.com/${article.path}" />
<meta property="og:image" content="https://tbeseda.com/og-img/${article.slug}" />

I may add an image and improve the layout later, but for now this is much better than nothing!

from Sanity.io (120.83ms) to HTML (1.12ms)