next/og (the ImageResponse API, built on @vercel/og) is the default way to make Open Graph images in Next.js, and for a title on a gradient it is excellent. It is not a browser, though: it converts JSX to SVG with Satori and rasterises it with Resvg. When your card needs CSS grid, your real stylesheet, woff2 fonts or more than 500 KB of assets, the fix is not to fight Satori — it is to render the card with real Chrome and put the resulting URL in generateMetadata.
This guide lists the limits precisely, shows when they bite, and gives a drop-in replacement that works on the Node.js and Edge runtimes.
The limits, from the Next.js docs
The ImageResponse reference states them plainly:
- Only flexbox and a subset of CSS. "Advanced layouts (e.g.
display: grid) will not work." The full list of supported properties is in Satori's README. - 500 KB bundle limit, counting your JSX, CSS, fonts, images and other assets together.
- Fonts:
ttf,otfandwoffonly — nowoff2, which is what most font services andnext/fonthand you. - Default output is 1200×630 PNG, which is the right size for Open Graph.
In practice people hit these as: a two-column card that needs grid; a brand font that only ships as woff2 (convert it, or it silently falls back); two weights of a CJK font that blow past 500 KB on their own; a logo as SVG with filters Resvg does not support; and the general cost of re-implementing a design in inline styles because the site's CSS is not available.
A note if you read Satori's own README: recent standalone Satori releases added CSS grid and WOFF2, but the @vercel/og that Next.js bundles (1.0.x in Next 16, built on an older Satori) does not have them — the Next.js limits above are the ones that apply to next/og.
None of this is a bug. Satori trades completeness for running anywhere in milliseconds. If your card fits inside it, keep it.
Option A: keep next/og, but stay inside the lines
If the limits are only fonts and size, fix those and stay with ImageResponse:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
// ttf/otf parse fastest; subset the font to Latin to stay far below 500 KB.
const inter = readFile(join(process.cwd(), 'assets/Inter-Bold-latin.ttf'));
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const post = await getPost((await params).slug);
return new ImageResponse(
(
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
width: '100%', height: '100%', padding: 72, background: '#0b0a09', color: '#fff' }}>
<div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>{post.title}</div>
<div style={{ fontSize: 28, opacity: 0.7 }}>yourblog.com</div>
</div>
),
{ ...size, fonts: [{ name: 'Inter', data: await inter, weight: 700, style: 'normal' }] },
);
}
Subsetting fonts (for example with pyftsubset from fonttools) is the single most effective fix for the 500 KB limit.
Option B: real Chrome, as a URL in generateMetadata
When you need grid, your stylesheet, woff2, emoji fonts or complex scripts, render the card from HTML and CSS in a real browser. Running Chrome inside a serverless function is possible (a trimmed Chromium build such as @sparticuz/chromium with puppeteer-core), but cold starts of several seconds and memory limits make it fragile — and a link-preview crawler will not wait.
The lighter option is a signed image URL: you compute it in generateMetadata (no network call, no API key on the page), and the image service renders it on the first request and serves the cached file afterwards. Below is Shareglint's URL format with its built-in blog-og template — swap in your own HTML template's slug to use your own design. Web Crypto makes the same code run on Node.js and Edge:
// lib/og.ts
const enc = (v: string) =>
encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
export async function ogImageUrl(template: string, fields: Record<string, string>) {
const path = `/i/${process.env.OG_ACCOUNT_ID}/${template}.png`;
const query = Object.keys(fields).sort().map((k) => `${enc(k)}=${enc(fields[k])}`).join('&');
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(process.env.OG_SIGNING_SECRET!),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${path}?${query}`));
const s = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
return `https://shareglint.com${path}?${query}&s=${s}`;
}
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { ogImageUrl } from '@/lib/og';
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const post = await getPost((await params).slug);
const image = await ogImageUrl('blog-og', { title: post.title, author: post.author, site: 'yourblog.com' });
return {
title: post.title,
openGraph: { images: [{ url: image, width: 1200, height: 630, alt: post.title }] },
twitter: { card: 'summary_large_image', images: [image] },
};
}
encodeURIComponent leaves ! ' ( ) * unescaped, which RFC 3986 does not — the replace fixes that so the signature matches. We checked this function against the test vector in the API docs. OG_SIGNING_SECRET has no NEXT_PUBLIC_ prefix, so Next.js never inlines it into client bundles; only the signature ends up in the HTML.
Two Next.js details to know:
- File conventions win. If a route segment has an
opengraph-image.tsxor.png, Next.js uses it instead ofopenGraph.imagesfromgenerateMetadata— the generateMetadata docs say file-based metadata "has the higher priority and will override" both. Delete the file when you switch. - Absolute URLs. A signed URL is already absolute. If you keep any relative image paths, set
metadataBasein the root layout or Next.js cannot build the full URL.
Option C: render at build time for static exports
With output: 'export' there is no server at request time. A signed URL still works — it is a string computed during the build. If you would rather ship files, call the API from a build script and store the returned URLs:
// scripts/og-images.ts — run before `next build`
const res = await fetch('https://shareglint.com/v1/images?wait=true', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OG_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'blog-og', modifications: { title: post.title, site: 'yourblog.com' } }),
});
const { url } = await res.json(); // same fields again = same file, no extra render
Which to choose
| You need | Use |
|---|---|
| Title, logo, gradient; fonts under 500 KB | next/og |
| CSS grid, your own stylesheet, woff2, complex scripts | Real Chrome — signed URL (B) |
No server at all (output: 'export') |
Signed URL computed at build, or a build script (C) |
| One generic card for every page | A static opengraph-image.png |
Whichever you pick, verify the result where it is seen: paste a production URL into LinkedIn's Post Inspector and Facebook's Sharing Debugger — localhost URLs cannot be fetched by either.