SEO for Next.js

Next.js Dynamic OG Images with ImageResponse

next/og generates per-page OG images at build time. Here's the pattern that produces social-share cards that don't look generic.

Published May 24, 20266 min readBy RankCrab Team

A generic OG image is a missed opportunity. When someone shares your blog post on Slack or Twitter, the preview card is the first thing the reader sees — before they decide whether to click. A card showing the actual post title, author name, and publication date performs better than a static logo on a white background.

next/og makes per-page OG images practical. You write a JSX component, Next.js renders it to a PNG using Satori, and the image is available at a predictable URL. Here's the production pattern.

The File Convention

The App Router uses a file convention for OG images: create opengraph-image.tsx in the same directory as your page.tsx. Next.js automatically serves it at /<route>/opengraph-image and wires up the og:image meta tag.

app/
  blog/
    [slug]/
      page.tsx
      opengraph-image.tsx   ← OG image for /blog/[slug]
  opengraph-image.tsx       ← OG image for /  (homepage)

You don't need to manually add og:image to your metadata — Next.js handles that automatically when it finds the opengraph-image.tsx file.

Basic ImageResponse

The file exports a default function that returns an ImageResponse:

// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const runtime = 'edge'

export const alt = 'RankCrab — On-Page SEO for Developers'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default function OGImage() {
  return new ImageResponse(
    (
      <div
        style={{
          background: '#0f172a',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'flex-start',
          justifyContent: 'flex-end',
          padding: '80px',
        }}
      >
        <div style={{ color: '#38bdf8', fontSize: 24, marginBottom: 16 }}>
          rankcrab.com
        </div>
        <div
          style={{
            color: '#f8fafc',
            fontSize: 64,
            fontWeight: 700,
            lineHeight: 1.1,
          }}
        >
          On-Page SEO for Developers
        </div>
      </div>
    ),
    { ...size }
  )
}

Dynamic Images from Route Params

The real value is per-page images. For a dynamic route like /blog/[slug], the opengraph-image.tsx file receives the route params and can fetch data:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

type Props = {
  params: { slug: string }
}

export default async function OGImage({ params }: Props) {
  const post = await fetch(
    `${process.env.API_URL}/posts/${params.slug}`
  ).then((r) => r.json())

  return new ImageResponse(
    (
      <div
        style={{
          background: '#0f172a',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: '80px',
        }}
      >
        <div style={{ color: '#38bdf8', fontSize: 24 }}>RankCrab Blog</div>
        <div>
          <div
            style={{
              color: '#f8fafc',
              fontSize: 56,
              fontWeight: 700,
              lineHeight: 1.2,
              marginBottom: 24,
            }}
          >
            {post.title}
          </div>
          <div style={{ color: '#94a3b8', fontSize: 24 }}>
            {post.author} · {new Date(post.publishedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
          </div>
        </div>
      </div>
    ),
    { ...size }
  )
}

Loading Custom Fonts

Satori only supports fonts you provide explicitly — system fonts don't transfer. Load fonts from your public folder or from a CDN:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

async function loadFont() {
  const response = await fetch(
    new URL('/fonts/Inter-Bold.ttf', process.env.NEXT_PUBLIC_SITE_URL!)
  )
  return response.arrayBuffer()
}

type Props = {
  params: { slug: string }
}

export default async function OGImage({ params }: Props) {
  const [post, fontData] = await Promise.all([
    fetch(`${process.env.API_URL}/posts/${params.slug}`).then((r) => r.json()),
    loadFont(),
  ])

  return new ImageResponse(
    (
      <div
        style={{
          fontFamily: 'Inter',
          background: '#0f172a',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          padding: '80px',
        }}
      >
        <div
          style={{
            color: '#f8fafc',
            fontSize: 60,
            fontWeight: 700,
            lineHeight: 1.1,
          }}
        >
          {post.title}
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [
        {
          name: 'Inter',
          data: fontData,
          style: 'normal',
          weight: 700,
        },
      ],
    }
  )
}

Static vs. Runtime Generation

By default, opengraph-image.tsx files generate at build time for static routes. For dynamic routes with generateStaticParams, images are generated for each pre-rendered page at build time. For dynamic routes without generateStaticParams, images are generated on first request and then cached.

The export const runtime = 'edge' directive runs generation on the Edge Runtime. This is faster than Node.js for on-demand generation (lower cold start, geographically distributed). Use it unless you need Node.js-specific APIs.

For pages where the post content changes frequently, use export const revalidate = 3600 to revalidate the image every hour alongside the page itself.

export const runtime = 'edge'
export const revalidate = 3600 // revalidate every hour

The JSX Subset Satori Supports

Satori renders a JSX subset — not full HTML/CSS. The constraints that trip people up:

  • Only flexbox layout is supported. No CSS Grid.
  • display: flex must be set explicitly on container elements — it's not the default.
  • Images must use absolute URLs. Relative paths don't resolve in the Edge Runtime.
  • background-image with linear-gradient works. Complex background shorthand may not.
  • Use style objects, not Tailwind classes (unless you use the tw prop from a Satori Tailwind plugin).

For background images (e.g., a subtle texture), fetch them as ArrayBuffer and convert to a data URL:

const bgImage = await fetch('https://yourdomain.com/og-bg.png')
  .then((r) => r.arrayBuffer())
  .then((buf) => `data:image/png;base64,${Buffer.from(buf).toString('base64')}`)

Verifying OG Images

After deploying, verify your OG images look correct across platforms. The meta tag previewer shows the rendered preview card as it would appear on Twitter/X. Things to check:

  • Image resolves to an absolute URL (check the og:image meta tag value)
  • Image dimensions are 1200×630
  • Text is not clipped or overflowing
  • File size is under 5MB (Twitter's limit)
Free tool
Meta tag + SERP previewer
See exactly how your title and meta description will render in Google.
Try it

Common Issues

"ReferenceError: Buffer is not defined" — the Edge Runtime doesn't have Node's Buffer. Use btoa(String.fromCharCode(...new Uint8Array(buf))) for base64 encoding, or switch to the Node.js runtime with export const runtime = 'nodejs'.

Font not loading in production — font fetch URLs must be absolute. Use process.env.NEXT_PUBLIC_SITE_URL to construct the full URL, and make sure the font file is in your public directory and included in your deployment.

Image not appearing in social preview — social platforms cache OG images aggressively. Use the Open Graph debugger and Twitter's Card Validator to force a cache refresh.

This article is part of the SEO for Next.js cluster. Related reading:

Ship optimized content this afternoon.

7-day trial. $29 when it converts. Cancel from the dashboard the second it stops earning its keep.