Generating OpenGraph images with Hugo

Published 23.09.2023 • Last modified 05.11.2023

Have you ever shared a link somewhere and wondered where those little preview images of websites come from? Well if you didn’t know, they are called OpenGraph tags. Open Graph protocol was invented by Facebook and is now used by many other social media sites, like X (Twitter), LinkedIn, Reddit, Discord, Mastodon and many others.

They look like this:

 <meta property="og:title" content="Generating OpenGraph images with Hugo" />
 <meta name="description" content="Have you ever shared a link somewhere and …">
 <meta property="og:image" content="">

There are a bunch more tags, but these 3 are probably the most important ones. Title and description can be easily generated with Hugo, but what about images? There are a bunch of cloud services that offer og:image generation, like Cloudinary or Vercel’s og, but I didn’t want to pay for those. I want my whole website to be static and cheap to host. I managed to generate some sweet og:images using Hugo’s image manipulation tools.

First, I created a new partial “opengraph.html” and copied hugo’s builtin opengraph partial.

{{/* Copied from */}}
<meta property="og:title" content="{{ .Title }}" />
<meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}" />
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
<meta property="og:url" content="{{ .Permalink }}" />

{{- if .IsPage }}
{{- $iso8601 := "2006-01-02T15:04:05-07:00" -}}
<meta property="article:section" content="{{ .Section }}" />
{{ with .PublishDate }}<meta property="article:published_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
{{ with .Lastmod }}<meta property="article:modified_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
{{- end -}}

{{- with }}<meta property="og:audio" content="{{ . }}" />{{ end }}
{{- with .Params.locale }}<meta property="og:locale" content="{{ . }}" />{{ end }}
{{- with .Site.Params.title }}<meta property="og:site_name" content="{{ . }}" />{{ end }}
{{- with .Params.videos }}{{- range . }}
<meta property="og:video" content="{{ . | absURL }}" />
{{ end }}{{ end }}

Next, I created a background for the og:image using Figma. I made a frame which is 1200px wide and 630px tall, as those seem to be the recommended dimensions.

Screenshot of a Figma frame with a logo and title reading “Insert title here”

I saved the image as “og_base.png” in the assets directory. I also downloaded the Inter font from Google Fonts and copied “Inter-Medium.ttf” and “Inter-SemiBold.ttf” to the assets folder as well. Next, I added this to the partial:

{{/* Generate opengraph image */}}
{{- if .IsPage -}}
  {{ $base := resources.Get "og_base.png" }}
  {{ $boldFont := resources.Get "/Inter-SemiBold.ttf"}}
  {{ $mediumFont := resources.Get "/Inter-Medium.ttf"}}
  {{ $img := $base.Filter (images.Text .Site.Title (dict
    "color" "#ffffff"
    "size" 52
    "linespacing" 2
    "x" 141
    "y" 117
    "font" $boldFont
  {{ $img = $img.Filter (images.Text .Page.Title (dict
    "color" "#ffffff"
    "size" 64
    "linespacing" 2
    "x" 141
    "y" 291
    "font" $mediumFont
  {{ $img = resources.Copy (path.Join .Page.RelPermalink "og.png") $img }}
  <meta property="og:image" content="{{$img.Permalink}}">
{{ end }}

This will overlay text on top of the base image, conveniently wrapping the title if it overflows. You might be wondering why I call resources.Copy at the end and don’t use the generated image straight away. The reason is that I wanted to put the image right where the content is, and not pollute the output’s root folder with dozens of images. This way, the url will be and not Much nicer!

The only thing left is to add {{ partial "opengraph.html" . }} to your <head> tag.