Dynamic OG images without a build step
A build script that runs once per blog post cannot make a card for a user, a signup or an order that does not exist yet. Here is the version of og:image that can.
The build-time approach and its ceiling
The common pattern for OG images is a script that runs before next build or inside a static site generator's build step: loop over every page you know about, render its card, write it to public/og/. It works well for a blog or a docs site, because the set of pages is known in advance and does not change between deploys.
It stops working the moment the image depends on something that only exists at request time: a user who just signed up, a share card for content someone just posted, a certificate issued after a course finished, a ticket for an order placed five seconds ago. There is no build to run those images at, because there is no list of them until they happen.
Dynamic images with a signed URL
The alternative is to make the image URL itself do the work. A signed URL encodes a template id, your data as query variables, and an HMAC signature under a secret only your account knows. Point og:image at it and:
- The first time anyone (a crawler, a browser, a link unfurler) requests that exact URL, ogrender renders it from your saved template and caches the result.
- Every request after that, for 24 hours, is served from cache: no render, no charge, and a
304if the client already has it. - Nobody but you can mint a working URL for your templates, because the signature covers the whole query: append or change a parameter and the signature no longer matches, and the request is
403.
There is no build step, no queue, no server process of yours that has to run on every pageview. Your backend's only job is string concatenation: build the URL with the right variables and sign it, the same way you would build any other link.
Worked example: a signup welcome card
Say a new user signs up and you want their signup announcement, shared to Slack or Discord, to unfurl with a personal card instead of your generic homepage image.
1. Save the template once, with the two things that change as variables:
curl -X POST https://ogrender.dev/v1/templates \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "signup-welcome",
"html": "<div style=\"display:flex;flex-direction:column;justify-content:center;height:100%;
padding:80px;font-family:Inter;background:#0a0a0a;color:#fff\">
<div style=\"font-size:22px;color:#888\">Welcome to Acme</div>
<div style=\"font-size:56px;font-weight:700;margin-top:12px\">{{name}}</div>
<div style=\"font-size:22px;color:#888;margin-top:8px\">Member #{{number}}</div>
</div>"
}'
2. On every signup, your backend builds and signs one URL (Node shown, Python and PHP in the signing docs):
import { createHmac } from "node:crypto";
function welcomeCardUrl(user) {
const variables = { name: user.displayName, number: String(user.id) };
const enc = (s) => encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
const query = Object.keys(variables).sort()
.map((k) => `${enc(k)}=${enc(variables[k])}`)
.join("&");
const sig = createHmac("sha256", process.env.OGRENDER_SIGNING_SECRET)
.update(`tpl_signup_welcome\npng\n${query}`)
.digest("hex");
return `https://ogrender.dev/v1/img/tpl_signup_welcome.png?${query}&sig=${sig}`;
}
3. Drop it straight into the page that announces the new member, no image file ever written by your app:
<meta property="og:image" content="https://ogrender.dev/v1/img/tpl_signup_welcome.png?name=Ada%20Lovelace&number=482&sig=...">
The first unfurl (Slack fetching the link preview, usually within seconds of the message being sent) renders the card. Every unfurl after that, on any platform, for the next 24 hours, is a cache hit: free, and fast enough that a chat client's link-preview timeout never sees a slow response.
What this does not do
A signed URL is still a URL, so it goes wherever a URL goes: an og:image tag, a chat message, an email, a Discord bot's embed. It does not fetch a remote profile photo into the card yet (v2.1); until then, inline an avatar as a data: URI in the request that calls /v1/render directly if you need one, or leave the card to typography and color, which is what the example above does.