The Problem
I shipped a Next.js 16.2 marketing site for a client last Friday. The OG image route at app/og/route.tsx worked in next dev, worked in next build && next start on my laptop, and worked the first time I opened the deployed URL on Vercel. Twenty minutes later every social share preview showed a broken image. The route returned a 500 with the cryptic message Failed to fetch resource and no stack trace in the function logs. Refreshing the same URL sometimes worked, sometimes did not.
If you upgraded to Next.js 16 and your ImageResponse route from next/og is throwing intermittent 500s in production while running fine locally, and the logs say something like fetch failed or An error occurred while loading the font, this is the pattern. It hits hardest when you fetch a custom font from Google Fonts or a public URL.
Why It Happens
ImageResponse runs on the Edge runtime and uses Satori under the hood to rasterise JSX into a PNG. Satori needs the actual font binary, not a CSS link. So most teams write code like this in their route:
const fontData = await fetch(
'https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap'
).then((res) => res.text());
That worked in Next.js 15 because the Edge function had a generous cold-start budget and Google's CDN responded fast enough. Three things changed in Next.js 16 that broke it:
- Edge runtime fetch caching changed. Next.js 16 removed the implicit
force-cacheonfetchinside Route Handlers. Every cold start now re-fetches the font, and the cold start budget on most Edge regions is tighter than the round trip to Google Fonts plus the binary download. - Google Fonts rate-limits per-region edge IPs. Vercel's edge runs from hundreds of POPs. When a popular site cold-starts a thousand functions at once, Google sees the burst from a small IP range and throttles. The failed
fetchdoes not throw, it returns a 200 with HTML that Satori cannot parse. ImageResponsedoes not bubble the underlying fetch failure. It catches the error inside Satori and rewraps it as a generic 500, so the real upstream cause is invisible.
The next dev server uses Node.js runtime and a local font cache, which is why it never reproduces. next start on your laptop is on a fast residential IP that Google does not throttle.
The Fix
There are three parts: stop fetching Google Fonts at request time, pin to the Node runtime if you need filesystem access, and add a real error boundary so future failures are visible.
Step 1: Ship the font as a static asset in your repo. Download the .woff or .ttf from Google Fonts (or fontsource) once and commit it under app/og/. Then read it from the build:
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export const runtime = 'nodejs';
export const dynamic = 'force-static';
export const revalidate = 86400;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title')?.slice(0, 120) ?? 'qasimcode.com';
const fontPath = join(process.cwd(), 'app', 'og', 'Inter-SemiBold.ttf');
const fontData = await readFile(fontPath);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0a0a0a',
color: '#00ff88',
fontFamily: 'Inter',
fontSize: 64,
padding: 80,
textAlign: 'center',
}}
>
{title}
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 600,
},
],
}
);
}
Three lines do the heavy lifting:
runtime = 'nodejs'switches off Edge. Node has a stable filesystem and no cold-start font fetch.dynamic = 'force-static'caches the rendered PNG keyed by URL search params. Second request for the same?title=is served from cache.revalidate = 86400revalidates once a day so you can update the design without a full deploy.
Step 2: If you must stay on Edge, fetch from your own origin. Some teams keep the route on Edge for latency. Host the font on the same domain so the request never leaves Vercel's network:
export const runtime = 'edge';
const fontUrl = new URL('/fonts/Inter-SemiBold.ttf', request.url);
const fontData = await fetch(fontUrl, { cache: 'force-cache' }).then((r) =>
r.arrayBuffer()
);
Pass cache: 'force-cache' explicitly because Next.js 16 no longer applies it for you. The font sits in /public/fonts/ and ships with your deployment. No third-party DNS in the hot path.
Step 3: Make failures visible. Wrap the body in try/catch and return a debug image with the error text:
try {
// ... ImageResponse code
} catch (err) {
console.error('OG render failed', { url: request.url, err });
return new ImageResponse(
<div style={{ display: 'flex', padding: 40, background: '#330000', color: '#ff6666', fontSize: 32 }}>
OG render failed: {String(err).slice(0, 200)}
</div>,
{ width: 1200, height: 630 }
);
}
A broken-but-readable image in the preview is better than a transparent placeholder, and the structured log gives you the failing URL for repro. The official ImageResponse docs cover the full API surface.
The Lesson
ImageResponse is a contract between Satori, the runtime, and whatever resources your JSX references. Next.js 16 changed two halves of that contract (fetch caching defaults and Edge cold-start behaviour), so any OG route that pulled fonts from Google at request time is going to flap. Ship the font as a static asset, pin to Node, and cache the rendered PNG. If you must stay on Edge, host the font on your own origin with explicit force-cache.
If your OG previews are flapping in production after a Next.js 16 upgrade and you want them locked down, that is the kind of work I do. See my services. For another Next.js 16 production-only bug I covered, see Speed Insights no data Next.js 16.