How to Add an Open Graph Image in Next.js
Add static, external, or dynamically generated Open Graph images in Next.js App Router, with metadata, caching, debugging, and production guidance.

Direct answer: In the Next.js App Router, put a static opengraph-image.png, .jpg, .jpeg, or .gif in app/ or the route segment that owns the page. Next.js discovers it and emits the Open Graph tags automatically. For a generated image, create opengraph-image.tsx, return new ImageResponse(...) from next/og, and export alt, size, and contentType. If the image already exists elsewhere, set metadata.openGraph.images to an absolute URL.
This guide covers the App Router metadata conventions documented by Next.js, including route precedence, dynamic data, multiple variants, image limits, caching, testing, and production troubleshooting.
1. Choose the right implementation
| Approach | Use it when | Main trade-off |
|---|---|---|
| Static file | Every page in a route segment can share one designed image | Simple, but the artwork is fixed |
opengraph-image.tsx |
You need title, author, category, or other page data rendered into the image | More flexible; renderer supports flexbox and a subset of CSS |
metadata.openGraph.images |
An image is already hosted at a public URL | You manage the URL and its availability |
generateImageMetadata |
One route needs several generated variants | More code and variant selection logic |
Open Graph images should normally be 1200×630 pixels. Keep the file below Next.js’ documented 8 MB Open Graph limit. Twitter images have a separate 5 MB limit.
2. Add a static Open Graph image
Create this structure for a site-wide default:

app/
├── layout.tsx
├── page.tsx
└── opengraph-image.png
The filename is special. Next.js evaluates it as metadata and generates og:image, image type, width, and height tags. You can also place one inside a route segment:
app/
├── opengraph-image.jpg
└── blog/
├── page.tsx
└── opengraph-image.png
The image in app/blog/ is used for pages in that segment and takes precedence over the higher-level image. A more specific image wins as you move deeper into the folder tree. This lets you use a global default and override it for a product, blog, or documentation section.
For static alt text, add a sibling text file:
app/opengraph-image.png
app/opengraph-image.alt.txt
Put the text you want associated with the image in opengraph-image.alt.txt. Keep the asset publicly reachable through the deployed application and use a real image format supported by the convention.
3. Generate an image with ImageResponse
Use a route file when the image should be rendered from JSX and data. Create app/opengraph-image.tsx:
import { ImageResponse } from 'next/og'
export const alt = 'About Acme'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function Image() {
return new ImageResponse(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
About Acme
</div>,
)
}
The exported values describe the resulting metadata. size sets width and height, contentType sets the media type, and alt supplies alternative text. The official renderer supports flexbox and a subset of CSS properties; do not assume that arbitrary browser CSS, external stylesheets, or CSS Grid will work.
Dynamic route parameters
For a blog route such as app/blog/[slug]/opengraph-image.tsx, use the route parameter to load the post and render its title. In current Next.js v16 documentation, params resolves to a promise:
import { ImageResponse } from 'next/og'
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 title = slug.replaceAll('-', ' ')
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '72px',
background: '#111827',
color: 'white',
fontSize: 64,
}}
>
{title}
</div>,
)
}
Replace the example title conversion with your database or CMS lookup. If that lookup fails, return a stable fallback title or handle the error so social crawlers still receive a valid image.
4. Point metadata at an existing image URL
If another service already hosts the image, export metadata from the page or layout:
import type { Metadata } from 'next'
export const metadata: Metadata = {
openGraph: {
images: [
{
url: 'https://example.com/og.png',
width: 1200,
height: 630,
alt: 'Example article preview',
},
],
},
}
Each URL in openGraph.images must be absolute. Include width, height, and alt when you control the metadata; dimensions help consumers interpret the asset and alt describes it for accessible metadata handling.
5. Generate multiple image variants
Use generateImageMetadata when one route needs multiple generated images. Return an array describing each variant, then read the selected id in the image function:
import { ImageResponse } from 'next/og'
export function generateImageMetadata() {
return [
{
id: 'light',
alt: 'Light article preview',
size: { width: 1200, height: 630 },
contentType: 'image/png',
},
{
id: 'dark',
alt: 'Dark article preview',
size: { width: 1200, height: 630 },
contentType: 'image/png',
},
]
}
export default function Image({ id }: { id: string }) {
const background = id === 'dark' ? '#111827' : '#ffffff'
const color = id === 'dark' ? '#ffffff' : '#111827'
return new ImageResponse(
<div style={{ background, color, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 72 }}>
Article preview
</div>,
)
}
Each returned item should provide an id, alt, size, and contentType. Keep variant output deterministic so caches do not fill with unnecessary permutations.
6. Caching, freshness, and deployment behavior
Generated metadata routes are cached by default. That is useful for stable article titles and predictable response times. A route can become dynamic when it uses Dynamic APIs or uncached data. Decide deliberately whether an image should change immediately after an edit or remain cached until the normal revalidation or deployment path.

