ScreenshotNeo

BlogHow-to

How to Generate Dynamic Open Graph Images in Next.js

Generate route-specific Open Graph images in Next.js with opengraph-image, ImageResponse, dynamic data, caching, troubleshooting, and deployment guidance.

By the ScreenshotNeo team29 September 20269 min read

How to Generate Dynamic Open Graph Images in Next.js

Use the App Router’s opengraph-image file convention and return an ImageResponse from next/og. Place the file in the route segment that owns the page, export alt, size, and contentType, then read the current route parameters to render a different image for each page. Next.js adds the corresponding Open Graph metadata to the document head automatically.

This guide shows a complete implementation for blog posts, explains static and dynamic generation, covers Next.js 16’s promise-based params, and includes cache, font, data, deployment, debugging, and cost considerations.

1. Choose the image convention for your route

Next.js supports literal image files and code-generated opengraph-image and twitter-image files. A file in the root app segment can provide a site-wide default. A file in a deeper segment overrides it for that route, so app/blog/[slug]/opengraph-image.tsx is a natural choice for per-article previews.

Need Recommended approach
One fixed preview for the site Use a static app/opengraph-image.png or one generated root image.
A different image for each slug Use app/blog/[slug]/opengraph-image.tsx.
Data that changes at request time Use a generated route and deliberately configure fetching and caching.
Simple artwork maintained by a designer Use a static file and an accompanying opengraph-image.alt.txt.

The official convention and metadata behavior are documented in the Next.js opengraph-image and twitter-image reference. The documented generated-image example uses 1200 × 630 pixels. Treat that as a useful framework example, then confirm requirements for the platforms where your links will be shared.

2. Install the required Next.js pieces

In a current App Router project, ImageResponse is imported from next/og. Keep the image file on the server; it is a route handler-like convention and does not need to be a Client Component.

npm install next react react-dom

Check the version in your project before copying the parameter type below. Next.js introduced the convention in 13.3.0, and the current documentation uses promise-based params in Next.js 16.

3. Create a dynamic image for every blog post

Assume this route exists:

A route-specific slug can drive a generated Open Graph image through the App Router convention.
A route-specific slug can drive a generated Open Graph image through the App Router convention.
app/
  blog/
    [slug]/
      page.tsx
      opengraph-image.tsx

Create app/blog/[slug]/opengraph-image.tsx:

import { ImageResponse } from 'next/og'

export const alt = 'Blog article preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

type ImageProps = {
  params: Promise<{ slug: string }>
}

export default async function Image({ params }: ImageProps) {
  const { slug } = await params
  const title = slug
    .split('-')
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          background: '#111827',
          color: 'white',
          padding: '72px',
          fontSize: 64,
        }}
      >
        <div style={{ display: 'flex', fontSize: 28, color: '#93c5fd' }}>
          Example Blog
        </div>
        <div style={{ display: 'flex', maxWidth: '1000px' }}>{title}</div>
        <div style={{ display: 'flex', fontSize: 24, color: '#d1d5db' }}>
          Read the complete guide
        </div>
      </div>
    ),
    size,
  )
}

The exported values tell Next.js the image’s alternative text, dimensions, and MIME type. They populate the generated image metadata. ImageResponse returns a valid response for the generated-image contract.

Use trusted content and escape untrusted values

React escapes text nodes, as in the example above. Keep user-controlled values in text nodes or sanitize them before placing them in styles. Do not concatenate untrusted input into CSS, remote URLs, or executable JavaScript. Limit title length so a very long article name does not overflow the canvas.

4. Load title, description, and author data

For real content, look up the slug in the same data source used by the page:

import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const alt = 'Article preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

type Props = { params: Promise<{ slug: string }> }

export default async function Image({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    return new Response('Not found', { status: 404 })
  }

  return new ImageResponse(
    (
      <div style={{ display: 'flex', flexDirection: 'column', padding: 72 }}>
        <div style={{ display: 'flex', fontSize: 30 }}>{post.category}</div>
        <div style={{ display: 'flex', fontSize: 64 }}>{post.title}</div>
        <div style={{ display: 'flex', fontSize: 26 }}>{post.author}</div>
      </div>
    ),
    size,
  )
}

If the data fetch is static or cacheable, the generated image can be statically optimized. If the title changes frequently, choose a revalidation or uncached strategy that matches your content system. The framework documentation states that generated images are statically optimized and cached by default unless they use Dynamic APIs or uncached data.

5. Make the page metadata agree with the image

The file convention supplies the Open Graph image metadata. Use metadata for static page values or generateMetadata when values depend on a slug or external data:

import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: post?.title ?? 'Article',
    description: post?.description,
  }
}

Keep the image’s visible title and the page’s metadata derived from the same record. This avoids a social card that says one thing while the page title says another.

6. Add fonts and local assets safely

