Dynamic Open Graph images in Next.js
Every post needs its own social card, and hand-designing them does not scale. Here are the three approaches that actually work in the App Router, and when each one is the right call.
1. next/og — free, and runs on the edge
Next ships ImageResponse, which renders JSX to a PNG. It costs nothing and needs no third party, so try it first. The catch is that it supports a subset of CSS, fonts must be fetched and passed in as buffers, and debugging a layout you cannot open in a browser gets tedious. It also cannot screenshot a real page — it only draws what you describe.
Use it when your cards are simple text-on-a-background and you want zero dependencies.
2. A signed render URL — no server round-trip
Put the image URL straight in your metadata and let it render on demand. Nothing is generated at build time, nothing is stored by you, and the URL is safe in public markup because the signature covers the parameters.
// app/blog/[slug]/page.js
import crypto from 'node:crypto';
// The signing secret stays on the server. The signature covers the exact
// parameters, so a signed URL cannot be edited into rendering something else —
// which is why it is safe to put in public markup.
function signedOgUrl(params) {
const q = { token: process.env.SCREENMINT_KEY_ID, ...params };
const canonical = Object.keys(q)
.sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(q[k])}`)
.join('&');
const sig = crypto
.createHmac('sha256', process.env.SCREENMINT_SIGNING_SECRET)
.update(canonical)
.digest('hex');
return `https://api.screenmint.dev/v1/og?${canonical}&sig=${sig}`;
}
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
openGraph: {
images: [{
url: signedOgUrl({ title: post.title, site_name: 'My Blog' }),
width: 1200,
height: 630,
}],
},
};
}Identical parameters return a cached image in milliseconds and do not consume quota, so a post that gets shared a thousand times still costs one render.
3. Screenshot the page itself
Sometimes the best preview of a page is the page. Swap the OG parameters for a URL and you get a real capture rather than a generated card.
// Render the page itself as its own preview image.
// Useful for dashboards, changelogs and docs, where the page IS the story.
const url = signedOgUrl({
url: `https://example.com/blog/${slug}`,
width: 1200,
height: 630,
});Getting the two values
SCREENMINT_KEY_ID is the key’s id, shown as the signing token on the API keys page, and SCREENMINT_SIGNING_SECRET is shown once when you create the key. The id is public by design; the secret is not, so keep it in an environment variable and never in client code.
Check your work
Paste a URL into a social debugger before you ship — a card that fails to render looks identical to one you never added. You can try the parameters without any account in the free playground, then copy the ones you like into the code above.