DEV Community

Cover image for Open Graph images for every page with Next.js ImageResponse
RadarMakers
RadarMakers

Posted on

Open Graph images for every page with Next.js ImageResponse

A link to a product page should show that product in the preview card, not the same site banner as every other link. On our launch platform, live product pages use a 1200 x 630 PNG generated on the server with ImageResponse from next/og. Tag pages use the same generator except for eight tags: ai, ai-agents, audio, design, finance, music, open-source and productivity. Those tags use static Open Graph images at /og/tag/<slug>.png and separate Twitter images at /og/x/tag/<slug>.png. This is the image for one of the products listed on RadarMakers:

A generated preview image: the RadarMakers mark, the label PRODUCT, the product name IconCutter, its tagline on two lines, and the product logo in a framed square on the right

Nothing in that image was drawn by hand for this product. The layout is shared, the name, tagline and logo come from the database, and the text sizes were picked by measuring the text before rendering. Here is how the pieces fit together, using our real code on Next.js 16.

A route handler per image, a versioned URL in the metadata

Next.js offers two ways to serve a generated image. The opengraph-image.tsx file convention adds the image to a route segment and writes the og:image tags for you. Or you can return an ImageResponse from an ordinary route handler and point your metadata at it yourself.

We use route handlers: /product-og/[slug] and /tag-og/[slug]. The product page's generateMetadata builds the image URL with a version taken from the data that appears in the image:

// app/product/[slug]/layout.tsx, excerpt
const ogImages = [{ url: `/product-og/${encodeURIComponent(slug)}?v=${ogVersion(p)}`, width: 1200, height: 630, alt: `${p.name} on RadarMakers` }];
Enter fullscreen mode Exit fullscreen mode

and passes ogImages to both openGraph.images and twitter.images. Doing it this way gives us three things we rely on below: a URL version derived from the product name, tagline and logo URL, our own cache headers, and a fallback image when something fails.

Product and tag data go through a memory cache; on a miss the shared layout, fitted text and DejaVu Sans fonts go into ImageResponse, which returns a 1200 x 630 PNG with long cache headers; missing data or a render error returns a static fallback PNG with a short cache

One shared layout

Product and tag images share one file, lib/og-shared.tsx. It holds the size, the colors, the fonts and the background:

// lib/og-shared.tsx, excerpt, comments omitted
export const OG_WIDTH = 1200;
export const OG_HEIGHT = 630;

// ... colors and the Fonts type omitted

let fontsPromise: Promise<Fonts> | null = null;

export function loadFonts(): Promise<Fonts> {
  fontsPromise ??= (async () => {
    const dir = path.join(process.cwd(), "src/assets/og");
    const [regular, bold] = await Promise.all([readFile(path.join(dir, "DejaVuSans.ttf")), readFile(path.join(dir, "DejaVuSans-Bold.ttf"))]);
    return { regular, bold, regularMetrics: readFontMetrics(regular), boldMetrics: readFontMetrics(bold) };
  })().catch((err) => {
    fontsPromise = null;
    throw err;
  });
  return fontsPromise;
}
Enter fullscreen mode Exit fullscreen mode

The fonts are read once per process and kept as a promise. If reading fails, the promise is cleared, so the next request tries again instead of failing forever. The same function also reads the font metrics we need for measuring text, which comes up in a moment.

Backdrop() returns one SVG with everything that never changes: the curved panel, the radar rings, the frame for the logo, our mark and the footer line. Each image is then a 1200 x 630 div with the backdrop and a few absolutely positioned text blocks on top.

The Next.js docs list the limits to design for: only flexbox and a subset of CSS, no display: grid, and only ttf, otf and woff fonts. Our layout stays inside those limits: the root is a flex container, and every text block is an absolutely positioned div with display: flex and whiteSpace: "nowrap".

Per page data

The product image takes three fields:

