Aug 14, 2024

Auto-Generating Open Graph Images on a Static Astro Site

There are some nuances to getting Satori working right.

Intro

As I start writing a bit more on this site, I don’t want to have to manage Open Graph images. Surely there must be a smart way to generate them automatically, right? It’s 2024!

This site runs on Astro, which is seriously awesome. And I found two plugins that looked like they might do the trick: astro-og-canvas and astro-opengraph-images. But I fiddled with both and couldn’t get either to work with my setup.

Then I found this writeup on how to do it from Arne Bahlo. It was a nice short post with just a little bit of code. Neat! Let’s go.

Building The Image Layout

Since Arne’s technique uses Satori, he recommends getting used to its layout engine by using the OG Image Playground. Here’s what I wanted to generate automatically for each post on this site:

Example Open Graph Image

But, for reasons we’ll see in a sec, don’t expect to just be able to copy the code you come up with in that playground (at least if you’re running a simple static Astro site like me).

Installing Dependencies

First, npm install satori sharp to get the dependencies we need.

Then make an endpoint like Arne describes:

pages/og-image.png.ts
import fs from "fs/promises";
import satori from "satori";
import sharp from "sharp";
import type { APIRoute } from "astro";
export const get: APIRoute = async function get({ params, request }) {
const robotoData = await fs.readFile("./public/fonts/roboto/Roboto-Regular.ttf");
const svg = await satori(
{
type: "h1",
props: {
children: "Hello world",
style: {
fontWeight: "bold",
},
},
},
{
width: 1200,
height: 630,
fonts: [
{
name: "Roboto",
data: robotoData,
weight: "normal",
style: "normal",
},
],
},
);
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return new Response(png, {
headers: {
"Content-Type": "image/png",
},
});
};

Make sure you actually have Roboto (or whatever font) present in your public directory, or update that URL as needed. Any WOFF or TTF/OTF file should work.

Now you visit YOUR_SITE_URL/og-image.png you should see a nice simple “Hello World” image. Cool! We’ll move this code in a sec, but we just wanted to get it working.

No React? No Problem …But There’s a Catch

This is where I thought I could just copy/paste the stuff I had from the OG Image playground, but alas, it didn’t work. Because according to Astro

An appropriate UI framework (ReactPreact, or Solid) is required to render JSX/TSX files. Use .jsx/.tsx extensions where appropriate, as Astro does not support JSX in .js/.ts files.

Welp, I don’t have React on this site, and I wasn’t really looking to add it just for image generation.

BUT, if we build our image using the syntax Arne uses above, as mentioned on the Satori README, it’ll work!

Alright fast forward a bit…

Here’s what I went with for this site. I actually used chatGPT to translate the JSX version I built on the playground into this React-elements-like objects syntax. Here’s the full utility function:

src/utils/og-image.ts
import { getEntry } from "astro:content";
import fs from "fs/promises";
import satori from "satori";
import sharp from "sharp";
import { profileImageData } from "@src/profileImageData";
const generateOgImage = async (slug: string, collectionName: any, ogTitleField: string) => {
const fontData = await fs.readFile("./public/fonts/inter-black.ttf");
const page: any = await getEntry(collectionName, slug);
let thumbBase64String = "";
if (page.data.thumb?.fsPath) {
const thumbBase64 = (await fs.readFile(page.data.thumb.fsPath)).toString("base64");
thumbBase64String = "data:image/png;base64," + thumbBase64;
}
const element = {
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
justifyContent: "space-between",
backgroundImage: "linear-gradient(45deg, rgb(28,25,23), rgb(68,64,60))",
},
children: [
{
type: "div",
props: {
style: {
padding: "4rem",
paddingRight: 0,
display: "flex",
flexShrink: 1,
gap: "2rem",
flexDirection: "column",
justifyContent: "center",
},
children: [
{
type: "div",
props: {
style: {
fontSize: "3rem",
color: "#d6d3d1",
},
children: "levinelson.com",
},
},
{
type: "div",
props: {
style: {
fontSize: "5rem",
lineHeight: 1.1,
fontWeight: 900,
flexShrink: 1,
backgroundImage: "linear-gradient(45deg, rgb(255,255,255), rgb(231,229,228))",
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
},
children: page.data[ogTitleField] ?? page.data.title,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-end",
},
children: [
thumbBase64String
? {
type: "img",
props: {
width: 160,
height: 160,
src: thumbBase64String,
style: {
width: 160,
height: 160,
display: "block",
margin: "4rem",
},
},
}
: {
type: "div",
props: {
style: {
width: 160,
height: 160,
display: "block",
margin: "4rem",
},
},
},
{
type: "img",
props: {
width: 256,
height: 256,
src: profileImageData,
style: {
width: 256,
height: 256,
display: "block",
},
},
},
],
},
},
],
},
};
const svg = await satori(element, {
width: 1200,
height: 630,
fonts: [
{
name: "Inter",
data: fontData,
weight: 900,
style: "normal",
},
],
});
return sharp(Buffer.from(svg)).png().toBuffer();
};
export { generateOgImage };

