· Tutorials · By Simon Chiu

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/og 1.0.1), [email protected], [email protected] and Node 22.18, using next build and next start on 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 build printed a warning that metadataBase was not set and that it would use http://localhost:3000, and a dynamic page served by next start on port 4518 wrote http://localhost:4518/... into og:image. A localhost image URL is a classic reason an OG image does not show.
  • Width, height and type come from the exports. size and contentType become og:image:width, og:image:height and og:image:type. Keep size equal to what you pass to ImageResponse, 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 wrote twitter:card as summary_large_image and pointed twitter:image at the same file. You only need twitter-image.tsx for a different X image, for example an exact 2:1 shape; the Twitter card size guide shows when that matters.
  • The alt export is static. Every post got "Article card". For a per-post description, either write a better generic alt, or set openGraph.images yourself in generateMetadata (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 with Cache-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 with curl -I on 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
   .replaceAll('"', '&quot;').replaceAll("'", '&#39;');

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 &amp;, 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=true matters. 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