Next.js Open Graph Images: Static and Dynamic
Set a Next.js Open Graph image with metadata, an opengraph-image file, or a generated image. The same tags cover a React or Vue page rendered on the server.
By Abid Ali, maintainer of OG Scope
Updated · First published

A Next.js Open Graph image is still just an og:image tag in the HTML the server sends. Next.js can write that tag from the metadata export, from a static opengraph-image file, or from code that draws the file per page. Crawlers do not run the React app in the browser, so the tag has to be in that first response.
The same end result applies outside Next.js. A React open graph setup and a Vue open graph setup both succeed when the HTML contains an absolute image URL. The Next.js opengraph-image file convention is the reference for the file names below. The samples on this page follow the Next.js 16 App Router, where params is a Promise. 1200 x 630 is the size most often used for a large card.
A static image in metadata
Use this when the file already exists, for example a cover uploaded with the post. generateMetadata runs on the server and can read the post before it prints the tags. The images array accepts a URL string or an object with width, height, and alt.
// app/blog/[slug]/page.tsx
// Next.js 16. params is a Promise. getPost is a helper you write, not a Next.js API.
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return {
metadataBase: new URL("https://example.com"),
openGraph: {
title: post.title,
images: [
{
url: post.coverUrl,
width: 1200,
height: 630,
alt: post.coverAlt,
},
],
},
};
}Set metadataBase in the root layout, or on this page, if url is a path such as /covers/field-notes.jpg. Next.js turns that path into an absolute URL. An absolute https URL does not need it. The generateMetadata docs say a relative metadata URL without metadataBase is a build error, so the path is not left in the page as a relative og:image.
Make the file in the OG image generator when you want a template, a title, and a logo rather than a photograph. Put the finished file on a public URL, then point coverUrl at it. The metadata object does not upload the file. It only prints the tag.
An opengraph-image file
Place opengraph-image.jpg, opengraph-image.png, or opengraph-image.webp next to page.tsx. Next.js serves that file and adds the image tags for the route. A nested route can have its own file. A file in a parent segment applies to child routes that do not define one.
app/
blog/
[slug]/
page.tsx
opengraph-image.tsxA twitter-image file in the same folder does the same for twitter:image. You do not also list that file in the images array. Use the array when the picture already lives somewhere else, such as a CDN URL stored with the post. Use the file convention when the picture belongs to the route and should ship with the app.
The file has to be a real image in the source tree, not a tag written by a client component. After a deploy, open the route and view the page source. og:image should be an absolute URL on your host. If the tag is missing, the file is in the wrong segment or the page is still serving an older build.
A dynamic og image
A dynamic og image is generated when the route is requested, so the title or the picture can change per post without a stored file for each one. opengraph-image.tsx returns an ImageResponse from next/og.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
background: "#0f172a",
color: "white",
fontSize: 64,
padding: 64,
}}
>
{post.title}
</div>
),
{ ...size },
);
}That file is the nextjs og image for the route, generated rather than stored. The size export is the pixel size Next.js advertises. 1200 by 630 matches the large card. The JSX here uses a limited style set: flex layout, not every CSS feature a browser supports. Stick to flex, absolute positioning, and plain text. A style that works in Chrome can be dropped from the generated PNG.
Pass font data when the default face is not the one in the design. Pass an extra image when the card should include a photo or a logo, and fetch that asset on the server. Next.js caches a generated opengraph-image by default and serves that static file. It generates the image again when the route uses dynamic data or an uncached fetch. A slow getPost still runs at generation time, so keep that work small. Crop and check a static export of the same design in the OG image size tool when you want to confirm the frame before relying on the generated file.
React and Vue without the App Router
A React open graph tag in a browser-only app belongs in index.html, because that is the HTML the crawler receives for every route. A tag set with a client-side head library after hydration arrives too late.
<meta property="og:image" content="https://example.com/og/home.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Field notes cover" />A Vue open graph tag in Nuxt is written with useSeoMeta during server rendering. Nuxt's useSeoMeta docs list the property names. ogImage should be an absolute https URL. Width and height match the file.
useSeoMeta({
ogImage: "https://example.com/og/field-notes.jpg",
ogImageWidth: 1200,
ogImageHeight: 630,
ogImageAlt: "Field notes cover",
twitterCard: "summary_large_image",
});A plain Vue single-page app has the same limit as browser-only React. Put the tags in index.html, or prerender each route to its own HTML. One index.html means one image for the whole site. Per-route covers need a server render or a static file per URL.
One route, one image
A layout-level openGraph.images applies to every child route that does not set its own image. That is useful for a default cover and wrong for a blog, where every post would share one picture. Set the image in generateMetadata for the post, or add an opengraph-image file in the post segment, so the child replaces the parent.
Do not point og:image at a route that itself renders HTML. The URL has to return an image. The special opengraph-image route does that. A normal page URL does not.
Check the HTML, not the component
Open the route and view the page source. og:image should be an absolute URL. Paste that route into the Open Graph checker to see whether the file loads and what size it reports. A tag that exists only after hydration will not be in that result.
If the checker shows the tag and a platform still shows an older card, the platform has a stored copy. Change the image URL after you replace the design, then share the link again. The generated route for an opengraph-image file already has its own address. A new deploy can still be ignored until that address changes or the platform fetches it again.