Dynamic OG images in Next.js, without fighting satori.
Next.js ships ImageResponse for generating Open Graph images in a route handler. It is free, it runs on both runtimes, and for a headline on a gradient it is enough. This guide is for the point where it stops being enough: real CSS, web fonts, a consistent card across sites — and the alternative is one signed URL returned from generateMetadata, rendered by a real browser and cached at the edge.

blog template with the parameters used in the code below, not a mockup.When is Next.js's built-in ImageResponse enough?
Often. ImageResponse (from next/og) renders JSX to a PNG with satori, runs on the Node and Edge runtimes, and costs nothing beyond your own function time. If your card is a title, a subtitle and a logo, use it — there is no reason to add a dependency. The default opengraph-image.tsx convention looks like this:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', padding: 64, background: '#0b0d12', color: '#fff', fontSize: 56 }}>
{post.title}
</div>,
size,
)
}Where does satori's CSS subset run out?
satori is not a browser. It implements a flexbox-only layout model — no CSS grid, no external stylesheets, a curated list of supported properties — and every font must be fetched and passed in as an ArrayBuffer, emoji included. The card also renders inside your function on every cold start and ships its fonts in your bundle. None of that is a defect; it is the trade for running anywhere. It becomes a cost when you want a card that looks like your site, uses your web fonts, and matches across several properties. A real-browser template renders full CSS server-side, once, and every crawler after the first hits the CDN edge.
How do I set og:image from generateMetadata?
Sign the template parameters with your signing secret in generateMetadata and return the URL under openGraph.images. The secret stays on the server — generateMetadata never runs in the browser — and the crawler fetches the image straight from the API, so your route handler is not involved at all:
// app/blog/[slug]/page.tsx
import { createHmac } from 'node:crypto'
import type { Metadata } from 'next'
const rfc3986 = (s: string) =>
encodeURIComponent(s).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
function signedOgUrl(params: Record<string, string>, secret: string) {
const canonical = Object.entries(params)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, v]) => `${rfc3986(k)}=${rfc3986(v)}`)
.join('&')
const sig = createHmac('sha256', secret).update(canonical).digest('hex')
return `https://api.shotium.com/v1/og-image?${canonical}&sig=${sig}`
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
const image = signedOgUrl({
template: 'blog',
title: post.title,
author: post.author,
site_name: 'roadmap.dev',
tag: post.tag,
uid: process.env.SHOTIUM_UID!,
}, process.env.SHOTIUM_SIGNING_SECRET!)
return {
title: post.title,
openGraph: { title: post.title, images: [{ url: image, width: 1200, height: 630 }] },
twitter: { card: 'summary_large_image', images: [image] },
}
}What if the route runs on the Edge runtime?
node:crypto is unavailable there. Web Crypto is, and it produces the identical hex digest — swap the HMAC line for this and keep everything else:
async function hmacHex(secret: string, message: string) {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
)
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(message))
return Array.from(new Uint8Array(mac), b => b.toString(16).padStart(2, '0')).join('')
}
// const sig = await hmacHex(process.env.SHOTIUM_SIGNING_SECRET!, canonical)Why does og:image "not work on localhost"?
Because Next.js composes relative image paths against metadataBase, and when metadataBase is unset it falls back to http://localhost:3000 in development — so the tag is generated, but it points at a URL no crawler can reach, and the same misconfiguration in production produces a warning at build time. A signed Shotium URL is absolute: metadataBase is never consulted, the tag is identical in dev and prod, and pasting your localhost page into a card validator shows the real image because the validator fetches api.shotium.com, not your machine. You still want metadataBase set for canonical and alternate URLs; it just stops being the thing that breaks your social cards.
Fields and templates
Every parameter is a plain string in the query; template picks the design and uid identifies your account. The sample above is ?template=blog&title=Why%20we%20rewrote%20our%20onboarding%20flow%20twice&author=Maya%20Chen&site_name=roadmap.dev&tag=Product plus uid and sig. Parameter limits and live samples for each design are on /og-templates.
| Template | Parameters |
|---|---|
Blog Post blog | titleauthorsite_nametagavatar_url |
Product product | titlebrandpricedescriptionimage_url |
Podcast Episode podcast | titleshow_nameepisodecover_url |
Event event | titledatelocationorganizer |
Minimal minimal | titlesubtitle |
Signing rules in full — canonical ordering, RFC 3986 encoding, the sig parameter — are in the API reference. Want to see a card with your own text before writing any code? The free generator renders the same five templates with no sign-up.
Frequently asked questions
Does this work with the Pages Router?
Yes. Compute the signed URL in getStaticProps or getServerSideProps — both run only on the server, so the secret never reaches the client — and pass the URL as a prop into a <meta property="og:image"> tag rendered with next/head. The signing code is the same nine lines.
If I edit the post title, does the image update?
Yes. The signature covers every parameter, so a new title produces a new URL and a fresh render on the first crawler fetch. The old URL keeps serving its cached image until the cache expires; nothing references it any more, so nothing breaks.
Is a render billed every time a crawler fetches the image?
No. The first fetch renders and is billed as one credit. Responses carry Cache-Control: public, max-age=86400, immutable, and fetches served from the CDN edge are free — they do not count against quota or rate limits. A post shared a thousand times costs one render.
Can I keep ImageResponse for some pages and use the API for others?
Yes, and it is a sensible split: opengraph-image.tsx for pages where a plain text card is fine, generateMetadata with a signed URL for the ones that need real CSS or a shared template. Next.js uses whichever produces the og:image tag for that route.
Try it on your own pages
Sign-up is GitHub or Google OAuth and comes with 100 free render credits — no card. Your uid and signing secret are issued with your first API key. Failed renders never bill; plans from $15/month on pricing.
Also on the hub: Astro · Nuxt · Screenshot API by language