// next.js · May 10, 2026

Dynamic OG images in Next.js — a practical guide

Generate a unique Open Graph image for every page in your Next.js app. Two real approaches, working code, and the honest trade-offs that decide which one is right for you.

The two approaches, in one paragraph

You have two real options. The first is template-based: build a JSX or HTML template, render it to PNG via @vercel/og (which uses Satori under the hood), and serve the result from a route. The second is screenshot-based: take an actual headless Chromium screenshot of your live page and use that as the OG image. Both work; they have different costs.

Approach 1 — opengraph-image.tsx with @vercel/og

In the App Router, drop a file called opengraph-image.tsx next to any page.tsx and Next.js wires up the meta tag for you. Here is a working example for a blog post:

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

export const runtime = 'edge'
export const alt = 'Blog post preview'
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: 'space-between',
          padding: 64,
          background: 'linear-gradient(135deg,#0f172a 0%,#1e293b 100%)',
          color: 'white',
          fontFamily: 'Inter',
        }}
      >
        <div style={{ fontSize: 24, opacity: 0.6 }}>acme.com / blog</div>
        <div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 28, opacity: 0.7 }}>
          {post.author} · {new Date(post.date).toLocaleDateString()}
        </div>
      </div>
    ),
    { ...size }
  )
}

That is the entire file. Next.js handles the meta tag injection, edge caching, and revalidation. You get a unique OG image per blog post for free.

Where Satori starts to hurt

@vercel/og is built on Satori, which converts JSX into SVG. It is a clever piece of engineering and free, but the tradeoff is a strict CSS subset:

  • Flexbox only. No CSS Grid. Most layouts you would naturally write with grid-template-columns have to be translated.
  • No arbitrary TailwindCSS values. w-[42rem] and bg-[#abc] in your real app become inline-style equivalents inside the OG template.
  • Manual font loading. Fetch .ttf/.otf, pass as ArrayBuffer, register every weight separately. Variable fonts work in narrow cases.
  • Limited filters. backdrop-blur, complex shadows, gradients with multiple stops, and pseudo-elements all hit edge cases.
  • No client JS. Anything that depends on a browser at runtime (animations, charts, interactive widgets) must be re-rendered statically.

For text-and-logo cards, none of this matters. For "I want my real product page as the OG card," all of it does.

Approach 2 — point your meta tag at a screenshot service

The second approach skips templates entirely. You take a screenshot of the live page using a hosted Chromium service, and point the meta tag at it. The page itself is the design — there is no second copy to keep in sync.

tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const url = `https://acme.com/blog/${params.slug}`

  return {
    title: 'Blog post · Acme',
    openGraph: {
      images: [
        {
          // Linkshot generates a screenshot of the live URL.
          url: `https://uselinkshot.com/api/og/v1/${process.env.LINKSHOT_TEMPLATE_ID}?url=${encodeURIComponent(url)}`,
          width: 1200,
          height: 630,
        },
      ],
    },
  }
}

That is the entire integration. The first time someone shares the URL, Linkshot screenshots the live page; the result is cached at the edge. Every subsequent share serves the cached PNG in milliseconds.

See Vercel OG vs Linkshot for the full side-by-side, including the linkshot: TailwindCSS modifier that lets you hide elements (navbar, cookie banner, chat widget) only during screenshot capture.

Side-by-side comparison

Both approaches generate per-page OG images. The differences:

  • Design fidelity. Templates approximate your page in a CSS subset; screenshots are your page.
  • Maintenance. A template is a second codebase that must track every site redesign. A screenshot has zero copies of design state.
  • Cost. @vercel/og is free OSS but you pay Vercel for invocations. Linkshot is $9/mo entry tier with edge caching and a 7-day trial.
  • Runtime cost. Satori's SVG path is fast — usually 50-200ms cold. Chromium screenshots are slower cold (~300-800ms) but Linkshot caches per URL, so warm hits are CDN-fast.
  • Lock-in. @vercel/og is most natural on Vercel deploys. Linkshot is a hosted URL — works identically on Cloudflare, Netlify, AWS, self-hosted.

Caching and revalidation