Images can use custom fonts and local assets, but the renderer must be able to read them at generation time. Bundle font data with the application or load it using a supported server-side method, then pass it through the fonts option of ImageResponse. Confirm that your deployment target supports the required runtime and asset access. A missing font usually falls back to a system font or causes a generation error, depending on how it is loaded.

Remote logos and photographs add another failure point. Verify that the source is reachable by the server, returns an image content type, and does not require browser-only authentication. For a resilient card, render a colored background and text fallback when an optional asset cannot be loaded.

7. Control caching and freshness

There are three practical policies:

  1. Build-time generation: best for published posts that change rarely. It reduces request-time work but requires a rebuild when content changes.
  2. Cached dynamic generation: fetch content with an intentional revalidation period. This balances freshness and rendering cost.
  3. Request-time generation: use uncached data or dynamic APIs when every request must reflect current content. Expect more renderer work and plan for failures.

Do not promise that a social network will refresh immediately after your image changes. Crawlers and messaging platforms maintain their own caches. Validate the deployed URL with the relevant platform’s current debugging tools and documentation.

8. Static files, Twitter cards, and size limits

You can add twitter-image.tsx when you need a separate image for Twitter card metadata. Static convention files can include opengraph-image.alt.txt or twitter-image.alt.txt for alternative text. Next.js documents an 8 MB maximum for static Open Graph image files and a 5 MB maximum for static Twitter image files; exceeding those limits fails the build. These are Next.js file-convention limits, not a guarantee about every generated response or consuming platform.

9. Test the generated route

  1. Run the development server and open /blog/your-slug/opengraph-image directly.
  2. Check that the response has the expected MIME type and dimensions.
  3. Open the page source or rendered head and verify the generated og:image, width, height, type, and alt values.
  4. Test a slug with long text, non-ASCII characters, missing optional fields, and a missing record.
  5. Deploy and test the public URL from outside your local network.

10. Troubleshooting common errors

Symptom Likely cause Fix
params is treated as an object Using an older example with Next.js 16. Type it as a promise and use const { slug } = await params. For older versions, follow that version’s convention.
Image route returns 404 File is outside the route segment or named incorrectly. Use exactly opengraph-image.tsx under the owning segment.
Text or layout is missing Unsupported CSS or a flex child without display: 'flex'. Use the CSS supported by the image renderer and set flex display explicitly on layout containers.
Generation fails only in production Missing font, asset, environment variable, or runtime support. Log the failing fetch, bundle required assets, and verify deployment runtime requirements.
Old preview remains visible Social crawler cache. Confirm the deployed image URL first, then use the platform’s refresh or debugger workflow.
Card is clipped Title is longer than the fixed canvas. Clamp text, reduce font size, or render a controlled number of lines.
Build becomes slow Too many uncached image generations or data fetches. Cache stable data, generate at build time where appropriate, and avoid loading large remote assets.

11. Performance, reliability, and cost notes

Generated cards consume server or build resources. Keep the JSX tree small, avoid unnecessary remote requests, and cache post data. A stable 1200 × 630 canvas makes layout predictable. If a card is requested repeatedly, framework caching can prevent repeated rendering, but cache invalidation must match your publishing workflow.

Reliability improves when the image can render without optional assets. Provide a default background, handle missing posts with a 404, set timeouts on external data access where your data client supports them, and monitor generation errors. The dossier does not establish a universal social-platform refresh schedule, deployment benchmark, or runtime limit, so verify those details for your host and target networks.

12. Or skip the browser setup

If your goal is to capture an already-rendered page rather than generate an OG card inside Next.js, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be disabled.

ScreenshotNeo removes common consent banners, popups, and chat widgets before capture.
ScreenshotNeo removes common consent banners, popups, and chat widgets before capture.

Only clean shots are billed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response reports the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo documentation for all options. A direct call looks like this:

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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', data);

ScreenshotNeo includes full-page capture, CSS-selector element capture, dark mode, device presets, custom viewports, retina scale, custom CSS and JavaScript, waits, blocked resources, headers, cookies, user agents, timezone and geolocation, transparent backgrounds, resizing, selectable cache TTLs, signed links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, and an OpenAPI specification. It also accepts parameter names used by other screenshot APIs, which helps when switching.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.

13. FAQ

Can I generate one image for every dynamic route?

Yes. Put opengraph-image.tsx inside the dynamic segment and derive the design from its awaited params.

Do I have to use 1200 × 630?

No. It is the size shown in the official Next.js example. Choose dimensions for your distribution targets and verify their current guidance.

Should I use generateMetadata instead?

Use generateMetadata for route-dependent metadata values. Use the image file convention to generate the image itself; the two APIs work together.

Why does my updated title not appear immediately?

Next.js and the social platform may cache different layers. Confirm the image route first, then use the destination platform’s refresh tooling.

Can a generated image use external data?

Yes, but the fetch and cache policy determine whether the result is static, revalidated, or generated at request time. Design a fallback for unavailable data.