Next.js OG Image Generator: Static Files, Dynamic Routes, and Production Patterns
Build reliable Open Graph images in Next.js with static files, ImageResponse, route data, caching, variants, and a ScreenshotNeo fallback.

Direct answer: Next.js supports Open Graph images in two ways. Put a static file named opengraph-image in a route segment, or create an opengraph-image.js, .ts, or .tsx route that returns an image. For dynamic cards, use ImageResponse from next/og. A more specific image in a nested route takes precedence over one higher in the folder tree, and Next.js adds the corresponding metadata tags for you.
This guide shows a complete implementation for App Router projects, including route-specific data, fonts, multiple variants, caching, unsupported CSS, deployment limits, testing, and failure recovery. It also shows how to use ScreenshotNeo when you need a rendered screenshot or PDF of a page rather than a generated social card.
1. Choose static or generated OG images
Use a static image when artwork is fixed and can be checked into the repository. Accepted static formats documented by Next.js include JPG/JPEG, PNG, and GIF. The static Open Graph image limit is 8 MB; exceeding it causes the build to fail. The documented Twitter-image limit is 5 MB.
Use a generated route when the image changes by post, author, product, locale, theme, or other request data. Generated images are rendered from JSX through @vercel/og, Satori, and resvg. This is not a full browser: the supported CSS subset is limited, with flexbox supported but CSS Grid and many browser features unavailable.
| Requirement | Best fit |
|---|---|
| One fixed campaign image | Static opengraph-image.png |
| One card per article slug | Dynamic opengraph-image.tsx |
| Several social variants | generateImageMetadata |
| Browser CSS, charts, or complex widgets | Render a page in a browser and capture it |
2. Add a static Open Graph image
For a site-wide default, place the file in the root app segment:
app/
opengraph-image.png
layout.tsx
page.tsx
For a specific route, place it beside that route:
app/blog/[slug]/
opengraph-image.jpg
page.tsx
The nested image wins for that route. Static metadata files are cached by default. Keep the file below the documented 8 MB limit, and use an explicit 1200×630 canvas if you want to follow the dimensions used in the official generated-image example. That size is an example configuration, not a claim that every social network requires it.
3. Generate an image with ImageResponse
Create app/opengraph-image.tsx for a root-level generated image:

