# 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 2026-10-24 · https://shareglint.com/en/guides/og-images-static-sites-hugo-jekyll-astro

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](https://shareglint.com/en/docs#signed)). 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.

```go-html-template
{{/* 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">
```

```toml
# 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](https://gohugo.io/configuration/security/) 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](https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md) 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:

```ruby
# _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:

```astro
---
// 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" />
```

```astro
<!-- 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](https://docs.astro.build/en/guides/environment-variables/), 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](https://github.com/delucis/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.

```html
<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](https://shareglint.com/en/og-images-without-code).

## 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](https://developers.facebook.com/tools/debug/) 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.