- Use static files for content that changes only with a deployment.
- Use generated routes for data-driven artwork, but keep the data request small and reliable.
- Avoid putting timestamps or random values into the JSX; they create needless cache misses.
- Keep image output under 8 MB and verify the generated content type matches the exported value.
- Use a stable fallback when a post is unpublished, deleted, or missing.
7. Verify the tags and the rendered image
- Run the production build, because an image route can behave differently from a development request.
- Open the page HTML and confirm an absolute
og:imageURL is present. - Open the image URL directly and check its status, content type, dimensions, and file size.
- Test a route with a segment-specific image and confirm it overrides the root image.
- Test a missing slug and a slug containing non-ASCII characters.
- Share the URL in the target social platform and account for crawler caching when refreshing previews.
8. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
No og:image tag |
File is outside app, has the wrong name, or metadata is not exported from the expected route |
Use the exact special filename or export metadata.openGraph.images with an absolute URL. |
| Wrong image appears | A higher-level asset is being used, or a cached preview is stale | Place the more specific file in the route segment and request the image URL directly before refreshing the social debugger. |
| Image route returns a server error | Data lookup throws, remote data is unavailable, or JSX uses unsupported styling | Add a fallback, handle failed fetches, and reduce styles to documented flexbox-compatible properties. |
| Text is clipped | Long title exceeds the fixed canvas | Clamp or shorten the title, reduce font size, and reserve space for metadata. |
| External image is ignored | The URL is relative or inaccessible to crawlers | Use a fully qualified HTTPS URL and verify it responds without private authentication. |
| Build exceeds limits | Generated output or source assets are too large | Compress assets, remove unnecessary embedded data, and stay below the documented 8 MB Open Graph limit. |
| CSS layout differs from the browser | ImageResponse does not implement all CSS |
Use supported flexbox properties and test the actual generated image. |
9. Performance, reliability, and cost considerations
Static images usually have the simplest operational profile: no data fetch is required when a crawler requests the page. Generated routes add rendering work and may perform a data lookup, so keep payloads small and avoid blocking calls that are not needed to compose the image. If freshness matters, make the cache policy explicit and ensure the source data has a defined failure path.
Use one consistent canvas size, avoid huge fonts and embedded images, and keep titles bounded. A deterministic renderer is easier to cache and debug. Check both HTML metadata and the image response; a correct tag pointing to a broken URL still produces a broken share card.
10. Or skip the browser setup
If you need to capture a finished page, you can use ScreenshotNeo instead of maintaining browser automation. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/article -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/article"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/article' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Every feature is included on every plan. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
11. Short FAQ
Does the image have to be 1200×630?
No, but 1200×630 is the documented pattern and a practical default for social previews.
Can I use a relative URL in openGraph.images?
No. Use an absolute URL, including the scheme and hostname.
Can I use CSS Grid in ImageResponse?
The documented renderer supports flexbox and a subset of CSS. Build layouts with supported properties rather than relying on arbitrary browser CSS.
Why does a newly changed image still appear old?
Generated routes are cached by default, and social platforms cache previews too. Check the image endpoint directly, then use the platform’s refresh mechanism.
When should I use generateImageMetadata?
Use it when one route needs multiple generated image variants with separate IDs, dimensions, content types, and alt text.


