Next.js

Dynamic OG Images in Next.js with opengraph-image.tsx

How to generate a unique Open Graph image per page at build time using Next.js App Router's opengraph-image.tsx convention, with static export support.

Most Next.js sites ship with a single static OG image shared across every page. It works, but it's a missed opportunity. When someone shares your article on X or LinkedIn, the preview card shows the same generic image regardless of which post it is. With Next.js App Router's opengraph-image.tsx file convention, you can generate a unique image per page at build time with no extra infrastructure.

This is what I added to this site. Every notebook article now gets its own generated PNG, built statically alongside the page HTML. Here is exactly how it works.

How the convention works

Next.js treats a file named opengraph-image.tsx as a special route segment. At build time it calls generateStaticParams() (if exported) and renders one PNG per param set, writing the files directly into the static output directory. No server runtime is involved. The framework also automatically injects the correct <meta property="og:image"> tag pointing to the generated file.

This works with output: 'export' (fully static sites) as well as server-rendered deployments. For a static site, the generated images land at paths like:

out/notebook/nextjs/my-post/opengraph-image

Setting up the file

Place opengraph-image.tsx inside the same dynamic segment as your page:

app/
  notebook/
    [section]/
      [slug]/
        page.tsx
        opengraph-image.tsx   ← add this

The file needs five exports:

import { ImageResponse } from 'next/og'

export const alt = 'paulund.co.uk'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export async function generateStaticParams() {
    // same logic as page.tsx — must be declared here directly
}

export default async function Image({
    params,
}: {
    params: Promise<{ section: string; slug: string }>
}) {
    const { section, slug } = await params
    // fetch your data, return an ImageResponse
}

ImageResponse comes from next/og, which is bundled with Next.js itself. No additional packages needed.

generateStaticParams must live in this file

opengraph-image.tsx is treated as an independent route handler. It cannot inherit generateStaticParams from the parent page.tsx. Attempting to re-export it does not work reliably:

// This does not work:
export { generateStaticParams } from './page'

Declare it directly in the file, calling the same data functions:

export async function generateStaticParams() {
    const sections = getAllSections()
    const params: { section: string; slug: string }[] = []
    for (const section of sections) {
        const notes = getSectionNotes(section.slug)
        for (const note of notes) {
            params.push({ section: section.slug, slug: note.slug })
        }
    }
    return params
}

Fonts: TTF only, not WOFF2

ImageResponse uses Satori under the hood. Satori renders JSX to SVG, then to PNG. It only supports TTF and OTF font formats. WOFF2 will throw at build time:

Error: Unsupported OpenType signature wOF2

The simplest workaround is to request fonts from the Google Fonts CSS API with a user-agent that causes it to return TTF instead of WOFF2:

const fontCss = await fetch(
    'https://fonts.googleapis.com/css2?family=Inter:wght@700',
    {
        headers: {
            'User-Agent':
                'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0',
        },
    }
).then((r) => r.text())

const fontUrl = fontCss.match(/url\((.+?)\)/)?.[1] ?? ''
const fontData = await fetch(fontUrl).then((r) => r.arrayBuffer())

Then pass it to ImageResponse:

return new ImageResponse(jsx, {
    ...size,
    fonts: [{ name: 'Inter', data: fontData, style: 'normal', weight: 700 }],
})

This fetch runs at build time, once per static generation job. If your CI environment has no network access, download the TTF file, commit it to public/fonts/, and load it with fs.readFileSync instead.

Satori layout constraints

Satori supports a subset of CSS. A few things that will silently fail or throw:

  • No CSS grid. Use display: 'flex' everywhere, including on elements that just need to exist in the tree.
  • No text-overflow: ellipsis. Truncate long strings in JavaScript before passing them to JSX.
  • No filter: blur(). You cannot apply a blur filter to elements. To simulate a soft bokeh background, layer multiple large radial-gradient divs at different positions with low opacity.
  • position: absolute requires the parent to have both position: 'relative' and display: 'flex'.
  • Explicit dimensions on the root. Set width and height as inline styles on the outermost div. Satori does not infer canvas size from CSS.

Here is the bokeh background pattern this site uses:

<div style={{ width: '1200px', height: '630px', display: 'flex', position: 'relative', background: '#06081a', overflow: 'hidden' }}>
    {/* Cyan orb — top left */}
    <div style={{
        position: 'absolute', top: '-120px', left: '-80px',
        width: '580px', height: '580px', borderRadius: '290px',
        background: 'radial-gradient(circle, rgba(14,165,233,0.6) 0%, transparent 65%)',
        display: 'flex',
    }} />
    {/* Indigo orb — top right */}
    <div style={{
        position: 'absolute', top: '-60px', right: '-120px',
        width: '520px', height: '520px', borderRadius: '260px',
        background: 'radial-gradient(circle, rgba(99,102,241,0.55) 0%, transparent 65%)',
        display: 'flex',
    }} />
    {/* Purple orb — bottom right */}
    <div style={{
        position: 'absolute', bottom: '-180px', right: '120px',
        width: '600px', height: '600px', borderRadius: '300px',
        background: 'radial-gradient(circle, rgba(139,92,246,0.5) 0%, transparent 65%)',
        display: 'flex',
    }} />

    {/* Centred content */}
    <div style={{
        position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center',
        padding: '80px', gap: '28px',
    }}>
        <div style={{ display: 'flex', color: 'rgba(255,255,255,0.85)', fontSize: '36px' }}>
            {'{ }'}
        </div>
        <div style={{
            display: 'flex', flexWrap: 'wrap', justifyContent: 'center',
            color: '#ffffff', fontSize: '68px', fontWeight: 700,
            lineHeight: 1.2, textAlign: 'center', letterSpacing: '-0.02em',
        }}>
            {title}
        </div>
        <div style={{ display: 'flex', color: 'rgba(255,255,255,0.55)', fontSize: '22px', letterSpacing: '0.08em' }}>
            paulund.co.uk
        </div>
    </div>
</div>

Scale the font size based on title length to avoid overflow:

const fontSize = title.length <= 40 ? 68 : title.length <= 70 ? 52 : 40

Removing the static fallback

If your existing generateMetadata explicitly sets openGraph.images to a fallback, that explicit value takes precedence over the file-convention image. Remove the fallback:

// Before
images: note.image ? [{ url: note.image }] : [{ url: '/images/og-default.jpg' }],

// After — undefined lets the file convention supply the image
images: note.image ? [{ url: note.image }] : undefined,

Posts with a custom image in frontmatter keep their explicit override. Everything else gets the generated image.

Verifying the output

After running next build, check that PNG files were generated:

ls out/notebook/nextjs/my-post/opengraph-image

The file has no extension in the output but is a valid PNG. Open it in a browser or copy it with a .png extension to preview. Check that the built HTML for the page includes the correct og:image meta tag pointing to the generated path.

What it looks like in practice

This site generates one image per notebook article — around 160 at the time of writing. The build fetches the font once, then renders each image from the same JSX template with different title text. Total build-time overhead is a few seconds.

The file-convention approach is clean: one file handles all the routing, param enumeration, data fetching, and image rendering. Next.js wires up the meta tags automatically. There is no separate image service, no CDN cache to warm, and nothing to deploy beyond the static files that were already going to Cloudflare Pages.

← Older
Error and Not-Found Pages in the App Router
Newer →
Deploying Next.js to Cloudflare Pages

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.