Generating OpenGraph Images for Hugo

I wanted to enhance my blog posts by adding OpenGraph images—those visually appealing thumbnails that appear when you share a link. My posts don’t always have pictures. So, I decided to create OpenGraph images that feature the post’s title.

I found a couple blog posts with ideas on how to do this. One was using Cloudinary1 to generate the images. Another used Hugo’s built in image processing functions2 to add the post title to a pre-made image template, made with Figma.

This approach worked well for short titles that could fit on one line but I wasn’t a fan of the results for longer titles. I wanted the title to be aligned centrally no matter how long it was. I know that CSS solves the problem of laying out text very well with tools like Flexbox so I wanted to explore that approach.

For rendering the image, I chose wkhtmltoimage3. It works without needing a browser, perfect for automating the process with Hugo. I wrote a script using Bun shell to loop through each post and extract its title from its front-matter4. I’d put that title in my template before converting it to an image.

Script to generate the images
const generateImageForPost = async (post: string) => {
  try {
    (await readdir(join(postsDir, post)))
  } catch {
    return
  }

  const f = Bun.file(join(postsDir, post, "index.md"))
  const title = extractTitle(await f.text())
  const outputTemplate = template.replace('PAGE_TITLE', title)

  await $`echo ${outputTemplate} | wkhtmltoimage --quality 1 - ${join(postsDir, post, "og.png")}`.quiet()
  console.log(`Done ${title}`)
}


let tasks: Promise<any>[] = [];

for (const post of posts) {
  tasks.push(generateImageForPost(post));
}

await Promise.all(tasks);

I wanted to use the same custom font that I use on my website. I had to encode this font in base64 for the CSS and prefix some CSS properties because wkhtmltoimage uses an older browser engine.

HTML template
<!doctype html>
<html>
	<head>
		<style>
			@font-face {
				font-family: "Cera Pro";
				font-style: normal;
				font-weight: 500;
				font-display: swap;

				src: url(data:font/truetype;charset=utf-8;base64,<...>)
					format("truetype");
			}
			body,
			html {
				margin: 0;
				padding: 0;
				width: 1200px;
				height: 630px;
				font-weight: 500;
				font-family: "Cera Pro", sans-serif;
				color: black;
				background-color: white;
				display: -webkit-box;
				display: flex;
				-webkit-box-align: center;
				-webkit-flex-align: center;
				align-items: center;
			}
			.logo {
				top: 71px;
				left: 120px;
				font-size: 32px;
				position: absolute;
				display: -webkit-box;
				display: flex;
			}
			.logo > svg {
				margin-top: 4px;
				margin-right: 24px;
			}
			.title {
				font-size: 64px;
				word-wrap: break-word;
				padding: 0 120px 0 120px;
			}
		</style>
	</head>
	<body>
		<div class="logo">
			<svg width="32" height="32">
				<circle cx="16" cy="16" r="16" />
			</svg>
			<div>zidhuss.tech</div>
		</div>
		<div class="title">PAGE_TITLE</div>
	</body>
</html>
Adding to Hugo
<head>

	<!-- other code -->

	{{- with .Page.Resources.GetMatch "og.png" }}
		<meta property="og:image" content="{{ .Permalink }}" />
	{{- end }}
</head>

This is the final result. Here’s the image you’ll see when you share this post. Every post will now have its own image, making them look better when shared online.

OpenGraph image for this post generated with the code below

  1. https://www.brycewray.com/posts/2022/10/automated-social-media-images-cloudinary-hugo/ ↩︎

  2. https://aarol.dev/posts/hugo-og-image/ ↩︎

  3. https://wkhtmltopdf.org/ ↩︎

  4. I could have done this with an npm package but I didn’t want to have to install any JS dependencies as part of my build process. ↩︎