Skip to main content
Back to Blog
5 min read

OG image generation in Next.js 16 that doesn't cost you sleep

Dynamic OG images make your links look professional on Twitter and Slack. Here's the setup that works in production without edge-case surprises — and the approaches I stopped using.

next.jsperformance

When someone shares your link on Twitter, Slack, or LinkedIn, the OG image is the first thing they see. A generic fallback image says "I didn't bother." A well-designed dynamic image says "this is a real product, maintained by someone who cares."

Next.js has built-in OG image generation via next/og. It works. But the default tutorials skip the production gotchas. Here's the setup I use after burning time on three different approaches.

The approach that works: next/og with ImageResponse

Next.js provides ImageResponse from next/og, which renders JSX to a PNG at request time. It runs on the Edge runtime and uses Satori (a layout engine that converts a subset of HTML/CSS to SVG, then to PNG).

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

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

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

  return new ImageResponse(
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        padding: '60px',
        background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
        color: 'white',
        fontFamily: 'Inter',
      }}
    >
      <div style={{ fontSize: 48, fontWeight: 700, lineHeight: 1.2 }}>
        {post.title}
      </div>
      <div style={{ fontSize: 24, opacity: 0.7, marginTop: 20 }}>
        yoursite.com
      </div>
    </div>,
    { ...size }
  )
}

Why this works: Next.js auto-discovers opengraph-image.tsx files and adds the correct <meta property="og:image"> tag. No manual wiring. The image is generated at build time for static pages (SSG) or on-demand for dynamic ones.

Custom fonts

The default system font looks generic. Loading a custom font makes OG images match your brand. Here's the catch: Satori can't read .woff2 files. You need .ttf or .otf.

export default async function Image({ params }) {
  const fontData = await fetch(
    new URL('../../assets/Inter-Bold.ttf', import.meta.url)
  ).then((res) => res.arrayBuffer())

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

Gotcha: fetch with a relative URL works in development but can fail in production if the file isn't included in the edge bundle. Place the font file in the same directory or use an absolute URL to a CDN-hosted copy.

Size matters: A 500KB TTF file loads on every OG image request. Subset your font to only the characters you need (Latin + Cyrillic if you support those locales). I use pyftsubset from fonttools — it typically cuts the file from 500KB to 50KB.

The CSS subset

Satori supports a subset of CSS. This trips people up:

Supported: display: flex, flexDirection, justifyContent, alignItems, padding, margin, border, borderRadius, background, color, fontSize, fontWeight, lineHeight, opacity, position, top/left/right/bottom, overflow: hidden, linear gradients.

Not supported: grid, gap (use margin instead), box-shadow, CSS variables, transform, animations, text-overflow: ellipsis (do truncation in JS).

If you try unsupported CSS, Satori silently ignores it. Your image renders without the style — no error, just a layout that looks wrong.

My approach: keep OG image layouts dead simple. One flex column, text, maybe a logo or accent bar. If you're fighting Satori's CSS support, your design is too complex for a 1200×630 preview image.

Text truncation

Long titles break layouts. Satori doesn't support text-overflow: ellipsis, so you need to truncate in code:

function truncate(str: string, maxLength: number) {
  if (str.length <= maxLength) return str
  return str.slice(0, maxLength - 1) + '…'
}

// In the JSX:
;<div style={{ fontSize: 48 }}>{truncate(post.title, 80)}</div>

80 characters is a safe max for a 48px font at 1200px width with 60px padding on each side. Test with your actual font — character widths vary.

Caching

OG images are expensive to generate (50–200ms per request). Cache them aggressively.

For static pages (SSG), Next.js generates the image at build time. No runtime cost.

For dynamic pages, add cache headers:

export const revalidate = 86400 // Cache for 24 hours

Or if you're using generateStaticParams, the OG images for those paths are generated at build time automatically.

The Slack cache trap: Slack caches OG images aggressively. If you update an image and reshare the link, Slack shows the old one. There's no way to force a refresh from your end — Slack re-fetches on its own schedule. Don't panic when your updated image doesn't show immediately.

Approaches I stopped using

Puppeteer/Playwright screenshots: Spin up a headless browser, load an HTML page, take a screenshot. Works, but it's slow (2–5 seconds per image), requires a browser binary in your deployment, and has memory issues at scale. I used this on two projects before next/og existed. Wouldn't go back.

External services (Cloudinary, Bannerbear): These work well but add a dependency and a per-image cost. For a portfolio or small SaaS with hundreds of pages, next/og is free and keeps everything in your codebase.

Static fallback images: One generic image for all pages. Better than nothing, but a dynamic image with the page title gets significantly more clicks. The effort to set up next/og is 30 minutes — the CTR improvement is permanent.

The minimal setup

If you want the simplest possible OG image setup that still looks professional:

  1. One opengraph-image.tsx in your blog [slug] directory
  2. Dark gradient background, white text, your site URL at the bottom
  3. Custom font (subsetted, <50KB)
  4. Title truncation at 80 characters
  5. 24-hour revalidation for dynamic pages

That's 40 lines of code. It covers every blog post, every page, and makes every shared link look intentional instead of accidental.


Want your Next.js site to look polished everywhere it gets shared? Let's talk — OG images are part of the technical SEO baseline I ship on every project.