// lib/og-product.tsx, excerpt
export async function renderProductOg(product: Pick<Product, "name" | "tagline" | "logo_url">): Promise<Uint8Array> {
  const fonts = await loadFonts();
  const logo = await fetchLogo(product.logo_url);
  const name = fitName(fonts.boldMetrics, product.name);
  const tagline = wrapTagline(fonts.regularMetrics, product.tagline);
Enter fullscreen mode Exit fullscreen mode

The tag image takes only the tag name and draws a fixed tag illustration where the logo would be. Both end the same way: new ImageResponse(element, { width: OG_WIDTH, height: OG_HEIGHT, fonts: satoriFonts(fonts) }), read into a Uint8Array so the bytes can be cached.

The logo needed the most care, because it is user content. fetchLogo only accepts a path of the form /uploads/<file name>, fetches it from our own API origin with a 4 second timeout, rejects an empty body or one larger than 2 MiB after downloading it, and checks the first bytes of the file for PNG, JPEG, GIF or SVG instead of trusting the extension. Anything else returns null, and the image shows a tile with the first letter of the product name. The renderer never fetches an address that came from a user.

Measuring text before drawing it

Our design template has a fixed box for the name: 552 px wide on one line. A short name should stay at 60 px. A longer name should get smaller, down to 40 px. A name too long even at 40 px gets cut and ends with three periods. The tagline gets at most two lines of 30 px.

CSS has no rule for "shrink this text until it fits the box", so we measure. lib/og-text.ts reads advance widths straight from the font file: it looks each character up in the font's Unicode BMP cmap table and takes the width from hmtx. Characters above U+FFFF, such as most emoji, and characters the font does not have get the width of the font's fallback glyph, so text full of them is measured only roughly. Then it adds up the widths:

// lib/og-text.ts, excerpt, comments omitted
export function textWidth(font: FontMetrics, text: string, size: number, letterSpacing = 0): number {
  let total = 0;
  for (const ch of text) total += font.advance(ch.codePointAt(0)!) * size + letterSpacing;
  return total;
}
Enter fullscreen mode Exit fullscreen mode

Fitting the name is then a loop from the largest size down, in 2 px steps:

// lib/og-text.ts, excerpt, comments omitted
const SAFETY = 1.03;
export const NAME_BOX = 552;
export const NAME_MAX_SIZE = 60;
export const NAME_MIN_SIZE = 40;

// ... tagline constants and the clean and truncate helpers omitted;
// clean collapses whitespace, truncate cuts until the text plus "..." fits

export function fitName(
  bold: FontMetrics,
  rawName: string,
  box = NAME_BOX,
  max = NAME_MAX_SIZE,
  min = NAME_MIN_SIZE,
): { text: string; size: number; letterSpacing: number } {
  const name = clean(rawName);
  const spacing = (size: number) => (-1.8 * size) / max;
  const width = (text: string, size: number) => textWidth(bold, text, size, spacing(size)) * SAFETY;
  for (let size = max; size >= min; size -= 2) {
    if (width(name, size) <= box) return { text: name, size, letterSpacing: spacing(size) };
  }
  return { text: truncate((t) => width(t, min) <= box, name), size: min, letterSpacing: spacing(min) };
}
Enter fullscreen mode Exit fullscreen mode

Three details are worth copying:

  • The safety factor. Our measurement ignores kerning and rounding, so every width is multiplied by 1.03 before it is compared with the box. A few percent of slack is cheaper than a name that touches the logo frame.
  • The letter spacing scales with the size. The template uses -1.8 px at 60 px, so a smaller size gets proportionally less negative spacing, and the measurement includes it.
  • The font is the same file everywhere. The renderer draws with the DejaVu Sans files we pass in fonts, and the measurement reads the same files. Measuring with one font and drawing with another would make the numbers meaningless.

Fitting the name: collapse whitespace, measure against a 552 px box with glyph widths, letter spacing and a 1.03 factor, try 60 px down to 40 px; if a size fits keep the full name, if it is still too wide at 40 px shorten it and add three periods; product taglines use 30 px and at most two lines

wrapTagline does the same for the tagline: it fills the first line word by word, puts the rest on the second, and cuts the second line with three periods if needed. A single word longer than a line is broken where it stops fitting.

Because the result of all this is plain data, a name and a size, the text is placed in the JSX with fixed coordinates. One more helper, textBox, turns the template's baseline into a top and a lineHeight from the font's ascent and descent, so text sits exactly where the design file puts it.

Caching at three levels

Rendering a PNG is the slow part, and preview crawlers ask for the same image again and again. We cache at three levels.

The URL. ogVersion is a short FNV-1a hash of the name, tagline and logo URL. It goes into the ?v= parameter of the image URL in the metadata. When a maker renames a product, the page starts pointing at a new image URL. That helps a platform that fetches the page again pick up the new image. It does not force any platform to fetch the page again.

The process. The route handler keeps rendered PNGs in a small in-memory cache, keyed by slug and version:

// app/product-og/[slug]/route.tsx, excerpt
const product = await getProductBySlug(slug).catch(() => null);
if (!product || product.status !== "live") return await fallback();

const key = `${slug}:${ogVersion(product)}`;
let png = ogCache.get(key);
if (!png) {
  png = await renderProductOg(product);
  ogCache.set(key, png);
}
return new Response(png as BodyInit, { headers: IMAGE_HEADERS });
Enter fullscreen mode Exit fullscreen mode

OgCache is a Map used as a least recently used list: up to 200 entries, each expiring after six hours. Product and tag images share it, with a tag: prefix on tag keys.

HTTP. Successful images go out with:

Content-Type: image/png
Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800
Enter fullscreen mode Exit fullscreen mode

The fallback. If the product does not exist, is not live, or anything throws, the handler returns a static PNG with Cache-Control: public, max-age=300. The fallback has a five minute freshness lifetime, while a real image stays fresh for an hour in browser caches and a day in shared caches. After the five minutes, a cache that respects the header asks again, which matters for a product that was waiting for review and has since gone live. A sharing service may still keep its own copy of the preview longer than that.

How we test it

The fitting and caching code has no React and no renderer in it, so it runs under node --test. The tests load the real font files:

// tests/og-text.test.mjs, excerpt
test("a name too long even at 40 px is truncated with three periods and fits", () => {
  const fit = fitName(bold, "An extraordinarily long product name that cannot possibly fit in one line");
  assert.equal(fit.size, 40);
  assert.ok(fit.text.endsWith("...") && fit.text.length < 60);
  assert.ok(textWidth(bold, fit.text, fit.size, fit.letterSpacing) <= NAME_BOX);
});
Enter fullscreen mode Exit fullscreen mode

Other tests check the font metrics against known values from the DejaVu tables, that a tagline wraps into two lines that both fit, that identical inputs give the same version and that changing the name, the tagline or the logo URL gives a different one, and that the cache evicts the least recently used entry and drops expired ones with an injected clock. The tag page test loads the real page module with a fake lookup for ai-tools, a tag without a static card, and checks that its metadata points at a versioned /tag-og/ai-tools URL and that renaming this tag changes that address.

These tests do not render a PNG. For that we check the live site after a deploy:

curl -s -A "Slackbot-LinkExpanding 1.0" https://yoursite.com/product/some-slug -o page.html
grep -o '<meta property="og:image" content="[^"]*"' page.html
curl -s -D - -o og.png "https://yoursite.com/product-og/some-slug?v=..." | grep -iE "^HTTP|content-type|cache-control"
python3 -c "from PIL import Image; print(Image.open('og.png').size)"
Enter fullscreen mode Exit fullscreen mode

and then open the PNG and look at it. When we last did this, the product page above pointed at its versioned image, the image answered 200 with image/png and the long cache header, and it measured 1200 x 630. An unknown slug answered 200 with the fallback image and max-age=300.

Takeaways

  • Point your metadata at a route that renders the image, and put a version of the image's inputs in the URL.
  • Keep one shared layout and pass only the page data in. Our product and tag images differ by a label, a field or two and an illustration.
  • If the design needs text to shrink or stop at a number of lines, measure it with the same font file the renderer uses, add a little slack, and place the result with fixed coordinates.
  • Never let the renderer fetch a URL a user typed. Fetch from your own storage, check the bytes, reject files that are too large.
  • Cache the PNG bytes in the process, send long cache headers for real images and short ones for the fallback.

If you generate preview images differently, for example with the opengraph-image file convention, I would like to hear what made you choose it.

Top comments (0)