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.
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 DynamoDB (7.9ms) to HTML (0.92ms) in 8.9ms