Skip to main content
ASoc
Tutorial

Dynamic Open Graph Images in Next.js with ImageResponse

A metadata route under a dynamic segment needs its own generateStaticParams, or every card renders per request. That trap, the Satori CSS subset, fonts, and how to verify a crawler sees it.

The ASoc Team11 min read

To generate a per-page Open Graph image in the Next.js App Router, add an opengraph-image.tsx file to the route segment and default-export a function returning an ImageResponse from next/og. Next.js wires the og:image tag automatically. Under a dynamic segment, that file also needs its own generateStaticParams — without it, every card is rendered on demand at request time.

That last sentence is the one this post exists for. The basic setup is well covered elsewhere; the failure modes are not. Below: the file convention, the Satori CSS subset that will reject your JSX, fonts, the build-time trap, and how to check what a crawler actually receives.

The file convention

opengraph-image.tsx is a metadata route. Put it beside the page it belongs to:

src/app/
├── opengraph-image.tsx          ← the site-wide default
└── blog/
    ├── opengraph-image.tsx      ← /blog
    └── [slug]/
        ├── page.tsx
        └── opengraph-image.tsx  ← every post

The nearest file to a route wins, so a single root file gives your whole site a card, and each override is opt-in.

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

export const alt = "An article on the ASoc blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPost(slug);

  return new ImageResponse(
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        padding: 80,
        background: "linear-gradient(135deg, #465fff 0%, #161950 100%)",
        color: "#fff",
        fontFamily: "sans-serif",
      }}
    >
      <div style={{ display: "flex", fontSize: 26, color: "#c2d6ff" }}>
        {post?.cluster ?? "Article"}
      </div>
      <div style={{ display: "flex", fontSize: 72, fontWeight: 800 }}>
        {post?.title ?? "The ASoc Blog"}
      </div>
    </div>,
    size,
  );
}

Note params is a Promise and is awaited — that landed in Next.js 15 and applies to metadata routes exactly as it does to pages. Note also the ?? fallbacks: this function can be invoked for a slug you did not expect, and an exception here produces a broken image rather than a 404 page.

Import from next/og. @vercel/og is the same engine, but the framework-maintained entry point is the one to use inside Next.

The trap: a metadata route under a dynamic segment

Here is what we shipped, and what we found in the build output:

● /blog/[slug]                        ← static, prerendered
ƒ /blog/[slug]/opengraph-image        ← dynamic, on demand

Every page on the site was static, and every OG card was being rendered per request. Nothing errored. Lighthouse said nothing. The images were correct whenever anyone looked at them.

The cause: a metadata route is its own route. page.tsx declaring generateStaticParams does not resolve params for the opengraph-image.tsx sitting next to it. That file needs its own copy:

export function generateStaticParams() {
  return getPublishedSlugs().map((slug) => ({ slug }));
}

Add it and the ƒ becomes a . The cards are baked at build time and served as static files.

Why this is worth caring about: image generation is not cheap. Satori lays out the JSX, resvg rasterizes it to PNG, and on a cold serverless instance that is hundreds of milliseconds of compute — per crawl, per social unfurl, per link preview in a chat app. Slack, X, LinkedIn and Discord will all fetch it, some of them repeatedly, and some of them with a timeout short enough that a cold start loses the race and shows no image at all.

Check for this deliberately, because nothing tells you. Run next build and read the route list for ƒ next to anything named -image.

Satori is not a browser

ImageResponse renders JSX through Satori, which implements a deliberate subset of CSS. The rules that catch people:

RuleDetail
display: flex is requiredAny element with more than one child needs it explicitly. Satori throws rather than guessing.
No gridFlexbox only.
No gap shorthand in older versionsPrefer margins if you hit it.
Tailwind classes do not applyIt reads inline style, not your stylesheet. (tw= exists, but the class subset differs from your config.)
No external images by URLNothing is fetched for you. Fetch it yourself and inline it.
Text does not wrap like the webLong words overflow; constrain the container and cap title length.

That first row is the error you will actually hit, and its message is clear once you have seen it once: Expected <div> to have explicit display: flex or display: none if it has more than one child node. Every wrapper gets display: "flex".

The Tailwind row surprises people the most. This is a separate rendering environment — your @theme tokens, your custom colors and your utility classes do not exist inside it. Hard-code the hex values, and accept that this file will drift from your design system unless you import the tokens from a shared module.

Fonts

The default is a generic sans-serif that will not match your brand. To use your own, read the file and pass it as raw bytes:

import { readFile } from "node:fs/promises";
import { join } from "node:path";

const outfit = await readFile(
  join(process.cwd(), "src/assets/fonts/Outfit-Bold.ttf"),
);

