The file convention and the JSX are the parts that already work. Three things actually break, and the official examples make two of them more likely: fonts need bytes rather than a family name, the Edge runtime cannot read a local file, and the renderer only does flexbox — so unsupported CSS is ignored in silence rather than reported.
If the text is in the wrong font — which is the most common symptom — the family you named is not in the fonts array. There is no stylesheet here, so it fell back silently. And an unloaded weight is not synthesised: asking for 700 with only 400 loaded gives you 400, with no warning.
The single biggest cause of "it renders, but wrong".
There is no stylesheet and no font loading in the renderer. Satori resolves text against the fonts you hand it as ArrayBuffers, and a font-family naming anything not in that array falls back silently. Nothing errors, nothing warns, and the image ships in a default face. Load every weight you reference, and remember that a weight you did not load is not synthesised: asking for 700 with only 400 loaded gives you 400, not bold.
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const alt = 'Your page title';
export default async function Image() {
const bold = await fetch(new URL('./Inter-Bold.ttf', import.meta.url))
.then((r) => r.arrayBuffer());
return new ImageResponse(
(
<div style={{ display: 'flex', fontFamily: 'Inter', fontWeight: 700 }}>
Hello
</div>
),
{
...size,
// Every family AND weight you reference must appear here.
fonts: [{ name: 'Inter', data: bold, weight: 700, style: 'normal' }],
}
);
}And the official examples use Edge.
Edge gives you fast cold starts and no Node APIs. So node:fs/promises readFile is out, sharp is out, and anything that resizes, composites or reads an image off disk is out with them. Fetching the font over HTTP works on Edge, which is why the examples get away with it, but it puts a network call on the render path. If your card composites a logo, a screenshot or a mascot, set runtime to nodejs and stop fighting it: an OG image is rendered by crawlers, not by users waiting on a page.
// Needed the moment you read a local file or use sharp.
export const runtime = 'nodejs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const font = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'));Unsupported CSS does not throw. It just does nothing.
The renderer implements a subset: flexbox lays out, grid does not. There is no cascade, so no class names, no stylesheets and no selectors; every style is an inline style object. Percentage sizing is partial, and a handful of properties behave differently from a browser. The practical rule is to treat the component as a drawing, not as a page: one flex container, explicit pixel dimensions, and inline styles only. If the output looks nothing like what the browser shows, suspect an unsupported property before you suspect your maths.
It is the right default and it is wrong on four platforms.
A single landscape card gets cropped badly where the feed is square or vertical. Export a second portrait route rather than letting Instagram, Threads, TikTok and Pinterest centre-crop a wide card. The numbers below are the ones actually in use in this template, grouped because platforms sharing a size can be rendered once.
twitter 1200 x 675 instagram 1080 x 1080
bluesky 1200 x 675 threads 1080 x 1080
reddit 1200 x 628 tiktok 1080 x 1920
linkedin 1200 x 627 youtube 1080 x 1920
pinterest 1000 x 1500Cheap to fix, easy to forget.
Image generation is the most expensive route on a marketing site, and the callers are social crawlers that may fetch the same card repeatedly. The file-convention route is statically generated when its content does not depend on the request, which is the behaviour you want. If yours is dynamic, give it an explicit revalidate rather than leaving it to render per request: a card whose contents change daily does not need regenerating per crawl.
All of the above works with nothing but next/og. The template ships it assembled, and the last row is a real trade rather than a feature:
| Piece | What it does |
|---|---|
lib/og/ | A portable directory: config, colour maths, font fetching, sharp helpers, platform sizes, mascot and badge assets. |
runtime = "nodejs" | Chosen deliberately, because the card composites assets read off disk with sharp. |
PLATFORM_DIMS | The size table above, keyed by platform, with same-size platforms grouped so each dimension renders once. |
ensureContrast / luminance | Keeps overlaid text legible against a mascot or screenshot whose colours you do not control. |
Fonts come off the Google Fonts CDN | Behind a module-level cache. Honest cost: a cold start re-fetches, so generation has a network dependency. Vendor the TTFs if that matters to you. |
Because the family you named is not in the `fonts` array you passed to `ImageResponse`. There is no stylesheet in the renderer, so a font-family it cannot resolve falls back silently with no error. Load each family and each weight you reference; an unloaded weight is not synthesised, so bold text renders regular.
Edge if you only fetch a font over HTTP and draw text and shapes. Node the moment you read a file from disk or touch sharp, because those APIs do not exist on Edge. The Edge cold-start advantage matters much less here than elsewhere, since the caller is a crawler rather than a user waiting on a page.
Satori implements a subset of CSS, and unsupported properties are ignored rather than reported. The most common cause is `display: grid`, which simply does not lay out. There is also no cascade, so class names and stylesheets do nothing: every style has to be an inline object. Build the card as one flex container with explicit pixel sizes.
1200x630 is the right default for link previews, and it crops badly where feeds are square or vertical. X, Bluesky, Reddit and LinkedIn all sit near 1200x627-675, so one landscape card covers them. Instagram and Threads want 1080x1080, TikTok and YouTube Shorts 1080x1920, Pinterest 1000x1500. Render a second portrait route rather than letting those four centre-crop a wide image.
Yes, and this is usually what forces the Node runtime. Remote images can be referenced by URL, but anything you resize, composite or read off disk needs sharp or the filesystem, neither of which exists on Edge. Watch the total render time: an OG route that fetches three remote images is slow and fails in ways a crawler will not retry.
On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.