Next.js 16.2 Dynamic OG Image 404 in Production Fix

Next.js 16.2 dynamic OG image returning 404 in production but working in dev? Here is why ImageResponse breaks on Vercel and the runtime fix that ships.
Next.jsOpen GraphVercel
May 9, 20266 min read1059 words

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:

  1. The default runtime flipped to nodejs and the WebAssembly asset is not copied. The node:fs resolver inside ImageResponse looks for og-image.wasm next to the function bundle. Vercel's build pipeline only copies it when the route is on the edge runtime. Without runtime: 'edge' you get a silent 404 from the asset loader and ImageResponse returns a 404 response instead of the image.
  2. generateImageMetadata inside opengraph-image.tsx runs 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 with next/font/local and the OG image uses the Google Fonts API instead, Turbopack drops the reference and the route handler throws on cold start.
  3. A hand-rolled route.ts returns a Response with a stream body, but the Vercel function runtime now requires a content-length header for OG. If you wrote the route by hand instead of using opengraph-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.

Back to blogStart a project