Next.js OG Images: opengraph-image.tsx vs a Route Handler
Dynamic OG images in Next.js 16: opengraph-image.tsx, the tags it emits, measured static vs per-request timings, fonts, and when a screenshot route wins.
Next.js has two ways to give a page a dynamic OG image. The built-in one is a file: put opengraph-image.tsx next to page.tsx, return an ImageResponse, and Next.js serves a PNG and writes the meta tags for you. The other is a normal route handler that returns your card as HTML, plus a signed screenshot URL in generateMetadata, so a real browser renders the card with your full CSS. The file convention is the right default for simple flexbox cards; the route handler is the answer when the card needs CSS that Satori does not support.
The minimal file convention version, which we built and ran (it served a 1200×630 PNG):
// app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og'; import { getPost } from '@/lib/posts'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; export const alt = 'Article card'; export default async function Image({ params }: { params: Promise<{ slug: string }> }) { const post = await getPost((await params).slug); return new ImageResponse( <div style={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', padding: 72, background: '#0f172a', color: '#f8fafc', fontSize: 64 }}> {post?.title ?? 'Not found'} </div>, { ...size }, ); }
This guide covers what that file actually produces (we inspected the HTML and the image responses), the static versus per-request choice with timings, fonts, and the route-handler alternative. For a full screenshot-based setup with expiry handling and cache busting, the guide to generating OG images from HTML is the companion to this page.
Tested on 2026-10-18 with
[email protected](which bundles@vercel/og1.0.1),[email protected],[email protected]and Node 22.18, usingnext buildandnext starton macOS, Apple M1 Pro. Timings are from that local server, not from Vercel or any serverless platform. The signed screenshot URL was generated and inspected but not fetched; no request went to the RenderScreenshot API.
What opengraph-image.tsx emits
We built a blog route with one opengraph-image.tsx and no other image metadata, then read the HTML from next start. This is the full set of image tags Next.js wrote for /blog/hello-world:
<meta property="og:image" content="https://example.com/blog/hello-world/opengraph-image?5f573540e98804dc"/> <meta property="og:image:type" content="image/png"/> <meta property="og:image:width" content="1200"/> <meta property="og:image:height" content="630"/> <meta property="og:image:alt" content="Article card"/> <meta name="twitter:card" content="summary_large_image"/> <meta name="twitter:image" content="https://example.com/blog/hello-world/opengraph-image?5f573540e98804dc"/> <meta name="twitter:image:alt" content="Article card"/> <meta name="twitter:image:type" content="image/png"/> <meta name="twitter:image:width" content="1200"/> <meta name="twitter:image:height" content="630"/>
Four things to notice:
- The URL is absolute because the root layout sets
metadataBase: new URL('https://example.com'). We removed it to see what happens:next buildprinted a warning thatmetadataBasewas not set and that it would usehttp://localhost:3000, and a dynamic page served bynext starton port 4518 wrotehttp://localhost:4518/...intoog:image. A localhost image URL is a classic reason an OG image does not show. - Width, height and type come from the exports.
sizeandcontentTypebecomeog:image:width,og:image:heightandog:image:type. Keepsizeequal to what you pass toImageResponse, or the tags will describe a different image from the one served. - The X tags come for free. With no
twitter-image.tsx, Next.js still wrotetwitter:cardassummary_large_imageand pointedtwitter:imageat the same file. You only needtwitter-image.tsxfor a different X image, for example an exact 2:1 shape; the Twitter card size guide shows when that matters. - The
altexport is static. Every post got "Article card". For a per-post description, either write a better generic alt, or setopenGraph.imagesyourself ingenerateMetadata(as in the route-handler option below) where you have the post data.
Next.js also appended a hash as a query string (?5f573540e98804dc) to the image URL. Treat the URL as opaque and let Next.js generate it; do not build it by hand in other code.
Static or per request: what we measured
The same file behaves differently depending on whether Next.js knows the slugs at build time.
With generateStaticParams in the image file, the build prerendered one PNG per slug. The output listed /blog/hello-world/opengraph-image as an SSG route, and the image bytes were written to .next/server/app/blog/hello-world/opengraph-image.body (38,111 bytes for a short title, 56,331 bytes for a three-line title).
// Add to app/blog/[slug]/opengraph-image.tsx to prerender one image per post at build time. export async function generateStaticParams() { return (await allPosts()).map((p) => ({ slug: p.slug })); }
Without it, the build listed the image route as dynamic (ƒ), and every request ran the function and Satori again.
We timed both with curl against next start, restarting the server three times. "First" is the first request for the image after the server started; "warm" is five requests after that:
| Mode | First request after start | Warm requests (15 total) |
|---|---|---|
Dynamic (no generateStaticParams) |
156, 169, 169 ms | 21 to 42 ms |
| Prerendered at build | 7 to 20 ms | not measured separately |
Method: Next.js 16.4.2 production build, local next start on an Apple M1 Pro, 3 server restarts, curl -w '%{time_total}' on localhost, 2026-10-18. The script is in the tested code below. These numbers include no network and no serverless start; on a platform that starts a fresh function for the first request, add that platform's cold-start time to the first row.
What to take from this:
- Prerender when you can. A blog with known slugs gains nothing from rendering the card per request.
- Per-request rendering is fine on a warm server, but the first crawler to hit a cold function pays the start-up plus the render, and crawlers have short time budgets.
- Check the cache headers you actually get. Under
next start, both the prerendered and the dynamic image answered withCache-Control: public, max-age=0, must-revalidate. That means every crawler and CDN fetch goes back to the server to revalidate. Your hosting platform may set different headers; check withcurl -Ion the deployed URL.
Fonts in ImageResponse
ImageResponse does not read your CSS or next/font. You pass font files as bytes. In a Node.js route, read the file from disk:
import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; const font = await readFile(join(process.cwd(), 'assets/Geist-Regular.ttf')); return new ImageResponse(jsx, { ...size, fonts: [{ name: 'Geist', data: font, style: 'normal', weight: 400 }], });
Then set fontFamily: 'Geist' in the inline styles. We tested this with a TTF file. Points to know:
- Ship the font file with the app and read it with a path from
process.cwd(). A path relative to the source file can break after bundling. - Use TTF, OTF or WOFF. The Satori README lists these formats; WOFF2, the format most web font services serve, is not among them. We did not test WOFF2 ourselves.
- Each weight is a separate file. Bold text needs a bold font entry; Satori does not synthesise weights the way a browser can.
- Missing glyphs render as boxes. Emoji and CJK text need fonts that contain them. Our Vercel OG comparison shows a test where emoji and Japanese came out as boxes without an error.
Where opengraph-image.tsx runs out
ImageResponse uses Satori, which implements flexbox and a subset of CSS. It does not run a browser. Our React component to image tests show which React and CSS features survive that. The Vercel OG alternative page has our test of six templates in Satori and in Chromium: CSS grid, backdrop filters and missing-font text came out wrong in Satori without any error. If your card is a flexbox layout with one or two fonts, none of this matters. If you want to reuse your site's components, stylesheet, web fonts and grid layouts, you need a browser.
The route handler alternative
The second option keeps the card as ordinary HTML and lets a browser render it. In Next.js that is two pieces: a route handler that returns the card as HTML, and generateMetadata that puts a signed RenderScreenshot URL for that card into the page's metadata. The crawler fetches the signed URL and gets a 1200×630 PNG rendered with the og_card preset.
The card route. It takes a slug, looks up the post, and escapes every value, so a forged URL can only render a real post's card:
// app/og/[slug]/route.ts import { getPost } from '@/lib/posts'; const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') .replaceAll('"', '"').replaceAll("'", '''); export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) { const post = await getPost((await params).slug); if (!post) return new Response('Not found', { status: 404 }); const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"> <style> html, body { margin: 0; width: 1200px; height: 630px; } body { display: grid; grid-template-rows: auto 1fr auto; padding: 72px; box-sizing: border-box; background: #0f172a; color: #f8fafc; font: 400 28px/1.2 system-ui, sans-serif; } .section { color: #7dd3fc; } h1 { margin: 0; align-self: center; font-size: 64px; line-height: 1.1; text-wrap: balance; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; } .site { color: #cbd5e1; } </style></head> <body><div class="section">${esc(post.section)}</div><h1>${esc(post.title)}</h1><div class="site">example.com</div></body></html>`; return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); }
The page metadata. signedScreenshotUrl is the Node.js signer from the signed URL documentation, saved as lib/sign.ts:
// app/rs/[slug]/page.tsx (metadata part) import type { Metadata } from 'next'; import { getPost } from '@/lib/posts'; import { signedScreenshotUrl } from '@/lib/sign'; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const post = await getPost((await params).slug); if (!post) return {}; const card = `https://example.com/og/${post.slug}?v=${post.updatedAt.getTime()}`; const image = signedScreenshotUrl( { url: card, preset: 'og_card', cache_available: 'true', cache_ttl: 2592000 }, { keyId: process.env.RS_PUBLIC_KEY_ID!, // rs_pub_... secret: process.env.RS_SECRET_KEY!, // rs_secret_..., server only expiresAt: new Date(Date.now() + 21 * 24 * 3600 * 1000), }, ); const alt = `${post.title} (${post.section})`; return { title: post.title, openGraph: { images: [{ url: image, width: 1200, height: 630, alt }] }, twitter: { card: 'summary_large_image', images: [{ url: image, alt }] }, }; }
In our build, this produced og:image and twitter:image tags holding the full signed URL, with every & written as &, plus width, height and a per-post alt text. Unlike the file convention, there is no og:image:type tag unless you add type: 'image/png' to the image object.
Three details to get right:
- The card route must be public. The renderer loads it from the internet, so a preview deployment behind a password renders a login page.
- The expiry must stay within 30 days. Signed URLs require
expires, at most 30 days ahead. Statically generated pages freeze the URL at build time, so revalidate them well inside that window, or use the weekly expiry bucket from the OG image generation guide. cache_available=truematters. Without it every crawler fetch is a new render. With it, a repeat fetch inside the TTL reuses the first render for 0 credits.
If you also call the API from Server Actions, the Next.js screenshot API page covers that side.
Which one to use
| Your situation | Use |
|---|---|
| Flexbox card, one or two fonts, slugs known at build | opengraph-image.tsx with generateStaticParams |
| Flexbox card, content changes per request | opengraph-image.tsx, dynamic |
| Card should reuse site components, Tailwind or a stylesheet | Route handler + signed screenshot URL |
| Grid, filters, blend modes, many font weights, CJK or emoji | Route handler + signed screenshot URL |
| Image of a page you do not control (a user's link) | Screenshot API; Satori cannot load a URL |
| No third-party service allowed | opengraph-image.tsx |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
og:image starts with http://localhost:3000 |
metadataBase not set |
Set it in the root layout |
| Image route 500s in production | Font path does not exist after build | Read fonts from a path under process.cwd() that ships with the app |
| Text shows as boxes | Font lacks the glyphs | Add a font that has them, per script |
| Layout differs from the browser preview | CSS that Satori does not support | Flexbox only, or switch to the route-handler option |
| Every post has the same alt text | alt export is static |
Set openGraph.images in generateMetadata |
| Card shows a login page (route handler) | Card route behind auth | Use a public production URL |
invalid_signature (route handler) |
A parameter was added after signing | Sign every parameter; never edit the URL |
| First share shows no image | Slow first render on a cold function | Prerender, or warm the URL when you publish |
Tested code
The test app has a root layout with metadataBase, the blog route with opengraph-image.tsx (with and without generateStaticParams), the /og/[slug] card route and the /rs/[slug] page with signed metadata. We ran next build, next start, read the tags with curl, and timed the image routes with this script:
#!/bin/sh # measure.sh: start next, time the dynamic and prerendered OG image routes, stop next. Three rounds. PORT=4517 for round in 1 2 3; do (npx next start -p $PORT > start.log 2>&1 &) until curl -s -o /dev/null localhost:$PORT/_not-found 2>/dev/null; do sleep 0.2; done H=$(curl -s localhost:$PORT/dyn/hello-world | grep -oE 'dyn/hello-world/opengraph-image\?[0-9a-f]+' | head -1) first=$(curl -s -o /dev/null -w '%{time_total}' "localhost:$PORT/$H") warm="" for i in 1 2 3 4 5; do warm="$warm $(curl -s -o /dev/null -w '%{time_total}' "localhost:$PORT/$H")"; done static=$(curl -s -o /dev/null -w '%{time_total}' "localhost:$PORT/blog/hello-world/opengraph-image") echo "round=$round first=$first warm=[$warm] static=$static" pkill -f "next start -p $PORT"; pkill -f next-server; sleep 1 done
To try the screenshot route on your own card, create a free account; it includes 150 credits a month and a signing key pair for og:image URLs.
Last tested 2026-10-18.
Related in this topic
- Generate OG Images From HTML: A Production Guide Generate Open Graph images from an HTML template: platform size limits, escaping, signed URLs without redirects, cache-safe expiry and a Next.js example.
- The State of OG Images: A 10,000-Site Crawl We crawled the top 10,000 home pages for og:image and Twitter card tags: adoption, sizes, formats, file weight and broken image URLs, with our method.
- OG Image Not Showing? 12 Causes and a Checker Script OG image not showing in Slack, LinkedIn or X? Twelve causes in the order to check them, a tested Node script that finds each one, and the fix for every case.
- OG Image Size: 1200×630, Crops and File Limits The right OG image size is 1200×630 (1.91:1). Platform limits with sources, how many pixels each preview shape crops, and measured file sizes at 1x and 2x.
- Rails OG Images: meta-tags, an ERB Card and Signed URLs Dynamic Open Graph images in Rails 8: an ERB card route, the meta-tags gem, a signed og:image helper, Minitest tests and what allow_browser does to crawlers.
- Twitter Card Image Size: summary vs summary_large_image X card image sizes: 1200×600 (2:1) for summary_large_image, a square for summary. What 1200×630 and 1200×628 lose to the crop, and the tags that pick the card.