OG images for static sites: Hugo, Jekyll and Astro

Per-page og:image on a static site without a server: build-time generation, signed image URLs computed in Hugo, Jekyll or Astro templates, or one auto tag.

Published October 24, 2026 · 5 min read

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:image is an absolute URL. A relative /og/post.png is the most common static-site bug; platforms do not resolve it against the page.
  • One og:image per page — themes, SEO plugins and your own partial often each print one.
  • og:image:width and og:image:height match 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.

FAQ

Can a static site have dynamic OG images?

Yes. Either generate the images during the build and ship them as files, or put a URL in og:image that an image service renders on its first request. In both cases the HTML stays static; only the image is produced per page.

How does Hugo set og:image?

Hugo's embedded Open Graph template, called with {{ partial "opengraph.html" . }} since v0.146, reads the images front matter parameter (or a page resource named like feature, cover or thumbnail). If neither exists you can print your own og:image tag instead.

How does Jekyll set og:image?

The jekyll-seo-tag plugin prints og:image from the image key in a page's front matter. A small plugin can fill that key for every post during the build, so you do not have to add it by hand.

Is it safe to compute a signed image URL during the build?

Yes, as long as the signing secret is an environment variable of the build and never written into a page or a client-side script. The published HTML contains only the signature, which works for that exact URL and nothing else.