DEV Community

Cover image for Dynamic OG images for Hugo, no Node - no headless Chrome
Stevie g
Stevie g

Posted on • Originally published at shotpipe.io

Dynamic OG images for Hugo, no Node - no headless Chrome

You picked Hugo partly because it's one Go binary. No node_modules, no
lockfile, no toolchain drift. Then you want a social card per post, and every
tutorial you find opens with npm install.

There are three real options for a Go-based site. Two of them are free and
neither needs Node — I'll go through them honestly before getting to the one I
built.

Option 1: Hugo draws the image itself (images.Text)

Hugo's image pipeline includes an images.Text filter that draws text onto an
image. No external anything: a background image, a TTF, and a template.

{{ $base := resources.Get "images/og-base.png" }}
{{ $font := resources.Get "fonts/Inter-Bold.ttf" }}
{{ $card := $base.Filter (images.Text .Title (dict
    "color" "#ffffff"
    "size" 64
    "linespacing" 12
    "x" 80 "y" 220
    "font" $font
)) }}
<meta property="og:image" content="{{ $card.Permalink }}">
Enter fullscreen mode Exit fullscreen mode

That works, and if your card is "logo, brand background, title in one weight,"
it is genuinely the right answer — zero dependencies, zero third parties, zero
dollars. Know what you're signing up for though:

  • No automatic line wrapping. images.Text draws what you give it at the offset you give it. Long titles run off the canvas unless you split them into lines yourself in the template, which turns into a small pile of split and character-counting logic that you will tune by eye.
  • Layout is x/y offsets, not CSS. No flexbox, no auto-fitting type, no measuring the text you just drew. Every design change is arithmetic.
  • It renders during the build. Hugo caches generated images in resources/_gen, so local rebuilds are cheap — but CI with a cold cache regenerates every card, and the images ship in public/. On a 500-post site you are building and deploying 500 PNGs, including the ones nobody will ever share.

Option 2: a Node step in CI

Run hugo, then run a Node script over the output that renders cards with
Satori or Puppeteer. This is what most "Hugo OG image" search results end up
recommending, usually as a GitHub Action.

