<?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>Open Graph images for Eleventy without paying the build-time tax</title>
      <dc:creator>Stevie g</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:04:49 +0000</pubDate>
      <link>https://dev.to/stevie_g/open-graph-images-for-eleventy-without-paying-the-build-time-tax-1ad1</link>
      <guid>https://dev.to/stevie_g/open-graph-images-for-eleventy-without-paying-the-build-time-tax-1ad1</guid>
      <description>&lt;p&gt;Share a link with no &lt;code&gt;og:image&lt;/code&gt; and you get the dead gray card. On X, in Slack,&lt;br&gt;
in Discord, your post sits next to ones with real preview images and loses. So&lt;br&gt;
you decide to generate one per post. Reasonable. Then you look at how Eleventy&lt;br&gt;
sites usually do it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two standard approaches, and what they cost
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Approach one: render at build time with Satori&lt;/strong&gt; (this is what most&lt;br&gt;
&lt;code&gt;eleventy-plugin-og-*&lt;/code&gt; packages do). Satori is fast and doesn't need a browser,&lt;br&gt;
but it renders a subset of CSS, not real CSS. Flexbox mostly works; plenty of&lt;br&gt;
other things don't. You also now own a font pipeline: font files checked into&lt;br&gt;
the repo or downloaded in CI, emoji handled separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach two: render at build time with headless Chrome.&lt;/strong&gt; Real CSS now, but&lt;br&gt;
you've put Chromium inside your build. That means installing it in CI on every&lt;br&gt;
run, roughly a hundred milliseconds to a few seconds per page, and a new way&lt;br&gt;
for deploys to fail that has nothing to do with your content. A 500-post site&lt;br&gt;
renders 500 images every full rebuild, including the 490 posts nobody will&lt;br&gt;
share this year.&lt;/p&gt;

&lt;p&gt;Both approaches share an assumption I stopped believing: that the image has to&lt;br&gt;
exist when the build finishes.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pattern: sign now, render on first share
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;og:image&lt;/code&gt; has one consumer: social crawlers. Nothing fetches that URL until&lt;br&gt;
the page is shared. So the image doesn't need to exist at build time. Only the&lt;br&gt;
&lt;em&gt;URL&lt;/em&gt; does.&lt;/p&gt;

&lt;p&gt;That suggests a different shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At build time, construct an image URL from the post's data (title, author,
tag) and sign it with an HMAC so nobody else can render on your account.
One hash, microseconds, no network call.&lt;/li&gt;
&lt;li&gt;Put that URL in the meta tag. It points at nothing that exists yet.&lt;/li&gt;
&lt;li&gt;The first time a crawler fetches it, a rendering service draws the image
with real Chrome, caches it permanently, and serves it. Every later fetch
is a cache hit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Build cost per page: one HMAC. Pages nobody shares never render at all. And if&lt;br&gt;
the rendering service is down during your deploy, nothing happens, because&lt;br&gt;
your build never talks to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wiring it up
&lt;/h2&gt;

&lt;p&gt;Disclosure before the code: I built the service this plugin talks to&lt;br&gt;
(&lt;a href="https://shotpipe.io" rel="noopener noreferrer"&gt;Shotpipe&lt;/a&gt;), so read this section as the author showing&lt;br&gt;
their own tool, not a neutral survey. The pattern above stands regardless of&lt;br&gt;
whose renderer you use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i eleventy-plugin-shotpipe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eleventy.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eleventyConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;eleventyConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eleventy-plugin-shotpipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOTPIPE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOTPIPE_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#5ca9ff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{# base layout #}
&amp;lt;meta property="og:image" content="{% ogImageUrl title, 'Your Name', 'blog' %}"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What ships in the built HTML is just a long URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt;
      &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://shotpipe.io/og?template=terminal&amp;amp;title=Hello&amp;amp;key=k_you&amp;amp;sig=8f2c..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The templates are hosted server-side and parameterized by query string, so&lt;br&gt;
