You need a dynamic OG image pipeline in Next.js, and the App Router has two honest ways to build one: static metadata exports for fixed cards, and a route handler that renders an image per page. We run the second approach in production on ogimgen.com, and this guide covers both with the exact code patterns we shipped, plus the three traps that actually bite (missing metadataBase, unencoded titles, font fetches that hang).
Method 1: Static metadata — fine until content changes
If every page on your site can share one card, export a metadata object from your layout. The image lives in /public and gets referenced by URL:
import type { Metadata } from 'next'
export const metadata: Metadata = {
openGraph: {
title: 'My Page',
description: 'Page description',
images: [{
url: '/og-image.png',
width: 1200,
height: 630,
}],
},
}
Keep the relative URL and let metadataBase resolve it. We set metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL) in the root layout; do that once and every relative og:image below it becomes absolute. Skip it, and builds fail with a "metadata.metadataBase is not set" error — the strictest way Next.js has told me to stop guessing my own domain.
Static cards die for blogs and docs. The second an article's title changes, the card is wrong, and if you hand-render 50 images someone forgets one. That's where a route handler earns its keep.
Method 2: A /api/og route — one renderer, every page covered
Our dynamic card is a plain route handler at app/api/og/route.tsx. It reads title, subtitle, and template from the query string, renders JSX with satori, and converts the SVG to PNG with sharp:
import { NextRequest } from 'next/server'
import { renderOGImage } from '@/lib/og'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'OG Image Generator'
const subtitle = searchParams.get('subtitle') || ''
const png = await renderOGImage({ title, subtitle }, 1200, 630)
return new Response(png, {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
})
}
Then each page's generateMetadata points at it. On our blog posts we pass the title and description straight into the URL:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = getBlogPost((await params).slug)
return {
openGraph: {
type: 'article',
images: [{
url: `/api/og?title=${encodeURIComponent(post.title)}&subtitle=${encodeURIComponent(post.description)}`,
width: 1200, height: 630,
}],
},
}
}
Font caching is the difference between a card and a 5-second hang
Satori has no system fonts. Our first attempt fetched Inter from Google Fonts on every render; the share-scrape deadline doesn't care about your network luck. We now bundle Inter-Regular.ttf in public/fonts/ and read it from disk — zero network, one hot path.
Extra template fonts still fetch, but through a cache map with a 5-second timeout, and the catch silently falls back to Inter instead of failing the render. Wait around on a cold edge function during a Slack unfurl and you'll feel why.
Cache long, because the URL is your content hash
Different titles produce different URLs, so a year-long immutable cache never serves a stale card. Same title → same URL → cache hit. This is the whole trick of dynamic OG routes: encode the meaning in the query string, not in the file.
Why not the opengraph-image.tsx file convention?
The App Router ships opengraph-image.tsx as a first-class alternative, and it's clean for site-wide templates. We chose the API route instead because the same renderer powers our in-app preview: the checker tool and template gallery call /api/og directly so the card you preview is byte-for-byte the card a scraper downloads. One pipeline, no drift between preview and production.
Testing before the first share
Run the URL through our free OG Checker and confirm the tag pair — og:image alone renders nothing without og:image:width and og:image:height. Then paste the link into Slack and Discord; neither has a public debugger, and both will show you the cached (read: stale) version first.
Check your encodeURIComponent output before blaming cache. A title like "Cats & Dogs: The Report" unencoded breaks the query string at the ampersand, and a quote-heavy subtitle silently truncates. We caught both in testing; the encoded version survives every client.
The setup in four lines
- Set
metadataBaseonce in the root layout - Bundle your font locally — never fetch on render
- Cache the route 31536000; the URL is the cache key
- encodeURIComponent everything that enters the query string
If you'd rather not run a render pipeline at all, our free OG image generator mints the PNG for you — download, drop into /public, done. The dynamic route is there when your content moves faster than your design team.