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 }}">
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.Textdraws 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 ofsplitand 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 inpublic/. 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 }}
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 }}
Call it from your head partial:
{{ partial "shotpipe-og.html" (dict "title" .Title "author" .Site.Params.author "tag" .Section) }}
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_']
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
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.Textand 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)