Dynamic OG image generation in Next.js

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.

1. Fonts need bytes, not a family name

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' }],
    }
  );
}

2. The Edge runtime cannot read your font file

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'));

3. Satori is flexbox only

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.

4. 1200x630 is not the only size you need

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 1500

5. Every crawler re-renders unless you cache

Cheap 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.

What Spark Stack ships, and what it costs you

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:

The Spark Stack OG image package and what each piece does
PieceWhat 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_DIMSThe size table above, keyed by platform, with same-size platforms grouped so each dimension renders once.
ensureContrast / luminanceKeeps overlaid text legible against a mascot or screenshot whose colours you do not control.
Fonts come off the Google Fonts CDNBehind 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.

Questions

Why does my Next.js OG image use the wrong font?

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.

Should an OG image route use the Edge or Node runtime?

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.

Why does my OG image look nothing like the component in the browser?

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.

What size should an OG image be?

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.

Can I put a screenshot or logo in a generated OG 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.

Where do I see what Spark Stack costs?

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.