Dynamic og:images for static sites

Links shared on X, LinkedIn, Slack or Discord get far more clicks with a proper preview card. Static sites usually ship one generic og:image for every page, because generating images means a server function (@vercel/og, Satori) or a slow build step with headless Chrome. A signed image URL avoids both. Your build writes a URL into the meta tag, and the card is rendered the first time a crawler fetches it.

How it works

  1. Get a free API key (100 renders, no signup). GET /v1/account returns its public key_id.
  2. For each page, build https://shotmint.creasework.com/og/KEY_ID.png?title=…&subtitle=…&site=…&sig=…. The signature locks the text, so nobody can render other cards on your credits.
  3. Put the URL in og:image and twitter:image. The first fetch renders a 1200×630 PNG in real Chromium (1 credit). Repeats within 24h are served from cache for free, with Cache-Control: public, max-age=86400.
<meta property="og:image" content="https://shotmint.creasework.com/og/KEY_ID.png?title=Why%20we%20moved%20to%20Astro&site=blog.example.com&sig=...">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://shotmint.creasework.com/og/KEY_ID.png?title=Why%20we%20moved%20to%20Astro&site=blog.example.com&sig=...">

Want to see the cards first? The OG image generator previews all five themes (indigo, dark, light, sunset, mint) and makes signed tags one page at a time.

Sign URLs at build time (no API call)

The signature is a plain HMAC: HMAC-SHA256 with your API key over title, subtitle, site and theme joined by newlines (empty string for unset fields, theme defaults to indigo), hex, first 32 characters. Keep the key in a build-time environment variable. Only the key_id ends up in your HTML.

Node.js (Next.js, Astro, Eleventy, Gatsby)

import crypto from "node:crypto";

export function ogImage({ title = "", subtitle = "", site = "", theme = "indigo" }) {
  const sig = crypto.createHmac("sha256", process.env.SHOTMINT_KEY)
    .update([title, subtitle, site, theme].join("\n")).digest("hex").slice(0, 32);
  const q = new URLSearchParams();
  if (title) q.set("title", title);
  if (subtitle) q.set("subtitle", subtitle);
  if (site) q.set("site", site);
  if (theme !== "indigo") q.set("theme", theme);
  q.set("sig", sig);
  return "https://shotmint.creasework.com/og/" + process.env.SHOTMINT_KEY_ID + ".png?" + q;
}

ogImage({ title: "Why we moved to Astro", subtitle: "Faster builds, less JS", site: "blog.example.com" });

Next.js App Router: return it from generateMetadata: openGraph: { images: [{ url: ogImage({ title: post.title, site: "example.com" }), width: 1200, height: 630 }] }. Because the image is hosted elsewhere, you don't need an opengraph-image.tsx route or the edge runtime, and it works with output: "export".

Astro: call it in your layout's frontmatter (const og = ogImage({ title: Astro.props.title, site: Astro.site.host })) and write <meta property="og:image" content={og} />. Use a non-PUBLIC_ env var so the key stays out of client bundles.

Python (Pelican, MkDocs, Django, any generator)

import hashlib, hmac, os
from urllib.parse import urlencode, quote

def og_image(title="", subtitle="", site="", theme="indigo"):
    msg = "\n".join([title, subtitle, site, theme])
    sig = hmac.new(os.environ["SHOTMINT_KEY"].encode(), msg.encode(), hashlib.sha256).hexdigest()[:32]
    q = {k: v for k, v in [("title", title), ("subtitle", subtitle), ("site", site)] if v}
    if theme != "indigo":
        q["theme"] = theme
    q["sig"] = sig
    return "https://shotmint.creasework.com/og/" + os.environ["SHOTMINT_KEY_ID"] + ".png?" + urlencode(q, quote_via=quote)

og_image(title="Why we moved to Astro", site="blog.example.com")

Hugo or Jekyll? Generate a small JSON map of page path → og:image URL with the Python or Node function in a pre-build step, then read it in your template (site.Data in Hugo, site.data in Jekyll).

Tips

What it costs

One new card = one credit, so a 300-post blog costs 300 credits once. Each card is rendered again at most once a day, and only when a crawler actually asks for it. €5 buys 2,000 credits, credits never expire, and there's no subscription. A failed render is refunded automatically.

Try it with 100 free renders

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire).

Get a free API key Full API reference