All articles
Web DevelopmentOctober 13, 2026 6 min read

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.

Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks

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:

  1. Pre-generate at build time for anything static. The App Router will cache the ImageResponse output if the segment is statically rendered.
  2. Read from a replica or edge KV for dynamic routes. The og route shouldn't be hitting your transactional database.
  3. 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 -I the image URL. Is Content-Type: image/png? Is Cache-Control sane?
  • curl the page HTML with a bot UA. Is the og:image tag 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:

  1. Ship your font file explicitly, subsetted, from assets/. Never rely on Satori's fallback.
  2. Wrap ImageResponse in try/catch with a known-good fallback and log the identifier.
  3. Version the image URL with a content hash, cache aggressively, never invalidate.
  4. 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.

#Next.js#App Router#Performance#SEO#React

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project