How to Add Dynamic OG Images to Your Next.js App
Two real ways to give every page its own social preview image in the App Router: Next.js's built-in ImageResponse file convention, and pointing og:image at a rendering API instead — with the tradeoffs of each.
Every page on a Next.js site technically has an OG image the moment you deploy — it's just that without one set explicitly, most platforms either show nothing or fall back to whatever static image you put in your root layout. That's fine for a single-page marketing site. It falls apart the moment you have more than one page worth sharing: a blog with fifty posts, a docs site with two hundred articles, a product catalog. Share any of them and the preview card shows the same generic homepage image every time — the exact opposite of what you want when a link is trying to earn a click.
There are two real ways to fix this in a Next.js App Router app, and they trade off differently enough that it's worth understanding both before picking one.
Option 1: Next.js's built-in ImageResponse
Next.js ships a file convention for exactly this: drop an opengraph-image.tsx next to any page.tsx, export a component that returns an ImageResponse, and Next wires up the og:image tag automatically — no manual generateMetadata needed for the image itself.
// 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 default async function Image({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", background: "#0a0a0a", color: "#fff", padding: "80px", }} > <div style={{ fontSize: 64, fontWeight: 800 }}>{post.title}</div> </div> ), { ...size } ); }
Under the hood this is Satori, which converts a JSX/CSS tree into SVG, not a browser. That's the whole tradeoff in one sentence: it's fast and runs on the Edge runtime for free, but it only understands a flexbox-only subset of CSS — no grid, no absolute-positioning tricks beyond the basics, no arbitrary web fonts without manually fetching and passing the font file bytes yourself, and every design change means editing this file and redeploying. There's no dashboard, no saved templates, no caching layer — you get exactly what you build, and you're responsible for all of it.
For a design this simple — one heading on a solid background — that's a fine trade. It gets uncomfortable fast once you want a logo image, a specific brand font, or a layout that doesn't fit neatly into flexbox.
Option 2: point og:image at a rendering API
The other approach doesn't generate an image file in your app at all — og:image just becomes a URL to a service that renders one on demand, the same way OGImg's own /api/render works: a GET request with your title/description as query parameters, a real headless-Chromium render behind it (so full CSS, real fonts, arbitrary layouts), and a cached, CDN-served PNG comes back. Your app doesn't render anything — it just builds a URL.
Simplest case: a static image for the whole site, set once in your root layout's metadata.
export const metadata: Metadata = { openGraph: { images: [ "https://ogimg.net/api/render?key=YOUR_API_KEY&title=My+Next.js+App&description=Built+with+App+Router", ], }, };
For a page that actually varies — a blog post, a product page — build the URL per-page inside generateMetadata instead, using whatever data you're already fetching for the page itself:
// app/blog/[slug]/page.tsx import type { Metadata } from "next"; import { getPost } from "@/lib/posts"; export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); const ogImage = `https://ogimg.net/api/render?key=${process.env.OGIMG_API_KEY}` + `&title=${encodeURIComponent(post.title)}` + `&description=${encodeURIComponent(post.excerpt)}`; return { title: post.title, description: post.excerpt, openGraph: { images: [{ url: ogImage, width: 1200, height: 630 }] }, twitter: { card: "summary_large_image", images: [ogImage] }, }; }
Worth knowing: social crawlers fetch og:image URLs without any auth headers, so your API key is necessarily visible in your page's HTML source when used this way — the same tradeoff described in OGImg's auth docs. Use a key with a modest quota for this specific purpose, and regenerate it from the dashboard if it ever needs to change.
Side by side
ImageResponse | A rendering API (OGImg) | |
|---|---|---|
| Runs where | Your own app (Edge/Node runtime) | OGImg's servers |
| Rendering engine | Satori (JSX → SVG), flexbox-only CSS subset | Full headless Chromium — any CSS |
| Change the design | Edit code, redeploy | Edit in the dashboard, no redeploy |
| Fonts | Manually load + pass font files yourself | 5 built-in, or arbitrary CSS in Custom HTML |
| Caching | You build it (or re-render every request) | Built in — content-addressed, CDN-served |
| Cost | Free, part of your existing deploy | Free tier, then usage-based |
| Works outside Next.js | No — it's a Next.js-specific file convention | Yes — it's a plain URL |
Neither is strictly better — ImageResponse is the right default for a simple, code-owned design you rarely change. Reach for a rendering API once you want non-technical editing, a design that doesn't fit flexbox, or the same image logic reused outside a Next.js project entirely.
Checking your work
Whichever route you take, verify the actual tags before trusting them — it's easy to ship a typo in a query param or an image URL that 404s and not notice until someone screenshots a broken share. Paste the live page URL into the free OG tag checker and it'll show the Open Graph and Twitter Card tags those platforms read, and flag anything missing.