
How to Generate Dynamic Open Graph Images in Next.js?
When someone shares a link on LinkedIn, Slack, Facebook, X, or WhatsApp, the platform fetches the page, reads its Open Graph tags, and renders a preview card. The image in that card is the single biggest factor in whether people click. Most sites handle it with one generic banner for every page, or with a designer exporting a new PNG for each article. The first looks lazy in a feed full of shared links, and the second does not scale past a few dozen posts.
Next.js solves this with code. You describe the image in JSX, Next.js renders it to a PNG, and the correct og:image tags are added to the page automatically. Every blog post, product, or documentation page gets its own branded preview with the right title on it, and you never open a design tool.
This article covers dynamic Open Graph images in the Next.js App Router from start to finish: the opengraph-image and twitter-image file conventions, generating per-page images from route params, loading custom fonts and logos, the layout rules of the rendering engine, building a reusable /api/og route handler, caching behavior, and how to test the result before you share a link.
What an Open Graph Image Is
Open Graph is a set of meta tags, originally defined by Facebook, that describe how a page should look when shared. The image tags are the ones that matter most:
| Tag | Purpose |
|---|---|
og:image | Absolute URL of the preview image |
og:image:width | Image width in pixels, usually 1200 |
og:image:height | Image height in pixels, usually 630 |
og:image:type | MIME type, such as image/png |
og:image:alt | Text description of the image |
twitter:card | Card style on X, such as summary_large_image |
twitter:image | Optional X-specific image, falls back to og:image |
The standard size is 1200 by 630 pixels, an aspect ratio of about 1.91:1. Every major platform accepts it, and it crops cleanly to the square and near-square previews some apps use on mobile.
If you are new to how Next.js manages head tags in general, start with how Next.js handles SEO optimization, then come back here for the image part.
Three Ways to Add Open Graph Images
Next.js gives you three options, and they can be mixed in the same project:
| Approach | Best for | Where it lives |
|---|---|---|
| Static image file | One fixed image for a section | app/opengraph-image.png |
| Generated image file convention | Per-page images from route data | app/blog/[slug]/opengraph-image.tsx |
| Route handler with ImageResponse | Images used outside the route, custom URLs | app/api/og/route.tsx |
A static file is the simplest. Drop opengraph-image.png into any route segment, optionally with an opengraph-image.alt.txt beside it, and Next.js emits the tags for that segment and everything below it. The rest of this article focuses on the generated options, because that is where the real value is.
Your First Generated Image
Generated images use the ImageResponse class from next/og. Create an opengraph-image.tsx file in a route segment and default-export a function that returns an ImageResponse:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const alt = "Web Solution Master";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "80px",
background: "linear-gradient(135deg, #0a48ac 0%, #7c3aed 100%)",
color: "white",
}}
>
<div style={{ fontSize: 72, fontWeight: 700 }}>Web Solution Master</div>
<div style={{ fontSize: 36, marginTop: 24, opacity: 0.85 }}>
Practical guides for modern web development
</div>
</div>,
{ ...size },
);
}
Three named exports configure the generated tags:
altbecomesog:image:alt.sizebecomesog:image:widthandog:image:height. Spreading it into theImageResponseoptions keeps the tag and the actual image in sync.contentTypebecomesog:image:type.
Because this file sits at the root of app, every page in the site inherits it unless a deeper segment defines its own. Visit http://localhost:3000/opengraph-image in development to see the rendered PNG directly.
Set metadataBase First
Social platforms need an absolute URL. Next.js builds one by combining the generated image path with metadataBase, so set it once in your root layout:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://websolutionmaster.com"),
twitter: {
card: "summary_large_image",
},
};
Without metadataBase, Next.js falls back to a default (localhost in development, or the deployment URL on some hosts) and logs a warning. Shared links in production must never point at localhost, so treat this as required. Setting twitter.card to summary_large_image tells X to show the full-width image instead of a small thumbnail.
Per-Page Images from Route Params
The real use case is one image per blog post, product, or profile. Put opengraph-image.tsx inside the dynamic segment, and the default export receives params:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/posts";
export const alt = "Blog post cover";
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 getPostBySlug(slug);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "72px 80px",
background: "#eef5f3",
}}
>
<div style={{ display: "flex", fontSize: 28, color: "#475569" }}>
{post?.category ?? "Blog"}
</div>
<div
style={{
display: "flex",
fontSize: post && post.title.length > 60 ? 60 : 76,
fontWeight: 700,
lineHeight: 1.15,
color: "#0a48ac",
}}
>
{post?.title ?? "Web Solution Master"}
</div>
<div style={{ display: "flex", fontSize: 28, color: "#0f172a" }}>
websolutionmaster.com
</div>
</div>,
{ ...size },
);
}
A few things to notice:
paramsis a promise. Since Next.js 16 you mustawaitit. In Next.js 14,paramswas a plain object; in 15 it was a promise with a temporary synchronous fallback. If you copy older examples that destructureparams.slugdirectly, they will fail type checks and break at runtime.- The fallback values matter. If the post lookup fails, the function should still return a valid image instead of throwing, because a broken image means no preview at all.
- Font size adapts to title length. Long titles are the main reason generated images look bad. A simple length check, or a few breakpoints, keeps text from overflowing.
The generated route lives at /blog/[slug]/opengraph-image, and Next.js adds a cache-busting query string to the URL in the tags. Every post page automatically gets an og:image pointing at its own image.
Using the Same Data as the Page
The image and the page usually need the same post data. If getPostBySlug reads from the file system, as in a Markdown-based blog, there is no network cost to calling it twice. If it calls an API or database, wrap it in React's cache function so both calls in the same request share one result:
// lib/posts.ts
import { cache } from "react";
import { db } from "@/lib/db";
export const getPostBySlug = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } });
});
Custom Fonts in Open Graph Images
The image renderer does not have access to your site's CSS or to next/font. By default it uses a built-in font, which looks generic. To use your brand font, read the font file as binary data and pass it in the fonts option:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { getPostBySlug } from "@/lib/posts";
export const alt = "Blog post cover";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
// Read once at module scope, not on every request
const interBold = await readFile(
join(process.cwd(), "assets/fonts/Inter-Bold.ttf"),
);
const interRegular = await readFile(
join(process.cwd(), "assets/fonts/Inter-Regular.ttf"),
);
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "white",
fontFamily: "Inter",
}}
>
<div style={{ fontSize: 72, fontWeight: 700, color: "#0a48ac" }}>
{post?.title ?? "Web Solution Master"}
</div>
<div
style={{
fontSize: 32,
fontWeight: 400,
marginTop: 24,
color: "#334155",
}}
>
{post?.description ?? ""}
</div>
</div>,
{
...size,
fonts: [
{ name: "Inter", data: interBold, weight: 700, style: "normal" },
{ name: "Inter", data: interRegular, weight: 400, style: "normal" },
],
},
);
}
Rules for fonts in ImageResponse:
- Supported formats are TTF, OTF, and WOFF. WOFF2 is not supported, which catches people out because that is what most font downloads provide. Grab the TTF version from the font's source, such as the Google Fonts repository.
- Register each weight separately. If you use 400 and 700, load two files. Variable fonts are not supported in the same way as in browsers, so use static instances.
- Paths are relative to the project root.
process.cwd()is the project root during build and in a standard Node.js server. Keep fonts in a folder likeassets/fontsthat is not insidepublic, since they do not need to be publicly served. - Read files at module scope. This loads the file once instead of on every image request.
For fonts on the rendered pages themselves, see how to use next/font to optimize fonts in Next.js. The two systems are separate: next/font styles your HTML, and the fonts option styles your generated images.
Adding a Logo or Photo
Images inside the image work through a normal img element with an absolute URL or a data URI. For local files, read them as base64 at module scope:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const logo = await readFile(
join(process.cwd(), "public/images/logo.png"),
"base64",
);
const logoSrc = `data:image/png;base64,${logo}`;
export default function Image() {
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0f172a",
}}
>
<img src={logoSrc} width={320} height={320} alt="" />
</div>,
{ ...size },
);
}
Always set explicit width and height on images. The renderer cannot measure an image before laying out the page, so missing dimensions lead to stretched or invisible images. For remote images, such as an author avatar from a CMS, pass the full HTTPS URL as src.
Layout Rules You Need to Know
ImageResponse is built on Satori, which converts JSX and a subset of CSS into SVG, and Resvg, which converts the SVG into PNG. It is not a browser, and that has consequences:
- Flexbox only.
display: flexanddisplay: nonework.display: grid, floats, and tables do not. - Every element with more than one child needs
display: flex. Otherwise you get the error "Expected div to have explicit display: flex or display: none if it has more than one child node." The safest habit is to putdisplay: "flex"on everydiv. - Inline styles only. No Tailwind classes, no CSS files, no CSS modules. Satori has an experimental
twprop, but plain style objects are more predictable. - A subset of CSS properties. Gradients, borders, border radius, box shadows, absolute positioning, text overflow, and line clamping are supported. Check the Satori documentation before relying on anything unusual.
- A 500 KB bundle limit. JSX, fonts, and embedded images all count. Two or three font files plus a small logo fit comfortably; a full font family with every weight does not.
- No client-side code. Hooks, event handlers, and browser APIs have no meaning here. The function runs once on the server and returns a PNG.
When a layout does not look right, pass debug: true in the ImageResponse options. It draws bounding boxes around every element so you can see where the flex layout is going wrong. The Vercel OG Playground is also useful for experimenting with markup outside your project.
A Reusable /api/og Route Handler
The file convention is the best default, but sometimes you need an image URL you can control directly: for emails, for pages whose data lives in query parameters, or to share one template across many routes. Use a route handler:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = (searchParams.get("title") ?? "Web Solution Master").slice(
0,
100,
);
const label = (searchParams.get("label") ?? "Blog").slice(0, 30);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#eef5f3",
}}
>
<div style={{ display: "flex", fontSize: 30, color: "#7c3aed" }}>
{label}
</div>
<div
style={{
display: "flex",
fontSize: 68,
fontWeight: 700,
color: "#0a48ac",
marginTop: 20,
}}
>
{title}
</div>
</div>,
{
width: 1200,
height: 630,
headers: {
"Cache-Control": "public, max-age=86400, immutable",
},
},
);
}
Then reference it from generateMetadata in the page:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/posts";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
const title = post?.title ?? "Blog";
return {
title,
openGraph: {
title,
images: [
{
url: `/api/og?title=${encodeURIComponent(title)}&label=Next.js`,
width: 1200,
height: 630,
alt: title,
},
],
},
};
}
Two cautions with a query-string endpoint:
- Limit and validate input. Anyone can call
/api/og?title=anythingand get a branded image with arbitrary text. Truncating length is the minimum. For stricter control, accept a slug instead of a title and look up the real title on the server. - Do not combine both approaches on one route. If a segment has an
opengraph-image.tsxfile andgenerateMetadataalso setsopenGraph.images, you create confusion about which wins. Pick one per route.
Caching and Performance
Generated images are statically optimized by default. If the image function does not use request-time APIs like cookies(), headers(), or uncached data, Next.js renders it once and caches it, just like a static page. For dynamic segments, images for the params you prerender are generated at build time, and other params are generated on first request and then cached.
This means you do not need to worry about a social crawler hammering an expensive rendering function. If post titles change after deployment, the image updates when the page's data is revalidated, through the same mechanisms covered in how Next.js handles data fetching.
The route handler approach follows normal route handler rules. Because it reads searchParams, it renders at request time, so set a Cache-Control header as in the example above and let your CDN do the caching.
Twitter Images and Multiple Images
twitter-image.tsx works exactly like opengraph-image.tsx and emits twitter:image tags. In practice, you rarely need it: X uses og:image when twitter:image is missing, as long as twitter:card is set to summary_large_image. Create a separate twitter-image.tsx only when you want a different design for X.
If one segment needs several image variants, use generateImageMetadata in the same file. It returns an array of objects, each with an id, size, alt, and contentType, and the image function receives the matching id as a promise. This is mostly useful for icons in several sizes; for social images, one 1200 by 630 image is enough.
Testing Your Images
Before sharing links widely, verify three things.
- The image renders. Open
/blog/your-post/opengraph-imagein the browser. You should see the PNG. Errors from Satori show up in the terminal runningnext dev. - The tags are correct. View the page source and check that
og:imageis an absolute URL on your production domain, with width, height, type, and alt tags present. - Platforms read it. Use the Facebook Sharing Debugger and LinkedIn Post Inspector to fetch the page as their crawlers do. Both also let you force a re-scrape when you change an image, since platforms cache previews aggressively. X shows a live preview in the post composer when you paste the URL.
Remember that crawlers fetch from the public internet. Previews cannot work for localhost or for preview deployments behind authentication.
Common Problems and Fixes
- "Expected div to have explicit display: flex." A
divhas more than one child without a flex display. Adddisplay: "flex"to it, and preferably to everydivin the image. - og:image points at localhost.
metadataBaseis missing or wrong in the root layout. Set it to your production URL. - Custom font is ignored. The font is WOFF2, the
fontFamilyname does not match thenamein thefontsarray, or the weight is not registered. Use TTF and register every weight you reference. - Build fails with a bundle size error. You exceeded the 500 KB limit. Remove unused font weights, subset fonts, or compress embedded images.
- Old image still showing on social media. The platform cached the old preview. Use the platform's debugger to re-scrape the URL.
- Text overflows the image. Scale font size by title length, set a maximum width, or use
lineClampwithoverflow: "hidden"anddisplay: "block"on the text element. - TypeScript errors on
params.slug. You are using a pre-16 pattern. Typeparamsas aPromiseandawaitit.
Dynamic Open Graph Images FAQ
Use 1200 by 630 pixels. It works on Facebook, LinkedIn, X, Slack, Discord, and messaging apps, and it is the default size for ImageResponse. Keep important text away from the edges, since some apps crop slightly.
Not the normal way. The renderer does not load your stylesheets, so class names do nothing. Use inline style objects. Satori has an experimental tw prop that understands a subset of Tailwind utilities, but inline styles are more reliable.
No. By default they are statically optimized and cached, just like static pages, unless the image function uses request-time APIs or uncached data. A route handler that reads query parameters is the exception and should set its own cache headers.
Usually not. X falls back to the og:image tag when no twitter:image is set, as long as the twitter card type is summary_large_image. Add a twitter-image file only if you want a different design for X.
next/font generates CSS for HTML pages, and the image renderer does not use CSS files. You need to read the font file as binary data and pass it in the fonts option of ImageResponse. Use TTF, OTF, or WOFF files, since WOFF2 is not supported.
Image file conventions that do not depend on dynamic data can be generated at build time. Route handlers that read query parameters need a running server, so they do not work with output export.
Conclusion
Dynamic Open Graph images are one of the highest-return features in the Next.js App Router. A single opengraph-image.tsx file in a dynamic segment gives every page its own branded preview, with correct tags, absolute URLs, and build-time caching handled for you.
Start with metadataBase and a root-level image so every page has a sensible default. Then add a per-segment image for your blog posts or products, load your brand font as a TTF, keep every div on display: flex, and size titles by length. Reach for an /api/og route handler only when you need a URL you control directly. Finally, test with the platform debuggers before you rely on the previews in a campaign.
Here are some useful references for going deeper on Open Graph images in Next.js:
- Next.js Docs: opengraph-image and twitter-image — the file conventions, props, and config exports.
- Next.js Docs: ImageResponse — every option for generating images from JSX.
- Satori: Supported CSS and HTML — the layout engine behind ImageResponse and its CSS subset.
- The Open Graph Protocol: ogp.me — the original specification for og meta tags.
- Meta for Developers: Sharing Debugger — fetch, preview, and re-scrape your Open Graph tags.


