DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our link preview cards are drawn by code, from the same three files as the site

The usual way a link preview card gets made: somebody opens a design tool, makes a nice 1200x630, exports a PNG, drops it in /public, and points og:image at it. It looks great on the day. Then the brand colour changes, the logo gets redrawn, the typeface is swapped, and the card keeps showing last year's site to everybody who shares a link.

Ours are rendered by next/og from the same three inputs the actual site is built from: the logo SVG in public/, the Geist font files, and the palette in globals.css. It cannot drift, because there is no second copy of anything.

You can go and look at one right now: pub-trivia.app/features/opengraph-image is a live PNG, 1200x630, generated from that code. Change /features to /guides, /tools or /solutions in that URL and you get the same card with a different eyebrow, heading and subheading.

The shape

export async function renderOgImage({ heading, subheading, eyebrow }: OgImageContent) {
  const [fonts, logo] = await Promise.all([loadFonts(), logoDataUri()])

  return new ImageResponse(<div style={{ /* ... */ }}>{/* ... */}</div>, {
    width: 1200,
    height: 630,
    fonts,
  })
}
Enter fullscreen mode Exit fullscreen mode

One function, called by a handful of tiny opengraph-image.tsx route files that supply their own three strings. The palette is named once, from the stylesheet's resolved values:

/** app/globals.css `.dark`, resolved from HSL, plus the mug's amber. */
const BACKGROUND = '#171717'
const FOREGROUND = '#fafafa'
const MUTED = '#999999'
const BORDER = '#2e2e2e'
const BRAND = '#F5A623'
Enter fullscreen mode Exit fullscreen mode

Now here are the four things that will bite you, all of which took longer than the layout did.

1. Satori does not want your WOFF2

next/og rasterises through Satori, which reads TrueType and OpenType. It does not read WOFF2, which is exactly what next/font/google downloads for you.

So the font files are vendored as .ttf and read off disk:

const [regular, semibold, bold, mono] = await Promise.all([
  readFile(join(process.cwd(), 'assets/fonts/Geist-Regular.ttf')),
  readFile(join(process.cwd(), 'assets/fonts/Geist-SemiBold.ttf')),
  readFile(join(process.cwd(), 'assets/fonts/Geist-Bold.ttf')),
  readFile(join(process.cwd(), 'assets/fonts/GeistMono-Regular.ttf')),
])
Enter fullscreen mode Exit fullscreen mode

Reading from disk is only safe because these routes are prerendered at build time, with force-static on the routes that call this. If you generate cards per request on an edge runtime, there is no process.cwd() to read from and you have to fetch the fonts instead. Decide which of those you are doing before you start, because it changes the whole file.

Check the licence while you are at it. Geist is OFL, so vendoring is fine.

2. The bundler has to be able to see the path

This one is genuinely non-obvious:

/**
 * Each path is written out as one literal string rather than assembled from
 * segments. The bundler traces the files a route needs by reading these calls,
 * and a join(process.cwd(), ...segments) it cannot evaluate makes it give up
 * and trace the entire project into the bundle.
 */
Enter fullscreen mode Exit fullscreen mode

A tidy little loadFont(name) helper that builds its path from arguments is worse code here, because static analysis cannot evaluate it. The tracer then does the conservative thing and includes everything, and you find out via a build warning and a bundle several times the size it should be.

Anywhere a bundler needs to follow your filesystem access, keep the string whole.

3. Satori will not rasterise an inline SVG child

Our logo is an SVG. Dropping <svg>...</svg> into the tree does not work. An <img> whose src is an SVG data URI does:

async function logoDataUri(): Promise<string> {
  const svg = await readFile(join(process.cwd(), 'public/logo.svg'))
  return `data:image/svg+xml;base64,${svg.toString('base64')}`
}
Enter fullscreen mode Exit fullscreen mode

The win beyond "it works": the card uses the actual logo file, byte for byte, rather than a second hand-transcribed copy of the same paths that would silently diverge the next time the logo is edited. That is the whole thesis of this approach in one function.

4. Automatic line breaks will humiliate you

/**
 * The large line. Newlines are honoured, and are the only way to control where
 * it breaks. Automatic wrapping at this size puts the break wherever 1200px
 * happens to fall, which is rarely where the sentence wants it.
 */
heading: string
Enter fullscreen mode Exit fullscreen mode

At 70-odd pixels, a heading gets two, maybe three lines, and the difference between a good break and a bad one is the difference between a card that reads as designed and one that reads as generated. Put the newline in by hand. There are only a handful of these strings, and each is worth ten seconds of attention.

Two details that make it look deliberate

A flat translucent circle behind the logo reads as a stain at this size. A radial gradient reads as light:

backgroundImage: 'radial-gradient(circle at center, rgba(245,166,35,0.16), rgba(245,166,35,0) 62%)'
Enter fullscreen mode Exit fullscreen mode

And the faint 40px grid from the landing page's closing section is repeated on the card, at half opacity. Someone who clicks through from a shared link arrives at a page carrying the same texture they just saw in their Slack channel. That continuity is most of what a preview card is for.

Why it is worth the afternoon

Every one of our pages gets a card without anyone opening a design tool. Adding a page means adding three strings. The palette, the logo and the typeface have exactly one definition each, so the card a stranger sees in a Slack channel is the site they land on, this month and next year.

Go and compare: open the generated card next to the page it belongs to, then paste any URL from the site into a Slack message or a preview debugger to see it in the wild. If you want to know what all these pages are for, the app itself has a free tier and no card required.

Top comments (0)