Dynamic Open Graph images in Next.js
Every page gets its own share image, rendered the first time a crawler asks for it. No image route, no build step, one small function.
The idea
Next.js can generate OG images itself with ImageResponse, but that means a Satori-rendered subset of CSS, cold starts on your own function, and no caching between deploys. A signed URL moves the work to imageapis.com: your page emits a URL, the crawler fetches it, the first fetch renders in a real browser and every later fetch is served from the edge cache.
1. Create a signed base
Once, with your key:
curl -X POST https://api.imageapis.com/v1/signed-bases \
-H 'X-API-Key: YOUR_KEY' -H 'Content-Type: application/json' \
-d '{"name":"blog cards","template":"og"}'
Keep the id (sb_…) and the secret (sbs_…) in your environment as OG_BASE and OG_SECRET. Locking the base to the og template means a leaked secret can only ever render that card.
2. Sign in generateMetadata
// app/blog/[slug]/page.tsx
import { createHmac } from "node:crypto";
function ogUrl(params: Record<string, string>) {
const path = `/s/${process.env.OG_BASE}/og.png`;
const query = new URLSearchParams(params).toString();
const sig = createHmac("sha256", process.env.OG_SECRET!).update(`${path}?${query}`).digest("hex");
return `https://img.imageapis.com${path}?${query}&sig=${sig}`;
}
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const image = ogUrl({ title: post.title, description: post.excerpt, site: "example.com", tag: "Blog", theme: "dark" });
return {
title: post.title,
openGraph: { images: [{ url: image, width: 1200, height: 630 }] },
twitter: { card: "summary_large_image", images: [image] },
};
}
URLSearchParams encodes spaces as + and the signature covers exactly the string you send, so build the URL from the same query you signed.
3. Check it
Paste a page URL into the X card validator or LinkedIn's Post Inspector. The first check renders the card (one image against your plan); the card is then cached for a week at the edge and re-rendered on demand after that.
Your own design
Save a layer template with {{title}} and {{author}} placeholders, pass its id instead of og in the path, and the query parameters become the variables. Change the template and the next fetch of every URL picks it up.