return new ImageResponse(<div style={{ fontFamily: "Outfit" }}>…</div>, {
  ...size,
  fonts: [{ name: "Outfit", data: outfit, style: "normal", weight: 700 }],
});

Three constraints worth knowing before you spend an hour on this:

  • woff2 is not supported. Use ttf, otf or woff. This is the single most common font failure, because woff2 is what your site already serves.
  • Subset the file. A full-weight TTF can be several hundred kilobytes, read on every render. Subset to Latin, or to the glyphs you actually use.
  • Each weight is a separate entry. There is no synthetic bolding.

If the font is not worth the bytes, a well-chosen layout in the default face beats a card that fails to render.

Images inside the card

Satori will not fetch a URL for you. Read local files and inline them as a data URI:

const logo = await readFile(join(process.cwd(), "public/brand/logo.png"));
const src = `data:image/png;base64,${logo.toString("base64")}`;
// then: <img src={src} width={140} height={32} alt="" />

For a remote image, fetch it and do the same — but only if the card is prerendered, or you have added a network round-trip to every unfurl.

Checking what a crawler actually gets

In development, open the image route directly: /blog/some-post/opengraph-image. You get the PNG, and a Satori error becomes a readable stack trace instead of a broken thumbnail.

For the deployed page, four checks in order:

  1. View source — not the inspector — and confirm <meta property="og:image"> is an absolute URL. Next.js builds it from metadataBase; if that is unset you will see a relative path and a build-time warning, and most crawlers will ignore it.
  2. Load that URL directly in a private window. If it 404s, the metadata route did not build.
  3. Unfurl it: X's Post Inspector, LinkedIn's Post Inspector, Facebook's Sharing Debugger. Each caches aggressively, so use their re-scrape button after a change.
  4. Paste the link into Slack. It is the fastest real-world check and it exercises a short timeout, which is exactly the case a per-request card fails.

If you want a distinct card for X, add a twitter-image.tsx beside the OG file. Without one, X falls back to og:image per its own spec, which is usually what you want. Set twitter.card: "summary_large_image" in your metadata either way, or you get the small square variant.

Mistakes and their symptoms

MistakeSymptomFix
No generateStaticParams on the image routeƒ in build output; slow unfurls, occasional blanksAdd it to the image file itself
Multi-child element without display: flexBuild or render throwsSet it explicitly on every wrapper
woff2 fontFont silently ignored, or a render errorConvert to ttf/otf/woff
Expecting Tailwind classes to workUnstyled cardInline style with literal values
metadataBase unsetRelative og:image; crawlers skip itSet it in the root layout metadata
Remote image by URLNothing renders where the image should beFetch and inline as a data URI
Long titlesText overflows the canvasCap length, or scale font size by length
Reusing the site card everywhereEvery share looks identicalPer-route files; the nearest one wins
Testing only in the inspectorHydrated DOM, not the served HTMLView source

Two of those are worth combining into one habit: after any change to a card, run next build, grep the route list for -image, and re-scrape one URL in a social debugger. It takes a minute and catches everything above.

Frequently asked questions

Does an OG image affect SEO rankings? Not directly. It affects click-through from social and chat, which is a distribution channel rather than a ranking factor. Treat it as presentation, in the same category as a title tag's phrasing.

What size should the image be? 1200×630, which is the ratio every major platform crops to. Keep meaningful content away from the outer edges, since some surfaces crop to a square.

Can I use next/image inside ImageResponse? No. Satori is not React DOM and has no access to the image optimizer. Use a plain <img> with a data URI.

Is ImageResponse Edge-only? It runs on both the Edge and Node runtimes in current Next.js. Node is the easier default when you need node:fs to read fonts, which most branded cards do.

Do I need alt, size and contentType? size and contentType are how Next.js writes the og:image:width, og:image:height and og:image:type tags — some crawlers reserve layout from them before the image loads. alt becomes og:image:alt. All three are cheap and worth exporting.

Templates that already ship this

Per-page OG cards are one of those tasks that is an hour of work and a week of small surprises, and they are invisible right up until someone shares your page in a channel with two hundred people in it.

Our Next.js templates wire the metadata surfaces up front. ASoc Quill is a lightweight blog for makers with a featured post and story feed; ASoc Press is a news and magazine template with editor's picks, galleries and video feeds; ASoc Rank is an SEO-audit SaaS marketing site with pricing, services and an FAQ.

Browse every Next.js landing page template, or the Tailwind landing page templates.

Keep reading

Tutorial8 min read

Next.js Pagination: The Threshold This Blog Blew Past by 3x

This codebase paginates nothing — /blog still renders 106 posts on one page, 3.5x past its own stated 30-post threshold. The searchParams pattern for when it's real.

Read more
Tutorial10 min read

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

Read more