There’s some stuff in there I had to figure out - getting the images to work in general, and getting dynamic titles/images.

Working With Dynamic Routes

This site uses dynamic routing to display the posts. Meaning, for my Work posts on the other side of this site, the simplified directory structure looks like this:

src/
|-- content/
|-- work/
|-- post1.md
|-- post2.md
|-- post3.md
|-- pages/
|-- work/
|-- [...slug].astro

So to get that Open Graph image dynamically generated along with my posts, I had to add a new API route there:

src/
|-- content/
|-- work/
|-- post1.md
|-- post2.md
|-- post3.md
|-- pages/
|-- work/
|-- [...slug]
|--- og-image.png.ts <-- new
|-- [...slug].astro

That means on my site we’ll be able to go to /work/{slug}/og-image.png to get any given Open Graph image.

And that file looks like this:

src/pages/work/[...slug]/og-image.png.ts
import { getCollection } from "astro:content";
import type { APIRoute } from "astro";
import { generateOgImage } from "@src/utils/og-image";
export async function getStaticPaths() {
const workEntries = await getCollection("work");
return workEntries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
export const GET: APIRoute = async function get({ params }) {
const png = await generateOgImage(params.slug, "work", "ogTitle");
return new Response(png, {
headers: {
"Content-Type": "image/png",
},
});
};

A given work entry has some props defined in the front matter:

src/content/work/defer.mdx
title: Defer
thumb: ./thumbs/defer.png
... other props
---
... post markdown content below

That’s what lets us pull in the Title and Thumbnail Icon in the generateOgImage() function.

BUT one tricky bit with the images - if you store them in the src/ directory like I do to take advantage of the Astro <Image /> component, the simplest way to use them is to convert them to base64 data.

Here’s the magic bit in my generateOgImage() function that gets that relative URL of the image in the markdown front matter to the Satori generator in a way it likes:

const page: any = await getEntry(collectionName, slug);
let thumbBase64String = "";
if (page.data.thumb?.fsPath) {
const thumbBase64 = (await fs.readFile(page.data.thumb.fsPath)).toString("base64");
thumbBase64String = "data:image/png;base64," + thumbBase64;
}
  1. Grab the post/entry
  2. Grab the thumb if it exists, get that file system path of that image
  3. Convert it to base64
  4. Construct a full base64 url

Then we can use thumbBase64String in the function as seen above in this bit…

src/utils/og-image.ts
// previous code
children: [
thumbBase64String // yay!
? {
type: "img",
props: {
width: 160,
height: 160,
src: thumbBase64String, // yay!
style: {
width: 160,
height: 160,
display: "block",
margin: "4rem",
},
},
}
: {
type: "div",
props: {
style: {
width: 160,
height: 160,
display: "block",
margin: "4rem",
},
},
},
{
type: "img",
props: {
width: 256,
height: 256,
src: profileImageData,
style: {
width: 256,
height: 256,
display: "block",
},
},
},
];
// subsequent code

I also have the profileImageData just living as a base64 string in the src/ directory, since that’s constant on each Open Graph image.

Whew! Now we’ve got a nice Open Graph image for each page, without manually making them. Throw a /og-image.png on the end of any post on this site to see it in action.

I know this setup might be a bit idiosyncratic to this site, but hopefully it helps some poor soul trying to do something similar.