Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks
Our OpenGraph images looked perfect in local dev and broken in every Slack preview. Here's the postmortem on dynamic `og:image` routes, Satori fonts, caching headers, and the bot UA quirks nobody warns you about.

A client pinged us on a Friday: every link they shared in Slack, LinkedIn, and iMessage was showing a blank white card. The site was fine. The meta tags were fine. The /opengraph-image route returned 200. It still took us the better part of a sprint to figure out why the images were empty, and the fix wasn't one thing — it was five small ones stacked.
This is the breakdown of what actually broke, in the order we found it, with the production patterns we now use on every Next.js App Router project.
The setup that looked correct
We were on Next.js 15, App Router, deploying to Vercel. The route was the recommended file convention: an opengraph-image.tsx colocated with the page, exporting an async default that returned an ImageResponse from next/og.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';
export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<div style={{ display: 'flex', fontSize: 64, background: '#0b0b0f', color: 'white', width: '100%', height: '100%', padding: 80 }}>
{post.title}
</div>
),
{ ...size }
);
}
Local preview at /blog/some-slug/opengraph-image rendered beautifully. The Twitter card validator rendered it. Slack rendered a blank card. LinkedIn rendered a blank card. iMessage rendered a blank card.
That split — some validators happy, some crawlers not — is the clue we missed for too long.
Gotcha 1: fonts are not what you think they are
next/og uses Satori under the hood, which doesn't have access to the browser's font stack. If you write fontFamily: 'Inter' and don't ship the font bytes, Satori falls back to a built-in default. That default often works. Sometimes, when the glyph set doesn't match (emoji, non-Latin, even certain punctuation), Satori silently emits nothing for that run.
One of our post titles contained a curly apostrophe and an en dash. On a 64px headline, missing glyphs can look like a blank image if your layout collapses around them.
The fix is to always ship the font explicitly:
const interSemiBold = fetch(
new URL('../../../assets/Inter-SemiBold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
export default async function Image({ params }: { params: { slug: string } }) {
const [post, fontData] = await Promise.all([
getPost(params.slug),
interSemiBold,
]);
return new ImageResponse(
(/* JSX */),
{
...size,
fonts: [{ name: 'Inter', data: fontData, weight: 600, style: 'normal' }],
}
);
}
Subset the font or pay for it
A full Inter weight is around 300KB. Edge runtimes charge you for cold starts and function size. We now subset fonts down to Latin + the punctuation we actually use with glyphhanger or fonttools, dropping that payload under 40KB. Build it once, commit the subset, done.
Gotcha 2: Flex is not optional
Satori requires display: 'flex' on any element with more than one child. The error only shows up at render time, inside the edge function, and if your error handling swallows it, you get a 200 response with a broken PNG payload — which crawlers interpret as "no image here."
We now wrap the image generation in a try/catch that returns a known-good fallback instead of letting a thrown Satori error degrade silently:
try {
return new ImageResponse(<Card post={post} fontData={fontData} />, options);
} catch (err) {
console.error('og image failed', { slug: params.slug, err });
return new ImageResponse(<Fallback />, options);
}
And we log the slug. Without the slug, you'll never reproduce the failure.
Gotcha 3: caching headers lie to social crawlers
This was the real culprit for us. The ImageResponse constructor sets reasonable defaults, but when you proxy through a CDN or add middleware, headers get rewritten. Slack's unfurler, in particular, is aggressive about respecting (and sometimes misinterpreting) Cache-Control: private or short max-age values. If it gets a stale 304 without a body on first fetch, it caches the empty result for days.
Set them explicitly:
return new ImageResponse(<Card />, {
...size,
headers: {
'Cache-Control': 'public, immutable, no-transform, max-age=31536000',
'Content-Type': 'image/png',
},
});
immutable matters. no-transform matters if you're behind Cloudflare's Polish, which can transcode PNG to WebP and break crawlers expecting PNG.
What about content updates?
If the underlying post changes, the image needs to change too. We don't invalidate — we version. The meta tag points at a URL with a content hash:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
const ogVersion = post.updatedAt.getTime();
return {
openGraph: {
images: [{
url: `/blog/${params.slug}/opengraph-image?v=${ogVersion}`,
width: 1200,
height: 630,
}],
},
};
}
The route ignores the v param, but crawlers treat it as a new URL. You get instant invalidation and permanent caching at the same time.
Gotcha 4: dynamic data in an edge function
If getPost hits your primary database over a long round trip, your og route becomes the slowest thing on the site. Slack's unfurler times out at around 10 seconds. LinkedIn is less patient. We've seen crawlers abandon requests after 3 seconds and never try again for that URL.
Three patterns we use now:
- Pre-generate at build time for anything static. The App Router will cache the
ImageResponseoutput if the segment is statically rendered. - Read from a replica or edge KV for dynamic routes. The og route shouldn't be hitting your transactional database.
- Keep the payload small. Fetch only
{ title, author, category }— not the full post body.
If you're using server-only utilities, make sure they're actually edge-compatible. A single fs or crypto import from Node can force the route to the Node runtime, where cold starts can push you over the crawler timeout.
Gotcha 5: the crawler user agents
Some services won't follow redirects. Some won't accept cookies. Some strictly want og:image and ignore og:image:url. A few require absolute URLs with the protocol — relative paths fail silently.
Always emit absolute URLs:
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com';
return {
openGraph: {
images: [{
url: `${baseUrl}/blog/${params.slug}/opengraph-image?v=${ogVersion}`,
width: 1200,
height: 630,
alt: post.title,
}],
},
};
And test with the actual bots, not just the validators. We keep a short list of User-Agent strings we curl against staging before every release:
curl -A "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)" \
https://staging.example.com/blog/some-slug -I
If the HTML response doesn't include a resolvable, absolute og:image tag, you're not going to render.
A debugging checklist that actually works
When an og image fails in production, we walk this list before touching code:
- Open the image URL directly in a browser. Does it render? If not, it's the route.
curl -Ithe image URL. IsContent-Type: image/png? IsCache-Controlsane?curlthe page HTML with a bot UA. Is theog:imagetag absolute and resolvable?- Check the edge function logs for Satori errors. Look for the slug.
- Run the URL through the Facebook Sharing Debugger — it's the most honest validator and it tells you exactly which tag it read.
- Clear Slack's cache by appending a dummy query param to the shared link.
That last point saves hours. Slack caches aggressively per URL. If you've shared a broken link once, the fix won't appear until the cache expires or the URL changes.
Where we'd start on a new project
If you're setting this up fresh, do these four things on day one and you'll avoid most of what we hit:
- Ship your font file explicitly, subsetted, from
assets/. Never rely on Satori's fallback. - Wrap
ImageResponsein try/catch with a known-good fallback and log the identifier. - Version the image URL with a content hash, cache aggressively, never invalidate.
- Keep the data fetch under 500ms and the route on the edge runtime.
And if you're debugging a live outage, start with the Facebook Sharing Debugger and curl -A. The validators built into Twitter and LinkedIn lie by omission. If you want us to audit a broken social preview setup, that's part of what we do in our web development engagements — usually a half-day of work to catch all five of these at once.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Request Memoization in the App Router: The Hidden Cost of Fetching the Same Thing Twice
Next.js memoizes fetch() within a request, but the rules are stricter than most teams realize. Here's what actually dedupes, what silently doesn't, and how to stop paying for the same database call four times per page.

Streaming Suspense Boundaries: Why Your TTFB Looks Great and Your LCP Still Sucks
Streaming SSR in the Next.js App Router makes TTFB look amazing. But if your Suspense boundaries are drawn wrong, LCP gets worse, not better. Here's how we diagnosed and fixed it on a real site.

React 19's `use` Hook in Server Components: The Waterfall We Didn't See Coming
The `use` hook promised cleaner data fetching in React 19. Then our TTFB doubled. Here's what went wrong, how we spotted it, and the patterns that actually work in Next.js App Router.
