ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team29 September 20269 min read

Next.js OG Image Generator: Static Files, Dynamic Routes, and Production Patterns

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:

Route data can become a generated social card through an ImageResponse metadata route.
Route data can become a generated social card through an ImageResponse metadata route.
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.

A screenshot pipeline can clean browser overlays before returning the page image.
A screenshot pipeline can clean browser overlays before returning the page image.
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:image metadata.
  • 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.