Whichever approach you pick, OG image caching has two layers you control:

  1. Your edge cache. For opengraph-image.tsx, set export const revalidate = 3600 for a 1-hour TTL. For a custom Route Handler, set cache-control headers explicitly.
  2. The crawler cache. Facebook keeps OG images for ~30 days, X for ~7 days. To force a refresh, use the platform's share debugger or change the og:image URL by appending a version query string (?v=2).

For dynamic content (e.g. user dashboards), prefer URL-keyed caching with a content hash in the path, so each version of the OG image gets its own URL. Old versions evict naturally.

When each approach is right

Use @vercel/og when:

  • You only need text-and-logo cards.
  • You want the cheapest possible runtime cost.
  • You are already deeply on Vercel and don't mind the CSS subset.
  • You don't plan to redesign the OG card often.

Use a screenshot-based service when:

  • Your OG card should look like your real page (a hero, a chart, a product photo, a stylized header).
  • You don't want to maintain a second JSX template that has to track every redesign.
  • Your design uses CSS Grid, container queries, complex filters, or arbitrary Tailwind values.
  • You need framework portability — same OG URL works on Next.js, Astro, Remix, plain HTML.

Common mistakes in Next.js specifically

  1. Forgetting metadataBase. Next.js logs a warning if your openGraph.images URLs are relative and no metadataBase is set on the root layout. Always set it: metadataBase: new URL('https://acme.com').
  2. Mixing the App and Pages routers. opengraph-image.tsx only works in the App Router. In the Pages Router, you build the OG image via a Route Handler and inject the meta tag from <Head> in _document.tsx or per-page.
  3. Using static import paths inside ImageResponse. You cannot import logo from './logo.svg' and use it as a <img src>. Fetch it as a buffer or pass an absolute URL.
  4. Edge runtime cold starts. If you set runtime: 'edge' but import a heavy Node-only library, the build fails. Use nodejs runtime when in doubt — the cold start is only marginally slower for OG generation.

Frequently asked questions

Should I use @vercel/og or screenshot my real page?

For a single static card with text and a logo, @vercel/og is fine and free. For per-page OG that mirrors your live design (blog posts, product pages, dashboards), screenshotting the live page is simpler — no second template to maintain, no CSS subset to fight, no drift between site and card.

Does opengraph-image.tsx work with the App Router?

Yes. opengraph-image.tsx (or .ts, or .png, or .jpg) is App Router-only — it sits next to a page.tsx and Next.js auto-injects the og:image meta tag at the route. The Pages Router uses a different pattern via api routes and explicit <Head> tags.

Can I use TailwindCSS classes inside @vercel/og?

Partially. Satori implements a flexbox-only subset of CSS. Standard Tailwind classes like flex, p-8, text-2xl work. Arbitrary values like w-[42rem], CSS Grid, container queries, and many filter/backdrop utilities do not. The compatibility matrix is documented but easy to hit unexpectedly.

How do I cache OG images on Vercel?

opengraph-image.tsx is automatically cached at the edge and revalidates on deploy. For ISR-style time-based revalidation, export const revalidate = 3600 from the file. For URL-keyed caching with custom headers, use a Route Handler at app/api/og/route.ts and set cache-control yourself.

What is the right size for Next.js OG images?

Use 1200×630 for ImageResponse — it matches every social platform. Set the size export in opengraph-image.tsx: `export const size = { width: 1200, height: 630 }`. Smaller sizes are downscaled by Facebook, larger ones risk hitting the 5MB limit.

Can I use my Next.js page itself as the OG image?

Not natively — Next.js does not ship a screenshot endpoint. You either render with @vercel/og (template-based) or call a hosted screenshot API like Linkshot from your generateMetadata function. The latter takes one fetch call and gives you the live design, no template.

Does generateMetadata run at build time or request time?

It depends on the route. Static routes evaluate generateMetadata at build, dynamic routes evaluate per-request. For OG images that need fresh data (e.g. user-generated content), put the page on a dynamic segment or set `dynamic = "force-dynamic"` so the metadata always reflects current state.

How do I test my Next.js OG images locally?

Hit the route directly: `http://localhost:3000/your-page/opengraph-image` returns the PNG. For the meta tags, view source on the page and look for og:image — the URL there is what crawlers will fetch. Then run it through a real share debugger before deploying.

Skip the JSX template

Linkshot screenshots your real Next.js page directly. Full TailwindCSS, no Satori subset, no second design to maintain. One <meta> tag, every page.

7-day free trial · no credit card required