<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stevie g</title>
    <description>The latest articles on DEV Community by Stevie g (@stevie_g).</description>
    <link>https://dev.to/stevie_g</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4040553%2F48d56cd9-ac9c-4beb-b26d-01ad0e770532.png</url>
      <title>DEV Community: Stevie g</title>
      <link>https://dev.to/stevie_g</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stevie_g"/>
    <language>en</language>
    <item>
      <title>Dynamic OG images for Hugo, no Node - no headless Chrome</title>
      <dc:creator>Stevie g</dc:creator>
      <pubDate>Tue, 21 Jul 2026 17:48:11 +0000</pubDate>
      <link>https://dev.to/stevie_g/dynamic-og-images-for-hugo-no-node-no-headless-chrome-1k2</link>
      <guid>https://dev.to/stevie_g/dynamic-og-images-for-hugo-no-node-no-headless-chrome-1k2</guid>
      <description>&lt;p&gt;You picked Hugo partly because it's one Go binary. No &lt;code&gt;node_modules&lt;/code&gt;, no&lt;br&gt;
lockfile, no toolchain drift. Then you want a social card per post, and every&lt;br&gt;
tutorial you find opens with &lt;code&gt;npm install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are three real options for a Go-based site. Two of them are free and&lt;br&gt;
neither needs Node — I'll go through them honestly before getting to the one I&lt;br&gt;
built.&lt;/p&gt;
&lt;h2&gt;
  
  
  Option 1: Hugo draws the image itself (images.Text)
&lt;/h2&gt;

&lt;p&gt;Hugo's image pipeline includes an &lt;code&gt;images.Text&lt;/code&gt; filter that draws text onto an&lt;br&gt;
image. No external anything: a background image, a TTF, and a template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ $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
)) }}
&amp;lt;meta property="og:image" content="{{ $card.Permalink }}"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No automatic line wrapping.&lt;/strong&gt; &lt;code&gt;images.Text&lt;/code&gt; 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 &lt;code&gt;split&lt;/code&gt; and
character-counting logic that you will tune by eye.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout is x/y offsets, not CSS.&lt;/strong&gt; No flexbox, no auto-fitting type, no
measuring the text you just drew. Every design change is arithmetic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It renders during the build.&lt;/strong&gt; Hugo caches generated images in
&lt;code&gt;resources/_gen&lt;/code&gt;, so local rebuilds are cheap — but CI with a cold cache
regenerates every card, and the images ship in &lt;code&gt;public/&lt;/code&gt;. On a 500-post site
you are building and deploying 500 PNGs, including the ones nobody will ever
share.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Option 2: a Node step in CI
&lt;/h2&gt;

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

&lt;p&gt;It gives you real layout (or Satori's approximation of it), and it costs you&lt;br&gt;
exactly the thing you chose Hugo to avoid: a Node toolchain, a lockfile, and&lt;br&gt;
either a font pipeline or a Chromium download in every CI run. Deploys now have&lt;br&gt;
a way to fail that has nothing to do with your content.&lt;/p&gt;
&lt;h2&gt;
  
  
  Option 3: let Hugo sign a URL and render nothing
&lt;/h2&gt;

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

&lt;p&gt;Which is lucky, because &lt;strong&gt;Hugo can compute an HMAC natively&lt;/strong&gt;. &lt;code&gt;crypto.HMAC&lt;/code&gt;&lt;br&gt;
(aliased &lt;code&gt;hmac&lt;/code&gt;) is a built-in template function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ hmac "sha256" $secret $message }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;blockquote&gt;
&lt;p&gt;Disclosure: the service the URL points at is &lt;a href="https://shotpipe.io" rel="noopener noreferrer"&gt;Shotpipe&lt;/a&gt;,&lt;br&gt;
which I build. The pattern works with any renderer that signs requests the&lt;br&gt;
same way; what makes it a particularly good fit for Hugo is that it needs no&lt;br&gt;
package at all, which is not true of the Eleventy or Astro versions of this.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  The partial
&lt;/h3&gt;

&lt;p&gt;Save this as &lt;code&gt;layouts/partials/shotpipe-og.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{/* 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 "&amp;amp;" }}
{{ $sig := hmac "sha256" $secret $canonical }}
&amp;lt;meta property="og:image" content="{{ printf "https://shotpipe.io/og?%s&amp;amp;sig=%s" $canonical $sig | safeURL }}"&amp;gt;
&amp;lt;meta name="twitter:card" content="summary_large_image"&amp;gt;
&amp;lt;meta name="twitter:image" content="{{ printf "https://shotpipe.io/og?%s&amp;amp;sig=%s" $canonical $sig | safeURL }}"&amp;gt;
{{ end }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it from your head partial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ partial "shotpipe-og.html" (dict "title" .Title "author" .Site.Params.author "tag" .Section) }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the &lt;code&gt;+&lt;/code&gt; → &lt;code&gt;%20&lt;/code&gt; dance
&lt;/h3&gt;

&lt;p&gt;This is the part worth understanding, because it's where a hand-rolled version&lt;br&gt;
of this silently breaks and every URL comes back &lt;code&gt;401 invalid signature&lt;/code&gt;.&lt;/p&gt;

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

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

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

&lt;p&gt;Hugo's security allowlist blocks &lt;code&gt;getenv&lt;/code&gt; for anything not prefixed &lt;code&gt;HUGO_&lt;/code&gt;, so&lt;br&gt;
add this to &lt;code&gt;hugo.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[security.funcs]&lt;/span&gt;
  &lt;span class="py"&gt;getenv&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'^HUGO_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'^CI$'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'^SHOTPIPE_'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Check that it worked
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;hugo
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'og:image[^&amp;gt;]*'&lt;/span&gt; public/posts/my-post/index.html

&lt;span class="c"&gt;# fetch the card the way a crawler would&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;paste the URL&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
HTTP/2 200
content-type: image/png
x-cache: MISS

&lt;span class="c"&gt;# run it again — this is what every later crawler gets&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;same URL&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;x-cache
x-cache: HIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  What each option actually costs
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Tradeoffs, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a hosted service.&lt;/strong&gt; Your cards render on someone else's machines and
you need an API key. If "no third parties" is a hard requirement, take
&lt;code&gt;images.Text&lt;/code&gt; and spend the afternoon on line wrapping — that's a legitimate
choice, not a consolation prize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You use hosted templates.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The URL is public.&lt;/strong&gt; The signature stops anyone rendering new images on
your key; it doesn't stop someone re-fetching a card you already signed. For
an &lt;code&gt;og:image&lt;/code&gt;, that's fine — they're public by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your build is decoupled from us.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Go-shaped answer
&lt;/h2&gt;

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

&lt;p&gt;Full parameter list and the other integrations are in the&lt;br&gt;
&lt;a href="https://shotpipe.io/docs#eleventy" rel="noopener noreferrer"&gt;docs&lt;/a&gt;. The free tier is 100 renders a&lt;br&gt;
month, no card; on a static blog, the renders are one per post, once, forever.&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>opengraph</category>
      <category>go</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
