Open Graph images in the Next.js App Router: generate the card in code, and do not lose it in a page
Share cards in Next.js: generate them with ImageResponse, stay inside the renderer limits, and avoid the metadata merge that silently drops the image.
When someone pastes your link into a chat or a feed, the card is the whole first impression: an image, a title, a line of text. A link with no image gets scrolled past. Next.js makes the card easy to generate in code — and makes it surprisingly easy to lose on individual pages without noticing, because nothing errors when a page quietly drops its image.
Generate the card in code
The App Router has a file convention for this: put an opengraph-image file in a route segment and Next.js wires up the meta tags. It can be a static image file, or code that returns an image. The kit uses code, so the card is versioned with the site and there is no asset to keep in sync:
import { ImageResponse } from "next/og";
export const alt =
"Shipwright, the AI SaaS starter kit where the AI part is actually finished";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OpengraphImage() {
return new ImageResponse(
(
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", background: "#f9f8f1", padding: 80 }}>
{/* brand mark, headline, footer */}
</div>
),
{ ...size },
);
}
The exported alt, size, and contentType become the image's alt text, dimensions, and type in the page's meta tags. Per the Next.js docs, a generated image is statically optimized by default — rendered at build time and cached — unless it uses request-time APIs or uncached data, so a site-wide card costs nothing per request.
Know the renderer's limits
ImageResponse is not a browser. The docs spell out what it supports: flexbox and a subset of CSS, absolute positioning, custom fonts, and text wrapping — but not advanced layouts like display: grid. The whole bundle — JSX, CSS, fonts, and any images — is capped at 500KB, and custom fonts must be ttf, otf, or woff. If you use a static image file instead, the build fails outright when an opengraph-image exceeds 8MB or a twitter-image exceeds 5MB. Design the card for those constraints: one or two big lines of text, a flat background, and a mark you can draw with boxes.
The merge trap that drops your image
Here is the one that costs real traffic. Next.js merges metadata from layouts down to pages, but object fields like openGraph are replaced, not merged. The docs' example is blunt: when a page sets openGraph, all of the layout's openGraph fields are replaced — including the image. Give a blog post its own Open Graph title and description, and it silently stops having a card.
So a page that customizes openGraph has to bring the image back itself. This site's blog pages do it with a URL:
openGraph: {
type: "article",
url,
title: post.title,
description: post.description,
publishedTime: post.date,
siteName: "Shipwright",
images: "/opengraph-image",
},
That restores the image, but only the URL: compare the live tags, and the homepage carries og:image:alt, width, and height from the file convention, while the blog pages carry only the image URL. The better pattern — and the one the Next.js docs suggest for sharing fields across segments — is to pull the full image object into a shared variable (URL, width, height, alt) and spread it into every page that sets openGraph.
Per-page cards from params
A single site-wide card is fine to start with, but every post sharing it wastes the card's best feature. Put an opengraph-image.tsx inside a dynamic segment such as app/blog/[slug]/ and it receives the route's params — a Promise in current Next.js — so it can render each post's own title. Because it is a route of its own, there is no metadata merge to get wrong. That is the natural next step for a blog like this one, whose posts currently share the site card.
Things that bite
- Platforms cache cards hard. After a redesign, an old card can live on for days. Next.js appends a hash to generated image URLs on the page that owns the file, which helps; when you hand-reference
/opengraph-image, add your own version parameter or re-scrape with the platform's debugger. - Design for a thumbnail. Cards are often shown small. Text that reads at 1200×630 can be unreadable in a feed; fewer, bigger words win.
- Check the tags, not the file. The image existing proves nothing. Fetch the rendered page and confirm
og:image(and its alt) is actually present on every template — that is how the blog-page gap above shows up. - Discovery is a separate job. A great card helps a shared link get clicked; it does not get a page indexed. That part is the sitemap, covered in generating your sitemap and llms.txt from one source.
Shipwright — the Next.js and Claude starter kit this blog documents — ships its share card as code with ImageResponse, built and cached with the site. Paste the live demo link anywhere to see it.