Implementation · 9 min read
Next.js Open Graph images: metadata and dynamic OG cards
Add Open Graph images in the Next.js App Router with static metadata, generateMetadata, or a dynamic opengraph-image.tsx built with next/og.
Updated
The short answer
In the Next.js App Router, set metadataBase to your production origin, then return an openGraph.images array from a static metadata export or generateMetadata. For per-page generated cards, add an opengraph-image.tsx file that exports size, contentType, alt, and a default function returning new ImageResponse(...) from next/og. Next.js adds the generated image to the page metadata automatically.
- Router
- App Router
- Metadata API
metadataorgenerateMetadata- Generated cards
opengraph-image.tsx- Renderer
next/ogImageResponse- Card size
- 1200 x 630
- Base URL
metadataBaserequired for relative URLs
Next.js has three ways to ship an Open Graph image, and they solve different problems. A static file is the simplest. A metadata object per page is the flexible middle. A generated opengraph-image.tsx is the right choice when every page needs its own card and you do not want a design tool in the publish path.
| Approach | Best for | Trade-off |
|---|---|---|
Static file plus metadata.openGraph.images | A small site with a handful of pages | One image per page, maintained by hand |
generateMetadata with a computed image URL | A CMS or database-driven site | You still need to generate and store the images |
opengraph-image.tsx with next/og | Per-page cards generated at request or build time | The layout lives in code; fonts and assets need care |
1. Set metadataBase once
metadataBase is the origin Next.js uses to turn relative metadata URLs into absolute ones. Without it, relative image paths can resolve against localhost in development and produce warnings or broken previews in production. Set it in the root layout.
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "Example",
template: "%s | Example",
},
description: "A short description of the site.",
};If the production URL comes from an environment variable, read it once and pass it to new URL(). Do not point metadataBase at a preview deployment; preview URLs are usually private and the social crawler cannot fetch them.
2. Add a static Open Graph image to a page
A page can export its own metadata object. The openGraph.images array accepts a URL, width, height, and alt text. The same image can be reused for the X card.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "A clearer way to ship",
description: "What changed, why it matters, and what to try first.",
alternates: { canonical: "/blog/a-clearer-way-to-ship" },
openGraph: {
title: "A clearer way to ship",
description: "What changed, why it matters, and what to try first.",
url: "/blog/a-clearer-way-to-ship",
siteName: "Example",
type: "article",
locale: "en_US",
images: [
{
url: "/og/a-clearer-way-to-ship.png",
width: 1200,
height: 630,
alt: "Dark card with the headline: A clearer way to ship",
},
],
},
twitter: {
card: "summary_large_image",
title: "A clearer way to ship",
description: "What changed, why it matters, and what to try first.",
images: ["/og/a-clearer-way-to-ship.png"],
},
};Because metadataBase is set, /og/a-clearer-way-to-ship.png becomes an absolute URL in the rendered HTML. You can also pass an absolute URL directly. Both work; relative paths are easier to move between environments.
3. Compute the image URL with generateMetadata
When the page data comes from a CMS, a database, or a file system, export generateMetadata instead of a constant. It receives the same params as the page and can return a metadata object per URL.
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
url: `/blog/${post.slug}`,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
images: [
{
url: post.ogImage ?? `/og/${post.slug}.png`,
width: 1200,
height: 630,
alt: post.ogImageAlt ?? post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.ogImage ?? `/og/${post.slug}.png`],
},
};
}4. Generate the card with opengraph-image.tsx
The file convention is the cleanest way to generate a card per route. Put opengraph-image.tsx in the route segment. It can export alt, size, contentType, and a default component. Next.js renders it and adds the correct metadata tags to the page.
import { ImageResponse } from "next/og";
export const alt = "An article card from Example";
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: "space-between",
padding: 72,
background: "#0a0a0a",
color: "#fafafa",
fontFamily: "sans-serif",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 26,
letterSpacing: 1,
color: "#a3a3a3",
}}
>
<span>example.com</span>
<span>{post.category}</span>
</div>
<div
style={{
fontSize: 78,
lineHeight: 1.04,
letterSpacing: -3,
maxWidth: 980,
}}
>
{post.title}
</div>
<div style={{ fontSize: 26, color: "#a3a3a3" }}>
{new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
),
size,
);
}The layout above is deliberately plain: a top rail, a large headline, and a bottom line. That is enough for most article cards. Keep the padding around 64-72 px so the content survives a crop on X, and keep the headline under about 100 characters.
5. Load a custom font
ImageResponse uses a default sans unless you pass fonts. To match your brand, fetch a font file and include it in the options. Keep the file small and cache the fetch; generating a card should not download a 600 KB font on every request.
import { ImageResponse } from "next/og";
const fontPromise = fetch(
new URL("./assets/Inter-Bold.ttf", import.meta.url),
).then((response) => response.arrayBuffer());
export default async function Image() {
const font = await fontPromise;
return new ImageResponse(
<div style={{ fontFamily: "Inter", fontSize: 72 }}>Hello</div>,
{
...size,
fonts: [{ name: "Inter", data: font, style: "normal", weight: 700 }],
},
);
}Not every route needs a custom font. If the default renders cleanly and the card is legible, skip the extra weight. The card's job is recognition and readability, not typographic fidelity.
6. Caching and revalidation
A generated image is expensive relative to a static file. If the page content changes rarely, generate at build time with generateStaticParams and let the CDN serve the result. If it changes often, set a revalidation window that matches the content, and make sure the image URL changes when the design changes.
- Static route:
generateStaticParamsplus the default static rendering. The image is generated once per build. - Dynamic route: set
export const revalidate = 3600or the appropriate interval. Do not regenerate on every request if the content has not changed. - Cache busting: if you change the card layout, change the route or append a version to the image URL. Platforms cache by URL, independently of Next.js.
- Do not fetch user-specific data. A card that varies by cookie or session cannot be cached and may leak information.
7. Common Next.js mistakes
- Missing `metadataBase`. Relative image paths resolve unpredictably, especially in preview deployments.
- Tags in a client component. Client-side metadata is not in the initial HTML. Use a server component or the file convention.
- Not awaiting params. In current Next.js versions,
paramsis a Promise in pages and generated image routes. Await it before reading the slug. - Duplicating the file convention and a manual image. If
opengraph-image.tsxexists, Next.js adds it. A manualopenGraph.imagesentry can override it with a different URL. - A `localhost` image URL. The crawler cannot reach your machine. Use the production origin.
- A layout object that overwrites the page image. Remember that nested metadata objects are replaced, not deep-merged.
- Too many fonts or a huge background. Generation time and memory use grow quickly. Keep the card simple.
8. Check the rendered HTML
Start the production build, not the dev server, and fetch the page with curl. The og:image tag should be present in the response and should contain an absolute URL. Then open that URL and run the page through the platform debuggers.
npm run build && npm run start
curl -sL http://localhost:3000/blog/a-clearer-way-to-ship | grep -i 'og:image'
curl -sI http://localhost:3000/blog/a-clearer-way-to-ship/opengraph-image | headThe exact generated image path depends on the route and the file convention. Read the tag rather than guessing the URL. For the tag reference, see the Open Graph meta tags guide; for the dimensions, see the OG image size guide.
Frequently asked questions
How do I add an Open Graph image in Next.js?
Set metadataBase in the root layout, then return an openGraph.images array from a page's metadata or generateMetadata. For generated cards, add an opengraph-image.tsx file using ImageResponse from next/og.
What is the difference between metadata and generateMetadata?
A static metadata export is evaluated once and is fine for fixed pages. generateMetadata is an async function that receives params and searchParams, so it can build tags from a CMS, database, or file system for each URL.
Does Next.js add the OG image automatically?
Yes, when you use the opengraph-image file convention. Next.js renders the image and adds the metadata tags. If you set openGraph.images manually as well, your value can override the generated one.
Why is my Next.js OG image URL relative?
Relative URLs are expected in the metadata object. Next.js makes them absolute using metadataBase. If metadataBase is missing or points at localhost, the rendered tag will be wrong for social crawlers.
Can I use next/og without Vercel?
Yes. ImageResponse runs in the Next.js runtime and works on other hosts. Check that the deployment platform supports the runtime and that the generated image route is publicly reachable.
How do I use a custom font in next/og?
Fetch the font file as an ArrayBuffer and pass it in the fonts option of ImageResponse, with a name, style, and weight. Cache the fetch outside the request so the font is not downloaded for every card.
Why is my generated OG image slow?
The usual causes are a large font, a large background image, or generating on every request without caching. Generate at build time where possible, set a revalidation window, and keep the layout simple.
Can I generate a Twitter card image in Next.js too?
Yes. Add a twitter-image.tsx file with the same ImageResponse pattern, or point twitter.images at the same URL as the Open Graph image. Most sites use one card for both.
Sources and further reading
Written and maintained by GxAditya
The person who builds ogimg.in. Last updated . 9 min read.
Free, no account
Make the image, then copy the tags
Start from a template or a blank canvas. Export a 1200 x 630 PNG, JPEG, or WebP, and copy the Open Graph tags from the preview panel.