Next.js OG images: what next/og can't render, and a fix

next/og (ImageResponse, built on Satori) is the default way to make Open Graph images in Next.js. It's fast and free, but it isn't a browser: it draws a subset of CSS onto an SVG. When your card design uses CSS it doesn't support, the route either throws or quietly renders the wrong thing. We measured exactly which features break, then built a drop-in opengraph-image.tsx that renders plain HTML in real Chromium instead.

What next/og gets wrong (measured)

We rendered the same 16 small test cards with ImageResponse from next/og (Next.js 16.4.2, which bundles @vercel/og 1.0.1) and with Chromium, then checked pixels at known spots. Chromium got all 16 right. next/og got 8 wrong, and two of those throw, which makes the image route return a 500:

CSS featurenext/og result
display: gridThrows: Invalid value for CSS property "display" (only flex, block, contents, none)
conic-gradient()Throws: Invalid background image
z-indexIgnored, with a "not supported" warning; the later element always paints on top
calc() sizesIgnored, with a warning; the width falls back to auto
mix-blend-modeNot applied
outlineNot drawn
backdrop-filterNot applied (no frosted glass)
<style> + classNameIgnored: inline style props only (or the tw prop)
box-shadow, text-shadow, filter, clip-path, mask-image, CSS variablesOK
Emoji, Japanese textOK, but only because it downloads the glyphs from jsdelivr and Google Fonts at render time
it needs outbound network access when the image is generated

Fonts are the other common snag. You pass font files as buffers, and a .woff2 file throws Unsupported OpenType signature wOF2: only TTF, OTF and WOFF work. You can't use a Google Fonts <link> either, so every font and weight has to be fetched and passed by hand.

Pick next/og when your card is a few flex boxes, text and an image: it's the cheapest option. When the design comes from your site's real CSS (grid layouts, Tailwind classes, a web font, glass effects), render it in a browser instead.

A drop-in opengraph-image.tsx that renders HTML in Chromium

Next.js lets opengraph-image.tsx return any Response, not just an ImageResponse. So the file can build an ordinary HTML string, send it to the Shotmint screenshot API, and return the PNG. Next.js still writes the og:image, width, height and alt tags for you. The card below uses grid, a conic gradient, backdrop-filter, a Google Font and an emoji, which covers four of the failures in the table:

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

// Render every card once at build time (1 credit each) instead of on every crawler hit.
export { generateStaticParams } from './page';

const esc = (s: string) =>
  s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const title = slug.replace(/-/g, ' '); // swap in your CMS lookup

  // Plain HTML + CSS: grid, conic gradients, backdrop-filter, web fonts and emoji all work.
  const html = `<!doctype html><html><head>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@700&display=swap" rel="stylesheet">
<style>
  body { margin: 0; width: 1200px; height: 630px; display: grid; grid-template-columns: 1fr 380px;
         background: conic-gradient(from 200deg at 80% 30%, #4f46e5, #06b6d4, #a855f7, #4f46e5);
         font-family: system-ui, sans-serif; }
  .text { padding: 72px; display: flex; flex-direction: column; justify-content: space-between; color: #fff; }
  h1 { font: 700 68px/1.05 Fraunces, serif; margin: 0; text-transform: capitalize; text-wrap: balance; }
  .site { font-size: 28px; opacity: .85; }
  .glass { margin: 48px; border-radius: 28px; background: rgb(255 255 255 / .15);
           backdrop-filter: blur(18px); display: grid; place-items: center; font-size: 150px; }
</style></head><body>
  <div class="text"><h1>${esc(title)}</h1><div class="site">yourblog.com</div></div>
  <div class="glass">🚀</div>
</body></html>`;

  const res = await fetch('https://shotmint.creasework.com/v1/screenshot', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.SHOTMINT_KEY! },
    body: JSON.stringify({ html, width: 1200, height: 630, wait_until: 'networkidle' }),
  });
  if (!res.ok) throw new Error(`Shotmint ${res.status}: ${await res.text()}`);
  return new Response(await res.arrayBuffer(), { headers: { 'Content-Type': 'image/png' } });
}

The generateStaticParams re-export matters. Without it, next build lists the image route as ƒ (Dynamic) and renders a fresh card (one credit) every time a crawler or chat app fetches it. With it, every card is prerendered once at build time, and the build output shows ● (SSG) for each image. Here's the page it pairs with:

export function generateStaticParams() {
  return [{ slug: 'shipping-faster-with-preview-deploys' }, { slug: 'why-we-moved-to-postgres' }];
}

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

Put your key in .env.local as SHOTMINT_KEY=sm_… (and in your host's environment variables). It's only read on the server at build time and never reaches the browser. Set metadataBase in your root layout, or Next.js writes http://localhost:3000 into og:image.

What we tested

A fresh Next.js 16.4.2 app with the two files above: next build prerendered both cards (two API credits), next start served /blog/why-we-moved-to-postgres/opengraph-image as a 1200×630 PNG with the Fraunces title, gradient, frosted panel and color emoji, and the post page carried the matching og:image tags. The comparison table comes from a script that ran the same element trees through next/og's ImageResponse and through Chromium.

Cost

One credit per card, rendered once per build. €5 buys 2,000 images and credits never expire, and a free key gives you 25 renders with no signup. A blog with 200 posts uses 200 credits per full rebuild. If you rebuild often, keep the cards dynamic, pass cache_ttl: 86400 in the JSON body and set a long Cache-Control on the response, so repeats are served from cache.

Other ways to do it

Related: HTML to PDF in Next.js, screenshots on Lambda and Vercel, html2canvas alternative.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: Use cases

All guides: guides · integrations · comparisons