there's no template HTML to build, host, or version yourself. You pass a title&lt;br&gt;
and pick an accent color. A logo can be uploaded once and referenced by&lt;br&gt;
content hash.&lt;/p&gt;

&lt;p&gt;I timed the difference on this blog: adding the plugin changed the build time&lt;br&gt;
by nothing measurable. That's not an optimization claim, it's just what "one&lt;br&gt;
HMAC per page" means in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens on first share
&lt;/h2&gt;

&lt;p&gt;Paste your post into Slack. Slack's crawler requests the signed URL. The&lt;br&gt;
service verifies the signature, renders the template in headless Chrome,&lt;br&gt;
stores the PNG, and responds. That first fetch takes a couple of seconds; the&lt;br&gt;
crawler waits, it's what crawlers do. Every fetch after that, from any&lt;br&gt;
platform, is served from a CDN cache in milliseconds, forever. The render&lt;br&gt;
happens exactly once per unique title.&lt;/p&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 images render on someone else's
infrastructure and you need an API key (there's a free tier, no card). If
you want zero external dependencies, this is not your pattern; use Satori
and accept the CSS subset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You use their templates.&lt;/strong&gt; Four of them, parameterized. If your brand
needs a fully bespoke design, a build-time approach with your own HTML gives
you more control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The URL is public.&lt;/strong&gt; The signature means nobody can render arbitrary
images on your key, but anyone can re-fetch an image you've already signed.
For OG images that's fine; they're public by definition.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather stay self-contained
&lt;/h2&gt;

&lt;p&gt;Fair choice. &lt;code&gt;eleventy-plugin-og-image&lt;/code&gt; renders with Satori at build time and&lt;br&gt;
is well maintained. &lt;code&gt;@vercel/og&lt;/code&gt; does the same rendering trick at the edge if&lt;br&gt;
you're on Vercel. The build-time tax is real but modest on small sites; it&lt;br&gt;
mostly bites on large sites and slow CI.&lt;/p&gt;

&lt;p&gt;But if you've ever watched a deploy fail because Chromium didn't download, or&lt;br&gt;
waited on 400 images that nobody asked for yet, it's worth questioning the&lt;br&gt;
assumption underneath: the image doesn't have to exist until someone looks.&lt;/p&gt;

</description>
      <category>eleventy</category>
      <category>11ty</category>
      <category>opengraph</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running headless Chrome in production is a part-time job</title>
      <dc:creator>Stevie g</dc:creator>
      <pubDate>Wed, 22 Jul 2026 18:32:24 +0000</pubDate>
      <link>https://dev.to/stevie_g/running-headless-chrome-in-production-is-a-part-time-job-16d1</link>
      <guid>https://dev.to/stevie_g/running-headless-chrome-in-production-is-a-part-time-job-16d1</guid>
      <description>&lt;p&gt;It works on your laptop. You wrote fifteen lines of Puppeteer, pointed it at a&lt;br&gt;
URL, got a PNG back. You wrapped it in a route, deployed it, and for about a day&lt;br&gt;
it was the easiest feature you ever shipped. Then the container got OOM-killed&lt;br&gt;
at 2am, the queue backed up behind it, and you spent the next week learning that&lt;br&gt;
"just screenshot the page" is a systems problem wearing a fifteen-line disguise.&lt;/p&gt;

&lt;p&gt;Here is the list of what breaks, in roughly the order it breaks, and the process&lt;br&gt;
shape that stops it. It's the same list whether you're on Puppeteer or&lt;br&gt;
Playwright, and whether you're rendering screenshots, PDFs, or OG images — it's&lt;br&gt;
Chrome's lifecycle that's hard, not the API in front of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Memory: the first wall, and the loudest
&lt;/h2&gt;

&lt;p&gt;A single headless Chrome rendering one page sits in the low hundreds of&lt;br&gt;
megabytes of RSS — call it 150–300 MB depending on the page. That's per render,&lt;br&gt;
and it's the &lt;em&gt;good&lt;/em&gt; case. Chrome also leaks: keep one instance alive long enough&lt;br&gt;
and RSS climbs and doesn't come back, because a browser was built to be closed&lt;br&gt;
by a human at the end of the day, not kept resident for a month. On a box with a&lt;br&gt;
fixed memory limit the ending is always the same — the kernel's OOM killer picks&lt;br&gt;
the fattest process and kills it mid-render, and your logs show a bare &lt;code&gt;SIGKILL&lt;/code&gt;&lt;br&gt;
with no stack trace.&lt;/p&gt;

&lt;p&gt;If you've searched &lt;code&gt;puppeteer out of memory&lt;/code&gt; and found forty issues with no&lt;br&gt;
accepted answer, this is why: there isn't a line to fix, there's a lifecycle to&lt;br&gt;
manage. Two things follow. Cap how many renders share one instance and&lt;br&gt;
&lt;strong&gt;recycle it&lt;/strong&gt; — close the whole browser and launch a fresh one every N pages,&lt;br&gt;
so leaked memory is reclaimed by process death, not by hope. And size&lt;br&gt;
concurrency to &lt;strong&gt;RAM, not CPU&lt;/strong&gt;: if one render is 250 MB, eight concurrent&lt;br&gt;
renders is 2 GB before you've counted the OS, and "eight" is a small number.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Zombies: the processes that don't die
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;browser.close()&lt;/code&gt; is supposed to clean up, and usually does. But Chrome isn't&lt;br&gt;
one process — it's a tree: a main process, a zygote, a renderer per page, plus&lt;br&gt;
GPU and utility helpers. When Chrome crashes, or your Node process is killed&lt;br&gt;
while a browser is open, or a navigation wedges the renderer, &lt;code&gt;close()&lt;/code&gt; never&lt;br&gt;
runs or never finishes, and you're left with &lt;code&gt;&amp;lt;defunct&amp;gt;&lt;/code&gt; chrome processes&lt;br&gt;
reparented to init. They hold memory and file descriptors. Do that a few&lt;br&gt;
thousand times and you exhaust PIDs or FDs, and the box stops accepting work for&lt;br&gt;
reasons that have nothing to do with your code.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: reap the tree yourself. Track the browser's PID and, on&lt;br&gt;
any abnormal exit, kill the whole process group instead of trusting the&lt;br&gt;
library's &lt;code&gt;close()&lt;/code&gt;. In a container, run a real init that reaps orphans&lt;br&gt;
(&lt;code&gt;--init&lt;/code&gt;, tini, or dumb-init) so PID 1 isn't your app pretending to be an init&lt;br&gt;
system it isn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Cold starts: the 800ms tax
&lt;/h2&gt;

&lt;p&gt;Launching Chrome costs roughly 800 milliseconds before it renders a single pixel&lt;br&gt;
— process spawn, sandbox setup, the first blank page. Launch a fresh browser per&lt;br&gt;
request and you pay that tax every request, where it dwarfs the actual render for&lt;br&gt;
anything simple. So the instinct is to launch one browser and reuse it forever —&lt;br&gt;
which walks you straight back into problem #1.&lt;/p&gt;

&lt;p&gt;The resolution is the distinction most tutorials skip: reuse the &lt;strong&gt;instance&lt;/strong&gt;,&lt;br&gt;
isolate per &lt;strong&gt;context&lt;/strong&gt;. A browser context is a clean, cookieless, cacheless&lt;br&gt;
session inside an already-running Chrome — cheap to create, cheap to destroy,&lt;br&gt;
isolated from every other render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// not this — 800ms of startup tax on every request&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// this — warm instance, throwaway context per render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBrowserContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// cheap, isolated&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// …render…&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// nothing leaks into the next render&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep a small pool of warm instances, give each render its own fresh context,&lt;br&gt;
throw the context away after, and recycle the whole instance every N renders&lt;br&gt;
(problem #1). You pay the 800ms once per instance lifetime, not once per request,&lt;br&gt;
and renders don't bleed into each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Pages that fight back
&lt;/h2&gt;

&lt;p&gt;Your renderer navigates to a URL and waits. Sometimes the wait never ends: an&lt;br&gt;
infinite redirect, a page that never fires &lt;code&gt;load&lt;/code&gt;, a websocket that keeps the&lt;br&gt;
network "busy" forever, a &lt;code&gt;while(true)&lt;/code&gt; in someone's analytics. Without a hard&lt;br&gt;
ceiling, one bad URL parks a browser until it's killed — and if you're reusing&lt;br&gt;
instances, one hostile page can wedge a slot in your pool for good.&lt;/p&gt;

&lt;p&gt;Put hard timeouts on both navigation and capture, and when one trips, &lt;strong&gt;kill the&lt;br&gt;
instance and launch a new one — don't try to nurse it back.&lt;/strong&gt; A browser that&lt;br&gt;
hung once is not a browser you can reason about; it's carrying whatever state&lt;br&gt;
caused the hang. The correct response to a sick Chrome is a fresh Chrome. It&lt;br&gt;
feels wasteful and it's the single most reliability-improving rule in the whole&lt;br&gt;
system.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Concurrency is a queue problem, not a loop
&lt;/h2&gt;

&lt;p&gt;The naive version renders inline: request arrives, you launch or borrow a&lt;br&gt;
browser, render, respond. Under load this is exactly how you die — a traffic&lt;br&gt;
spike becomes N simultaneous browsers becomes an OOM kill becomes every in-flight&lt;br&gt;
render failing at once. Rendering is expensive and bursty, which is the precise&lt;br&gt;
profile a queue exists for.&lt;/p&gt;

&lt;p&gt;Put a queue between the request and the render. The API accepts the job and&lt;br&gt;
returns immediately; a pool of workers pulls jobs at a rate their RAM can&lt;br&gt;
survive. A spike becomes &lt;strong&gt;queue depth&lt;/strong&gt; — a number you can watch and autoscale&lt;br&gt;
on — instead of a memory graph that falls off a cliff. Queue depth is your&lt;br&gt;
capacity early-warning signal; RSS-at-the-OOM-line is the alternative, and it&lt;br&gt;
warns you by paging you.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The font stack nobody mentions
&lt;/h2&gt;

&lt;p&gt;Your laptop has fonts. A minimal Linux container does not. So the page that&lt;br&gt;
looked right locally renders with tofu boxes where the CJK text was, blank&lt;br&gt;
rectangles where the emoji were, and the wrong fallback for everything else — and&lt;br&gt;
you find out from a customer's screenshot, not a test. Real page rendering means&lt;br&gt;
you now own a font pipeline: a base font set, CJK coverage, an emoji font, and&lt;br&gt;
the standing knowledge that a Chrome upgrade can shift glyph rendering and&lt;br&gt;
quietly change your output out from under a cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Serverless doesn't make this go away
&lt;/h2&gt;

&lt;p&gt;The reflex is "put it on Lambda and let someone else scale it." That moves the&lt;br&gt;
problems, it doesn't remove them. You ship a special slimmed Chromium build to&lt;br&gt;
fit the unzipped size limit; you eat a multi-second cold start on every&lt;br&gt;
scale-up, because a warm pool is exactly what serverless won't give you; you cap&lt;br&gt;
out at the function's memory and time limits, which is where &lt;code&gt;playwright lambda&lt;br&gt;
timeout&lt;/code&gt; comes from; and you still own the fonts. It's a legitimate deployment&lt;br&gt;
target, but it's a different set of sharp edges, not fewer of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that survives
&lt;/h2&gt;

&lt;p&gt;Put it together and the design that works isn't exotic — it's this list, applied&lt;br&gt;
consistently:&lt;/p&gt;

&lt;p&gt;![Architecture diagram: bursty traffic enters a queue; a pool of stateless, disposable workers each run a warm Chrome instance with one context per render, recycled every N renders and killed on hang; results land in an edge cache served in milliseconds.]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqcl659ipq38wzsr9zx6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqcl659ipq38wzsr9zx6.png" alt=" " width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;queue&lt;/strong&gt; between the request and the render — capacity is a worker count,
not a prayer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless, disposable workers&lt;/strong&gt;, each holding a small pool of warm Chrome
instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fresh context per render&lt;/strong&gt;, discarded after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recycle each instance every N renders&lt;/strong&gt;, so leaks die with the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard timeouts on navigation and capture; kill-and-respawn on any hang&lt;/strong&gt; —
never nurse a sick browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency sized to RAM&lt;/strong&gt;, with queue depth as the capacity signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A maintained font and emoji stack&lt;/strong&gt;, plus a cache version you can bump when
Chrome moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole trick. It's also, to be blunt, most of what our workers do —&lt;br&gt;
because there isn't a cleverer answer, only this list, monitored.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disclosure before the turn: we run this as a service&lt;br&gt;
(&lt;a href="https://shotpipe.io" rel="noopener noreferrer"&gt;Shotpipe&lt;/a&gt;), so read the next two sections as the author&lt;br&gt;
pointing at their own tool. The list above is true whether you build it or buy&lt;br&gt;
it — I'm claiming the list &lt;em&gt;is&lt;/em&gt; the work, not that you need us to do it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The part the memory threads leave out
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;puppeteer out of memory&lt;/code&gt; thread is about pages you control — your own&lt;br&gt;
dashboard, your own invoice, your own marketing page. The moment the URL comes&lt;br&gt;
from your &lt;em&gt;users&lt;/em&gt; — a link preview, an unfurl, a "screenshot my site" button —&lt;br&gt;
you've added a second problem that has nothing to do with memory: &lt;strong&gt;the URL&lt;br&gt;
might point back at you.&lt;/strong&gt; Someone submits&lt;br&gt;
&lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; and your obliging headless browser&lt;br&gt;
reads your cloud credentials and hands them back as a PNG. That's SSRF, and a&lt;br&gt;
browser is a near-perfect engine for it, because it follows redirects and&lt;br&gt;
resolves DNS for you — the two exact places the attack hides.&lt;/p&gt;

&lt;p&gt;Fixing it properly means resolving DNS yourself, checking &lt;em&gt;every&lt;/em&gt; resolved IP&lt;br&gt;
against private, loopback, link-local, and cloud-metadata ranges, pinning the IP&lt;br&gt;
you validated and connecting to &lt;em&gt;that&lt;/em&gt; one, and re-checking on every redirect&lt;br&gt;
hop. It's a module with its own test suite, not an &lt;code&gt;if&lt;/code&gt;-statement — and it's the&lt;br&gt;
part no memory-leak tutorial mentions, because those authors are rendering their&lt;br&gt;
own pages. We wrote it up in &lt;a href="https://shotpipe.io/screenshot-api" rel="noopener noreferrer"&gt;screenshotting URLs you don't&lt;br&gt;
control&lt;/a&gt;: if your renderer ever touches a&lt;br&gt;
URL a stranger typed, read that before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to run it yourself anyway
&lt;/h2&gt;

&lt;p&gt;Honestly: if you render a handful of pages you control, on a schedule,&lt;br&gt;
self-hosting is fine. Launch Chrome, render, close, move on — none of the above&lt;br&gt;
bites at that volume, and you shouldn't pay anyone to avoid a problem you don't&lt;br&gt;
have. The list starts mattering when renders get frequent, bursty, or pointed at&lt;br&gt;
URLs you don't own. That's the crossover where "just screenshot the page" stops&lt;br&gt;
being fifteen lines and becomes a service — ours, or the one you'll end up&lt;br&gt;
building.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>playwright</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <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>