import { ImageResponse } from 'next/og'
export const alt = 'Acme Engineering'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function Image() {
return new ImageResponse(
(
<div
style={{
background: '#111827',
color: 'white',
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
padding: '72px',
justifyContent: 'center',
}}
>
<div style={{ fontSize: 72, fontWeight: 700 }}>Acme Engineering</div>
<div style={{ fontSize: 34, marginTop: 24 }}>Build better software</div>
</div>
),
{ width: 1200, height: 630 },
)
}
The exported alt, size, and contentType describe the generated asset. The example uses image/png; choose the MIME type that matches your route output and project needs. Keep styles inline and use flexbox primitives. CSS variables, external stylesheets, Grid, and browser-only APIs are common sources of rendering failures.
4. Create a card for every blog post
Put the generator in the dynamic segment so it can read the slug:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
type Props = { params: Promise<{ slug: string }> }
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
async function getPost(slug: string) {
const response = await fetch(`https://example.com/api/posts/${slug}`)
if (!response.ok) throw new Error('Post lookup failed')
return response.json() as Promise<{ title: string; author: string }>
}
export default async function Image({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(
(
<div style={{ display: 'flex', flexDirection: 'column', padding: 72, background: '#f8fafc', width: '100%', height: '100%' }}>
<div style={{ color: '#64748b', fontSize: 28 }}>ACME BLOG</div>
<div style={{ color: '#0f172a', fontSize: 58, fontWeight: 700, marginTop: 36 }}>{post.title}</div>
<div style={{ color: '#475569', fontSize: 30, marginTop: 'auto' }}>By {post.author}</div>
</div>
),
size,
)
}
The current documentation types dynamic params as a promise. Older examples may use a plain object, so check the Next.js version in your project before copying a signature. Route data can change whether the result is statically optimized. If the fetch is cacheable and no Dynamic API is used, Next.js can generate and cache the image; request-time data or dynamic configuration changes that behavior.
5. Load fonts and nested images safely
Generated routes can load a local font and include another image. Convert binary assets to data that the renderer accepts:
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const font = fetch(new URL('../../../assets/Inter-Bold.ttf', import.meta.url)).then((r) => r.arrayBuffer())
export default async function Image() {
const [fontData] = await Promise.all([font])
const logo = await readFile(path.join(process.cwd(), 'public/logo.png'))
return new ImageResponse(
<div style={{ display: 'flex' }}>
{/* The next/og renderer accepts this binary form for nested images. */}
<img src={logo as unknown as string} width="160" height="160" />
<div style={{ fontFamily: 'Inter', fontSize: 56 }}>A generated card</div>
</div>,
{ ...size, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] },
)
}
The official example notes that an ArrayBuffer as an <img src> is not part of the HTML specification even though the next/og renderer supports it. TypeScript may therefore need a targeted cast or suppression. Keep fonts and images small: they increase bundle size and generation work. An older versioned ImageResponse page documented a 500 KB bundle limit; verify the limit against your installed Next.js version before relying on it.
6. Add multiple image variants
Use generateImageMetadata when one route should expose several images, such as light and dark cards or different locales:
import { ImageResponse } from 'next/og'
export async function generateImageMetadata() {
return [
{ id: 'light', alt: 'Light social card', size: { width: 1200, height: 630 }, contentType: 'image/png' },
{ id: 'dark', alt: 'Dark social card', size: { width: 1200, height: 630 }, contentType: 'image/png' },
]
}
export default async function Image({ id }: { id: Promise<string> }) {
const variant = await id
const background = variant === 'dark' ? '#0f172a' : '#ffffff'
const foreground = variant === 'dark' ? '#ffffff' : '#0f172a'
return new ImageResponse(
<div style={{ display: 'flex', background, color: foreground, fontSize: 64, padding: 72 }}>
{variant} theme
</div>,
{ width: 1200, height: 630 },
)
}
Next.js 16 changed both params and the generator’s id to promises according to the documentation version history. Confirm your framework version and adjust the signature accordingly.
7. Control caching and freshness
Generated images are statically optimized and cached by default unless they use Dynamic APIs or dynamic configuration. Decide whether a card should change only at build time or reflect request-time content.
- Build-time card: use cacheable fetches and avoid cookies, headers, and other Dynamic APIs.
- Request-time card: use the route configuration and data-fetching behavior appropriate for your Next.js version, then expect more rendering work and lower cache reuse.
- Changing CMS content: define an invalidation or rebuild strategy so stale cards do not persist indefinitely.
- Stable URLs: keep the same route URL for a post unless you intentionally need a new social cache key.
Inspect the generated HTML metadata and request the image route directly in a browser or HTTP client. Social platforms may cache fetched images independently of Next.js, so changing pixels does not always produce an immediate preview refresh.
8. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Build fails because the file is too large | Static image exceeds 8 MB | Compress it, reduce dimensions, or generate the image. |
| Blank or partially rendered card | Unsupported CSS, missing dimensions, or an exception in route data | Use flexbox, explicit width/height, and log or handle failed fetches. |
| Font is ignored | Font bytes were not loaded or the family name does not match | Load the font as an ArrayBuffer and use the same family name in JSX. |
| Dynamic route returns a 404 | File is outside the intended route segment | Place opengraph-image.tsx beside the page or layout that owns the route. |
| Old example fails type checking | Promise-based signatures changed between versions | Check current docs for params and id types. |
| Preview shows an old image | Next.js or the social crawler cached it | Confirm the route response, then use the platform’s refresh/debug tool. |
| External image does not appear | Remote URL is unavailable to the renderer or requires browser state | Fetch and embed a local asset or a data representation, and avoid authenticated browser-only URLs. |
9. Performance, reliability, and cost
Generation cost is driven by route execution, data fetches, font loading, and image processing. Keep the JSX tree small, cache stable data, load fonts once where possible, and avoid making several independent remote requests for one card. A static or cached route is generally easier to scale than a request-time route.
Make failures deterministic. Validate the slug before fetching, return a deliberate fallback title when optional CMS fields are absent, and avoid putting secrets in image URLs. If a required data request fails, returning an error is preferable to silently publishing a misleading card. Test long titles, missing author names, non-Latin text, dark and light backgrounds, and narrow glyphs.
For deployment, verify that the runtime supports the APIs used by your generator, especially filesystem access and font loading. If your design depends on full browser rendering, CSS Grid, JavaScript widgets, cookie state, or charts, a browser screenshot pipeline is a better fit than forcing those features into Satori’s subset.
10. Or skip the browser setup
If your goal is a rendered screenshot of a live page, ScreenshotNeo provides a single HTTP request. It can capture PNG, JPEG, WebP, or PDF, including full pages, a selected CSS element, custom CSS and JavaScript, device presets, dark mode, retina scale, waits, blocked resources, cookies, headers, geolocation, and other options. See the ScreenshotNeo API documentation for parameter details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also includes an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. Create a free ScreenshotNeo account.
11. Verification checklist
- Open the generated route directly and confirm its HTTP content type.
- Inspect page source for the expected
og:imagemetadata. - Test a nested route to confirm precedence over the parent image.
- Try long titles, missing data, unusual characters, and both themes.
- Check static files against the 8 MB Open Graph and 5 MB Twitter-image limits.
- Confirm cache behavior matches your freshness requirement.
- Use a social preview debugger after deployment to check the crawler can fetch the public URL.
FAQ
Can I use a JPG instead of PNG?
Yes. Static convention files support JPG/JPEG, PNG, and GIF. Generated routes should export the content type that matches the response you produce.
Does an OG route need a page component?
It belongs in a route segment with your page, layout, or other metadata files, but the image handler itself is a separate special metadata route.
Can generated images use arbitrary HTML?
No. The renderer supports a subset of HTML and CSS. Build layouts around flexbox and test every visual feature you depend on.
Should changing a post always regenerate its image?
Only if the route is configured to observe changing data. Otherwise, static optimization and caches can preserve the build-time result.
When should I use a browser screenshot instead?
Use one when the source is an already-rendered page or requires browser behavior such as JavaScript widgets, full CSS support, consent handling, or a PDF of the page.


