The Problem
I shipped a Next.js 16.2 SaaS landing page with a dynamic Open Graph image generated through opengraph-image.tsx segment files. The OG generator worked perfectly in next dev, the previews were sharp, the Vercel preview deployments showed correct images on Twitter and Slack, and then production returned 404 for every OG image URL the moment the canary main branch promoted. Other route handlers were fine. Only OG.
If you deployed to Vercel (or any Edge runtime) and your dynamic OG image works locally, works in next dev, and even renders in next build && next start locally, but returns a 404 in production as soon as a social bot fetches it, this is the bug pattern. I ran into this on a client project this week and have seen it three more times since on Slack. The fix is small but the cause is annoying enough that I wrote it down.
Why It Happens
Next.js 16.2 changed how ImageResponse is bundled. Previously next/og lived inside the standard server bundle. In 16.2, when runtime: 'edge' is omitted (the new default is 'nodejs'), the OG route handler is split into a separate function bundle that gets the WebAssembly module for Resvg as an external asset.
Three things break on production:
- The default runtime flipped to
nodejsand the WebAssembly asset is not copied. Thenode:fsresolver insideImageResponselooks forog-image.wasmnext to the function bundle. Vercel's build pipeline only copies it when the route is on theedgeruntime. Withoutruntime: 'edge'you get a silent 404 from the asset loader andImageResponsereturns a 404 response instead of the image. generateImageMetadatainsideopengraph-image.tsxruns at build time, but the produced segment file references a hashed asset path that does not exist after Turbopack tree-shakes unused fonts. If you imported a font withnext/font/localand the OG image uses the Google Fonts API instead, Turbopack drops the reference and the route handler throws on cold start.- A hand-rolled
route.tsreturns aResponsewith a stream body, but the Vercel function runtime now requires acontent-lengthheader for OG. If you wrote the route by hand instead of usingopengraph-image.tsx, your stream is fine in dev (Node passes through) and broken in prod where the edge runtime needs the byte length up front.
The 16.2 release note for "OG Image bundling improvements" understated this. It is not just a bundling improvement, it is a runtime flip that quietly breaks every existing OG route until you opt in.
The Fix
Step 1: Force the edge runtime on every OG route. This is the single most important change.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'Article preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug);
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0a',
color: '#00ff88',
fontSize: 64,
fontFamily: '"DM Sans"',
}}
>
{post.title}
</div>
),
{
...size,
fonts: [
{
name: 'DM Sans',
data: await fetch(new URL('./dm-sans.ttf', import.meta.url)).then((r) => r.arrayBuffer()),
style: 'normal',
},
],
},
);
}
The runtime = 'edge' export tells Next.js to bundle the WebAssembly Resvg module alongside the function. Without it, you ship a Node bundle that cannot find the asset and the route returns 404 with no log line.
Step 2: Co-locate fonts as bundled assets. Do not fetch fonts from https://fonts.googleapis.com at runtime. The Edge runtime cold-starts faster when fonts are bundled, and Google Fonts has caching layers that occasionally return non-binary responses to the OG generator. Drop the .ttf next to the route file and use import.meta.url to resolve the asset path relative to the bundled function.
Step 3: If you cannot move to edge, write OG ahead of time. For static blog routes, generate the PNG at build time and ship it as a public asset. This is the bulletproof pattern when the runtime story is fragile:
// scripts/og-build.ts
import { ImageResponse } from 'next/og';
import { writeFileSync, mkdirSync } from 'node:fs';
import { posts } from '../lib/content';
mkdirSync('public/og', { recursive: true });
for (const post of posts) {
const response = new ImageResponse(
<div style={{ display: 'flex', background: '#0a0a0a', color: '#00ff88', fontSize: 64 }}>
{post.title}
</div>,
{ width: 1200, height: 630 }
);
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(`public/og/${post.slug}.png`, buffer);
}
Then point metadata.openGraph.images at /og/[slug].png in generateMetadata and the social bot fetches a static file. Zero runtime cost, zero cold-start risk.
Step 4: Verify with the real social bots. Open the post URL in opengraph.xyz or use the LinkedIn Post Inspector. The "Preview Image" must load, not 404. Watch the network tab. A 404 from /_next/og/... means the runtime issue. A 502 means the WebAssembly init failed and the function timed out, which is usually a fonts payload too large for the edge runtime's 4MB limit.
The Next.js Open Graph metadata documentation covers the full file convention and the runtime constraints if you want to dig deeper.
The Lesson
Dynamic OG images that work in dev and 404 in production are almost always a runtime mismatch in Next.js 16.2. Add export const runtime = 'edge' to every opengraph-image.tsx, bundle fonts as local assets, and prefer static PNG generation when the route does not need to be dynamic per request. Test against real social bots, not just curl, because cache layers behave differently for the Twitter and Slack User-Agents.
If your social previews are silently broken and you need a Next.js 16.2 deploy audit, this is the kind of fix-it work I take on, see my services. For another deploy-only bug pattern I wrote up, see Speed Insights showing no data on Next.js 16.