A static site has no server to render an image when a crawler asks for it, so per-page Open Graph images come from one of three places: files generated during the build, an image URL that a service renders on its first request, or one tag that reads the finished page. All three keep your HTML static. This guide shows each for Hugo, Jekyll and Astro, with code that runs as is.
Three ways to get a per-page og:image
| Build-time files | Signed image URL | Auto tag (reads the page) | |
|---|---|---|---|
| What runs at build | An image generator per page | An HMAC per page (microseconds) | Nothing |
| Build time | Grows with page count | Unchanged | Unchanged |
| Secret needed at build | No (or an API key) | Signing secret | No |
| Design control | Full | Full (template + fields) | One template for the site |
| Works on any host | Yes | Yes | Yes |
Build-time files are the classic answer and work well for a few hundred pages. A signed URL moves the rendering out of the build: the template computes a URL with the page's title in it, and the first crawler that fetches it causes the render. The auto tag is the zero-configuration option for sites where you cannot touch the build at all.
The signed-URL examples below use Shareglint's /i/ URLs and the built-in blog-og template. The signature is HMAC-SHA256 over the path and the sorted, RFC 3986-encoded query, truncated to 32 hex characters (full algorithm). The secret is an environment variable of the build and never appears in the output.
Hugo
Hugo can compute the signature in a template: querify builds a sorted query string, and crypto.HMAC signs it. querify encodes spaces as +, while the signature expects %20, so the replaceRE swaps them — Hugo encodes a literal plus sign as %2B, so nothing else is affected.
{{/* layouts/_partials/og-image.html (layouts/partials/ before Hugo 0.146) */}}
{{- $fields := dict "title" .Title "author" (.Params.author | default site.Title) "site" site.Title -}}
{{- $path := printf "/i/%s/blog-og.png" site.Params.og.account -}}
{{- $query := querify $fields | replaceRE `\+` "%20" -}}
{{- $sig := substr (crypto.HMAC "sha256" (os.Getenv "HUGO_OG_SIGNING_SECRET") (printf "%s?%s" $path $query)) 0 32 -}}
<meta property="og:image" content="{{ printf "https://shareglint.com%s?%s&s=%s" $path $query $sig }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
# hugo.toml
[params.og]
account = "acc_..." # your account id: public, not a secret
Build with HUGO_OG_SIGNING_SECRET=… hugo. The HUGO_ prefix matters: Hugo's default security policy only lets templates read environment variables that match ^HUGO_.
If you also use Hugo's embedded Open Graph template ({{ partial "opengraph.html" . }} since v0.146, {{ template "_internal/opengraph.html" . }} before), it prints og:image itself whenever a page has an images front matter entry — call the partial above only for pages without one, or you will ship two og:image tags and platforms pick the first.
We checked this partial on Hugo v0.168: it produces exactly the test-vector signature from the docs.
Jekyll
jekyll-seo-tag prints og:image from the image key in front matter. Rather than add that key to every post, fill it during the build with a hook:
# _plugins/og_image.rb
require "openssl"
require "erb"
module OgImage
def self.url(template, fields)
path = "/i/#{ENV.fetch("OG_ACCOUNT_ID")}/#{template}.png"
query = fields.map { |k, v| [k.to_s, v.to_s] }.sort
.map { |k, v| "#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }
.join("&")
s = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("OG_SIGNING_SECRET"), "#{path}?#{query}")[0, 32]
"https://shareglint.com#{path}?#{query}&s=#{s}"
end
end
Jekyll::Hooks.register :documents, :pre_render do |doc, _payload|
next if doc.data["image"] || doc.data["title"].to_s.empty?
doc.data["image"] = OgImage.url("blog-og", title: doc.data["title"], site: doc.site.config["title"])
end
:documents covers posts and other collections; register :pages the same way if standalone pages need cards too. ERB::Util.url_encode is RFC 3986 encoding, which is what the signature expects. Note that GitHub Pages' built-in Jekyll build does not run custom plugins — build with GitHub Actions instead and the plugin works.
Astro
In Astro the component frontmatter runs at build time for static output, so node:crypto is available and the secret stays on the build machine:
---
// src/components/OgImage.astro
import { createHmac } from 'node:crypto';
interface Props { template?: string; fields: Record<string, string> }
const { template = 'blog-og', fields } = Astro.props;
// encodeURIComponent leaves !'()* alone; RFC 3986 encodes them.
const enc = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
const path = `/i/${import.meta.env.OG_ACCOUNT_ID}/${template}.png`;
const query = Object.keys(fields).sort().map((k) => `${enc(k)}=${enc(fields[k])}`).join('&');
const s = createHmac('sha256', import.meta.env.OG_SIGNING_SECRET).update(`${path}?${query}`).digest('hex').slice(0, 32);
const src = `https://shareglint.com${path}?${query}&s=${s}`;
---
<meta property="og:image" content={src} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<!-- src/layouts/Post.astro, inside <head> -->
<OgImage fields={{ title: post.data.title, site: 'yourblog.com' }} />
Environment variables without the PUBLIC_ prefix are not exposed to client code, and this component ships no JavaScript to the browser at all.
If you prefer files in dist/, Astro can also generate images in a static endpoint such as src/pages/og/[slug].png.ts with getStaticPaths, using Satori or a package like astro-og-canvas. That keeps everything self-hosted, at the cost of build time and Satori's CSS subset (flexbox only, a limited set of font formats).
No build access at all: one tag for the whole site
On hosted static platforms where you can only edit a theme's <head>, a single tag can describe every page: the service fetches the page you name, reads its title, description and icon, and renders a card.
<meta property="og:image" content="https://shareglint.com/u/acc_….png?url={{ .Permalink | urlquery }}">
That line is Hugo syntax; the same idea works with {{ page.url | absolute_url | url_encode }} in Jekyll or Astro.url in Astro. Only domains you allow in the dashboard get images, so nobody can render cards for other sites on your quota. Snippets for other platforms are on the no-code page.
Checklist before you publish
og:imageis an absolute URL. A relative/og/post.pngis the most common static-site bug; platforms do not resolve it against the page.- One
og:imageper page — themes, SEO plugins and your own partial often each print one. og:image:widthandog:image:heightmatch the file,1200×630.- The first share of a new page may wait about a second for the render; afterwards the image is cached. Paste the URL into Facebook's Sharing Debugger once after deploying to warm it up.