Dynamic OG Images in the Next.js App Router with Custom Fonts
When someone shares a link on LinkedIn, X, Slack or WhatsApp, the preview image is the first thing anyone sees — usually before they read a single word. For a portfolio that matters. A recruiter scrolling a feed decides in about half a second whether to click.
My old setup had one generic image for the whole site. Now every page gets its own card: home, the project list, every case study, the blog index and every article. All of them use the site's real fonts, its black-and-white palette and the same ASCII torus knot as the preloader. Here's how it works.
The file convention
In the App Router, a file named opengraph-image.js inside a route segment is that route's share image. Next.js serves it at /<route>/opengraph-image, works out the og:image, og:image:width, og:image:height and og:image:alt tags, and adds a hash to the URL so caches bust when the image changes. twitter-image.js does the same for twitter:image.
app/
opengraph-image.js → /opengraph-image
projects/
opengraph-image.js → /projects/opengraph-image
[slug]/opengraph-image.js → /projects/hyqoo/opengraph-image
blogs/
opengraph-image.js
[slug]/opengraph-image.js
Each file is tiny, because all the design lives in one shared renderer:
// app/projects/[slug]/opengraph-image.js
import { getProjectBySlug, getProjects } from '@/lib/content'
import { OG_IMAGE_SIZE, renderOgImage } from '@/lib/og-image'
export const alt = 'Case study by Srikanth V'
export const size = OG_IMAGE_SIZE
export const contentType = 'image/png'
export async function generateStaticParams() {
return (await getProjects()).map(({ slug }) => ({ slug }))
}
export default async function Image({ params }) {
const { slug } = await params
const project = await getProjectBySlug(slug)
return renderOgImage({
path: `/projects/${slug}`,
eyebrow: 'Case study',
title: project?.name || 'Case study',
description: project?.description?.trim(),
tags: project?.tech || [],
})
}
Gotcha 1: generateStaticParams makes them static
Without generateStaticParams, the build listed my [slug] images as ƒ (dynamic): rendered on the first request, on demand. Adding the same generateStaticParams the page uses turns them into ● (SSG), prerendered at build time next to the HTML. The first person to share a new case study shouldn't have to wait while a server renders a PNG.
Gotcha 2: fonts must be woff or ttf, not woff2
ImageResponse uses Satori to turn JSX into SVG, then rasterises it. Satori ships with no fonts of its own, and it can't read woff2, which is what most font CDNs and next/font serve. I copied static .woff files from the Fontsource packages into the repo and read them at render time:
let fonts
const loadFonts = () => {
const font = (file) => readFile(join(process.cwd(), 'assets/og', file))
fonts ??= Promise.all([
font('hanken-grotesk-latin-500-normal.woff'),
font('hanken-grotesk-latin-700-normal.woff'),
font('jetbrains-mono-latin-500-normal.woff'),
font('source-serif-4-latin-400-italic.woff'),
]).then(([sans, bold, mono, serif]) => [
{ name: 'Hanken Grotesk', data: sans, weight: 500, style: 'normal' },
{ name: 'Hanken Grotesk', data: bold, weight: 700, style: 'normal' },
{ name: 'JetBrains Mono', data: mono, weight: 500, style: 'normal' },
{ name: 'Source Serif 4', data: serif, weight: 400, style: 'italic' },
])
return fonts
}
Three details here:
- Use static weights, not variable fonts. Register each weight you use as its own file.
- The module-level promise (
fonts ??=) means a build that renders thirty cards reads the files once, not thirty times. - Keep the files in the repo (here
assets/og) and read them withprocess.cwd(). That way they're bundled with the deployment and still available when an admin edit regenerates a card at runtime.
Gotcha 3: Satori is flexbox-only
Satori supports a subset of CSS. The rules that cost me the most time:
- Every element with more than one child needs
display: 'flex'. Leave it off and the render throws an error. - No CSS grid and no
calc(). Lay everything out with flex and absolute positioning. - Truncate text yourself. There's no reliable multi-line ellipsis, so I trim descriptions by length, and give long titles fewer characters of description so nothing collides.
ASCII art in a PNG
The torus knot is the same pure function the preloader uses on the client: it returns a frame of characters. On the server I seed its rotation with a hash of the page path, so every page gets its own pose of the knot, but the same page always gets the same image. Then I crop it to the knot's bounding box and render each row as a div with whiteSpace: 'pre' in JetBrains Mono. A monospace font is what turns rows of characters into a picture, and JetBrains Mono's characters are exactly 0.6em wide, so the sizing maths is simple:
const artSize = Math.min(560 / art.length, 520 / (art[0].length * 0.6))
A horizontal gradient from the background colour to transparent sits between the art and the text, so long titles never run into it.
Gotcha 4: your metadata helper might be overriding everything
This one is silent. My generateMetaData() helper used to set openGraph.images and twitter.images to a fixed URL on every page. File-based images take priority in the App Router, but I'd rather not depend on that, and a hard-coded image is easy to forget about. So the helper now sets images only when a page explicitly passes one:
...(imageUrl ? { images: [{ url: imageUrl, width: 1200, height: 630, alt: seo.title }] } : {}),
Keeping them fresh
Projects and blogs live in Supabase and the site is static, so every admin save calls revalidatePath('/', 'layout'). That one call refreshes the pages, their share cards, the markdown versions, the sitemap and llms.txt together, so a renamed project never ends up with a card showing its old name.
Checking your work
- Open
/opengraph-imageand/projects/<slug>/opengraph-imagedirectly in the browser. curl -s https://yoursite.com/projects/x | grep og:imageto confirm the tag points at the generated image.- Once you've deployed, run the URL through the LinkedIn Post Inspector (it also clears LinkedIn's cache) and paste it into a draft post on X or Slack. Platforms cache previews aggressively, so check after every design change.
The cards on this site are generated by the code above. Share any page, like the Hyqoo case study, and you'll see its own card.