---
title: "Dynamic OG Images in the Next.js App Router with Custom Fonts"
description: "How I generate a unique share image for every page of my portfolio with ImageResponse and the opengraph-image file convention: loading custom fonts (and why woff2 fails), prerendering cards for dynamic routes, and the metadata mistake that silently overrides them."
url: "https://iamsrikanth.com/blogs/nextjs-dynamic-og-images-custom-fonts"
author: "Srikanth V"
published: "2026-10-18"
updated: "2026-10-24"
tags: "Next.js, Open Graph, SEO, App Router"
---

# Dynamic OG Images in the Next.js App Router with Custom Fonts

By Srikanth V · 2026-10-18 · 5 min read

> How I generate a unique share image for every page of my portfolio with ImageResponse and the opengraph-image file convention: loading custom fonts (and why woff2 fails), prerendering cards for dynamic routes, and the metadata mistake that silently overrides them.

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

```txt
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:

```js
// 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](https://github.com/vercel/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:

```js
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 with `process.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:

```js
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:

```js
...(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-image` and `/projects/<slug>/opengraph-image` directly in the browser.
- `curl -s https://yoursite.com/projects/x | grep og:image` to 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](/projects/hyqoo), and you'll see its own card.
