Dynamic OG images: Next, Satori, and the size traps
Your docs site has a different title on every page. Marketing still ships one fixed 1200×630 hero. In Slack, twenty links look identical — nobody can tell which section they are about to open.
You do not need a prettier static PNG. You need a card image generated per URL or title. On the engineering side, the mainstream path is Next opengraph-image plus Satori (next/og). On the ops side, it is template APIs such as Bannerbear or Placid. Both produce images; cost and failure modes diverge hard.
When you actually need dynamic images
Ask first: is one static asset enough?
| Situation | Static is usually enough | Dynamic is worth it |
|---|---|---|
| Marketing home / single landing page | ✅ | Rarely |
| Blog, changelog, docs — different titles per URL | Brand plate works, low recognition | ✅ Title on the card |
| User-generated pages | No | ✅ Or template SaaS |
| Same path, different locale copy | Easy to mix up | ✅ Per-locale image |
| Campaign art that changes daily | Manual is fine | Depends on churn |
Dynamic images buy card recognition. They do not fix wrong tags, crawlers that never see HTML, or stale platform caches. Those still need meta correctness, SSR, and rescrape — covered elsewhere in this series.
Code path vs template SaaS
Code path (Next opengraph-image / Satori / @vercel/og) |
Template SaaS (Bannerbear, Placid, …) | |
|---|---|---|
| Who edits copy | Engineers / CMS / frontmatter | Operators in a visual template |
| When pixels exist | Build-time static generation, or request-time edge/Node render | API render → CDN URL |
| Fonts & CJK | You subset; you own failures | Vendor fonts; plan limits |
| Cost shape | CPU, cold start, dependency weight | Per-image or subscription |
| Versioning | Lives in git; reviewable in PRs | Template lives in a SaaS UI |
| Best for | Programmable layouts (title + brand plate) | Fast design iteration without deploys |
OGKit deliberately does not ship a template designer. The product is diagnosis and gates (rules, scan, CI, extension rescrape) — not a Bannerbear competitor. If you need drag-and-drop templates, use SaaS or export from design tools to absolute URLs. This post is about the code path pitfalls, and how to validate the final URL after generation.
Minimal Next shape (conceptual)
App Router convention: put opengraph-image.tsx (or .jsx / a static .png) in a route segment. Next wires that image into the segment’s Open Graph tags. Under the hood you usually call ImageResponse from next/og; the engine is Satori — it paints a restricted JSX layout to PNG, not a full browser.
Illustrative structure only; names and import paths shift across Next minors — follow the docs you ship against:
// app/blog/[slug]/opengraph-image.tsx (illustrative)
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Article share card";
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await loadPost(slug); // your data layer
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 64,
background: "#0b1120",
color: "#f8fafc",
fontSize: 56,
fontFamily: "YourSubsetFont",
}}
>
<div style={{ display: "flex", fontSize: 28, color: "#94a3b8" }}>
Blog
</div>
<div style={{ display: "flex", fontWeight: 600, lineHeight: 1.15 }}>
{post.title}
</div>
<div style={{ display: "flex", fontSize: 26, color: "#94a3b8" }}>
example.com
</div>
</div>
),
{
...size,
fonts: [
{
name: "YourSubsetFont",
data: await loadFontBytes(),
weight: 600,
style: "normal",
},
],
},
);
}
What matters more than API trivia:
- Layout subset — Satori is flex-first, not full CSS. Absolute positioning, complex grids, and casual web-font loading all break in boring ways.
- Fonts are explicit — no system fallback. Latin can be a small Inter subset; CJK needs a real weight + character plan (next section).
sizematches real pixels — industry default share size is about 1200×630 (~1.91:1). Too small looks soft; see og-image-dimensions-too-small.- Prefer stable generation — static routes should
generateStaticParamsso images are build artifacts; dynamic routes need intentional cache headers and cold-start budget. - Meta is still your job — a dynamic image is only one source of
og:image; absolute HTTPS, title, and URL rules do not go away.
This site’s blog follows the same pattern: opengraph-image + ImageResponse + a pre-subset font, with titles from frontmatter rather than an ops dashboard.
Size, fonts, CJK, emoji
Dynamic images most often die after “it previews on my laptop.” Rough frequency order:
1. Platform byte budgets (WhatsApp is the tight one)
A large PNG can look fine in a browser while chat apps silently drop it. In OGKit’s platform profiles, WhatsApp’s hard cap is about 300KB and the soft cap about 200KB (see platforms and the rule; numbers track the profile data). For chat-heavy products, aim roughly 100–300KB.
Rule: og-image-bytes-exceeds-platform-budget.
Compression, formats, redirects, and Content-Type belong in Open Graph image engineering.
Satori / ImageResponse typically emit PNG. Text-only cards often fit; photographic backgrounds, heavy gradients, and unoptimized bitmaps blow the budget. Options: post-process to JPEG/WebP and host that URL, or simplify the layout.
2. Font loading and CJK subsets
- Full Noto Sans SC is multi‑megabyte — not something you want in an edge function or on every cold render.
- Subset by weights and characters you actually render (e.g. weight 600 only, site copy + punctuation). This blog’s OG CJK subset is tens of kilobytes on purpose.
- When mixing Latin and CJK families, match weight and metrics or titles look “half bold.”
- Missing glyphs often become tofu boxes or silent omissions. English titles pass CI; Chinese titles fail in production.
3. Emoji
Emoji are not ordinary glyphs: you need a color emoji font or pre-rasterized art. Satori’s default stack is limited. “🚀 Launch” turning into a box on the card is a known trap. Drop the emoji, swap in an SVG icon node, or pre-render a static asset that already contains them.
4. Dependencies and cold start
The next/og stack pulls resvg-style native work and font decoding. Edge runtimes have size and CPU budgets; Node is wider but cold start still hurts the first social scrape. Prefer build-time images for routes you can list ahead of time.
5. Contrast
Dark plate + light type survives thumbnail compression better than low-contrast gradients. LinkedIn and Slack small previews make weak contrast look “broken” even when the protocol is fine.
URL stability and cache
Correct generation does not mean correct shares:
- Platforms cache the first successful image — especially when the URL path does not change.
- Unstable query strings or CDNs that return different bytes for the same path make debugging miserable.
- Title changed, image bytes changed,
og:imagepath unchanged → you need an official rescrape, not another browser hard-refresh.
Practical defaults:
- Keep the OG image path stable per article (framework default
/…/opengraph-imageis usually fine). - After content or template changes, rescrape the platforms you care about; series post: cache and rescrape.
- Avoid one-off signed URLs as
og:imageunless you accept permanent cache skew or you version the path deliberately.
Ship checklist
- Every route that needs a distinct card has
opengraph-image(or an equivalent absolute HTTPS image URL) - Output about 1200×630;
contentTypematches the real bytes - Sample real English and CJK titles — no missing glyphs, no tofu
- Final payload fits the tightest relevant budget (chat: under ~300KB)
- Long titles have a font-size or wrapping strategy so the point of the card is not cropped away
- Emoji / special symbols have a policy (strip, replace with icons, or accept boxes)
- Social crawler UA gets 200 on the image in production — no auth wall, no redirect chains (og-image-redirects, og-image-forbidden)
- After copy or template changes, rescrape Meta / LinkedIn / peers that matter
- (Optional) CI checks image bytes and dimensions on critical URLs so a background texture cannot silently break WhatsApp
After generation: validate the final URL with OGKit
Pixels are mid-pipeline. Platforms fetch the final image URL and page meta — not your local JSX preview.
Paste a live article into the homepage scanner. Rules cover relative paths, HTTPS, redirects, dimensions, and byte budgets. Full machine-readable list: rules. Third-party multi-previews approximate layout; authoritative cache invalidation is still each platform’s official debugger — same honesty as the rest of this product.
OGKit will not drag-and-drop a template for you. It will tell you whether the URL you generated is likely to be dropped by a chat app.
What to read next
- Open Graph image engineering — bytes, formats, redirects dynamic images still must obey
- Cache and rescrape — correct image, stale share
- What Open Graph actually solves — full workflow and tool map
- Why your Open Graph card is blank — five-minute triage
Need a template designer? Use SaaS or design tooling. Need to know whether the final URL survives platform budgets? Use rules and a scan — not another screenshot of your own browser.