My Learning Curve

Feed / Cassidy Williams

Generating open graph images in Astro

Thursday, June 12, 2025

Something that always bugged me about this blog is that the open graph/social sharing images used this for every single post:

Original blog social card

I had made myself a blank SVG template (of just the rainbow-colored x pattern) for each post literally years ago, but didn’t want to manually create an image per blog post.

There are different solutions out there for this, like the Satori library, or using a service like Cloudinary, but they didn’t fit exactly how I wanted to build the images, and I clearly have a problem with control.

So, I built myself my own solution!

Using Puppeteer for screenshotting

Last year, I made a small demo for Cosynd with Puppeteer that screenshotted websites and put it into a PDF for our website copyright offering, aptly named screenshot-demo.

I liked how simple that script was, and thought I could follow a similar strategy for generating images. My idea was to:

  1. Put my blank SVG template on a webpage
  2. Position some text with a white background on top of the image
  3. Adjust the font size based on the length of the post title
  4. Screenshot it with Puppeteer

And then from there, I’d do this for every blog title I’ve written. Seemed simple enough?

Reader, it was not. BUT it worked out in the end!

My first strat: A page per blog post

Initially, I set up a fairly simple Astro page with HTML and CSS:

// [og].astro
---
let text = "Hello, world!"
---

<div class="svg-container">
	<img src="../img/blank-card-opt.svg" class="svg-image" />

	<div class="svg-text-group">
		<div class="svg-overlay-text" id="title">{text}div>
		<div class="svg-overlay-text-sub">a blog post by cassidoodiv>
	div>
div>

<style>
  ...
style>

With this, I was able to work out what size and positioning I wanted my text to be, and how I wanted it to adjust based on the length of the blog post title (both in spacing and in size). I used some dummy strings to do this pretty manually (like how I wanted it to change ever so slightly for titles that were 4 lines tall, etc.).

Amusing note, this kind of particular design work is really fun for me, and basically impossible for AI tools to get right. They do not have my eyes nor my opinions! I liked feeling artistic as I scooted each individual pixel around (for probably too much time) and made it feel “perfect” to me (and moved things in a way that probably 0 other people will ever notice).

Once I was happy with the dummy design I had going, I added a getStaticPaths() function to generate an HTML page for every post, so that Puppeteer could make a screenshot for each of them.

// In the frontmatter of [og].astro
import { getCollection } from "astro:content";

export async function getStaticPaths() {
	let posts = await getCollection("posts");

	return posts.map((post) => {
		return {
			params: { og: post.id },
			props: { text: post.data.title },
		};
	});
}

const { text } = Astro.props;

My next (better) strat: A single page template

With the previous strategy, everything worked well. But, my build times were somewhat long, because altogether the build was generating an HTML page per post (for people to read), a second HTML page per post (to be screenshotted), and then a screenshot image from that second HTML page. It was a bit too much.

So, before I get into the Puppeteer script part with you, I’ll skip to the part where I changed up my strategy (as the kids say) to use a single page template that accepted the blog post title as a query parameter.

The Astro page I showed you before is almost exactly the same, except:

  • It didn’t have to be a dynamic route anymore, so instead of the variable name [og].astro I changed it to just open-graph.astro
  • It didn’t need the frontmatter anymore, because it wasn’t rendering any pages or text at build time
  • It added a script for getting text from the query parameters

The new script on the page looked like this, which I put on the bottom of the page in a