It gives you real layout (or Satori's approximation of it), and it costs you
exactly the thing you chose Hugo to avoid: a Node toolchain, a lockfile, and
either a font pipeline or a Chromium download in every CI run. Deploys now have
a way to fail that has nothing to do with your content.

Option 3: let Hugo sign a URL and render nothing

Here's the observation the other two miss. An og:image has exactly one
consumer: social crawlers. Nothing fetches that URL until somebody shares the
page. So the image doesn't need to exist when the build finishes — only the
URL does.

Which is lucky, because Hugo can compute an HMAC natively. crypto.HMAC
(aliased hmac) is a built-in template function:

{{ hmac "sha256" $secret $message }}
Enter fullscreen mode Exit fullscreen mode

That's the whole dependency story. A signed image URL is a string Hugo can
build during the build, with a template, in microseconds per page. No package,
no Node, no Chromium, no image files in public/. The card renders the first
time a crawler asks for it — once, ever — and is cached from then on.

Disclosure: the service the URL points at is Shotpipe,
which I build. The pattern works with any renderer that signs requests the
same way; what makes it a particularly good fit for Hugo is that it needs no
package at all, which is not true of the Eleventy or Astro versions of this.

The partial

Save this as layouts/partials/shotpipe-og.html:

{{/* params appended in alphabetical order — that IS the canonical sort */}}
{{ $key := getenv "SHOTPIPE_KEY" }}{{ $secret := getenv "SHOTPIPE_SECRET" }}
{{ if and $key $secret }}
{{ $pairs := slice }}
{{ with .author }}{{ $pairs = $pairs | append (printf "author=%s" (replace (urlquery .) "+" "%20")) }}{{ end }}
{{ $pairs = $pairs | append (printf "key=%s" (replace (urlquery $key) "+" "%20")) }}
{{ with .tag }}{{ $pairs = $pairs | append (printf "tag=%s" (replace (urlquery .) "+" "%20")) }}{{ end }}
{{ with .template }}{{ $pairs = $pairs | append (printf "template=%s" (replace (urlquery .) "+" "%20")) }}{{ end }}
{{ $pairs = $pairs | append (printf "title=%s" (replace (urlquery .title) "+" "%20")) }}
{{ $canonical := delimit $pairs "&" }}
{{ $sig := hmac "sha256" $secret $canonical }}
<meta property="og:image" content="{{ printf "https://shotpipe.io/og?%s&sig=%s" $canonical $sig | safeURL }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ printf "https://shotpipe.io/og?%s&sig=%s" $canonical $sig | safeURL }}">
{{ end }}
Enter fullscreen mode Exit fullscreen mode

Call it from your head partial:

{{ partial "shotpipe-og.html" (dict "title" .Title "author" .Site.Params.author "tag" .Section) }}
Enter fullscreen mode Exit fullscreen mode

Why the +%20 dance

This is the part worth understanding, because it's where a hand-rolled version
of this silently breaks and every URL comes back 401 invalid signature.

The string being signed is canonical: parameters sorted alphabetically by name,
each name and value percent-encoded per RFC 3986, joined with &. Both sides
have to build that string byte-for-byte identically or the HMACs differ.

Hugo's urlquery is Go's url.QueryEscape, which is almost RFC 3986 —
except it encodes a space as + rather than %20. Hence the replace. And
the parameters are appended to $pairs in alphabetical order, which means the
template never needs to sort anything; the source order is the canonical
order. Keep it that way when you add parameters.

We pin this with a test on our side: it builds the canonical string the way
this partial does — including Go's escaping rules, unicode titles, emoji, and
awkward punctuation — and asserts it is byte-identical to what the server
computes. If the recipe on this page ever drifts from the server, our test
suite fails before you find out from a broken card.

Two setup notes

Hugo's security allowlist blocks getenv for anything not prefixed HUGO_, so
add this to hugo.toml:

[security.funcs]
  getenv = ['^HUGO_', '^CI$', '^SHOTPIPE_']
Enter fullscreen mode Exit fullscreen mode

Then export SHOTPIPE_KEY and SHOTPIPE_SECRET wherever Hugo builds — your
shell locally, and the environment-variable settings in Netlify, Cloudflare
Pages, or your GitHub Action. Note the partial's if and $key $secret guard:
with no key configured it emits nothing at all rather than a broken tag, so a
contributor without secrets can still build the site.

Check that it worked

$ hugo
$ grep -o 'og:image[^>]*' public/posts/my-post/index.html

# fetch the card the way a crawler would
$ curl -sI "<paste the URL>" | head -3
HTTP/2 200
content-type: image/png
x-cache: MISS

# run it again — this is what every later crawler gets
$ curl -sI "<same URL>" | grep x-cache
x-cache: HIT
Enter fullscreen mode Exit fullscreen mode

A 401 means the canonical string doesn't match: check the parameter order and
that every value went through the replace. If the tag is missing entirely,
your environment variables aren't reaching Hugo — check the security.funcs
block first.

What each option actually costs

images.Text Node in CI Signed URL
dependencies none Node + Satori or Chromium none
build cost/page image encode (cached) 0.1–3 s render one HMAC
cold CI regenerates every card installs a toolchain nothing to do
layout x/y offsets, manual wrapping real (or Satori-subset) CSS hosted templates, parameterized
renders when every build every build first share, once
third party no no yes — an API key
cost free free (plus CI minutes) free tier, then $14/mo

Tradeoffs, honestly

  • It's a hosted service. Your cards render on someone else's machines and you need an API key. If "no third parties" is a hard requirement, take images.Text and spend the afternoon on line wrapping — that's a legitimate choice, not a consolation prize.
  • You use hosted templates. Four of them, parameterized by title, author, tag, accent colour, and an optional logo. Fully bespoke art direction wants a build-time approach with your own HTML.
  • The URL is public. The signature stops anyone rendering new images on your key; it doesn't stop someone re-fetching a card you already signed. For an og:image, that's fine — they're public by definition.
  • Your build is decoupled from us. Worth stating plainly: because Hugo never calls the API during a build, an outage on our side cannot fail your deploy. It would delay a card rendering, nothing more.

The Go-shaped answer

Hugo users get told to bolt a JavaScript pipeline onto a Go site every time
this comes up. You don't have to. Either draw the card with the image filter
Hugo already ships, or let Hugo do the one thing it's very good at — producing
a string, fast, at build time — and let the image show up when someone actually
looks at it.

Full parameter list and the other integrations are in the
docs. The free tier is 100 renders a
month, no card; on a static blog, the renders are one per post, once, forever.

Top comments (0)