A link preview card is the one piece of a site most people see before they see the site. It shows up in Slack, in a WhatsApp group, on a search results page, in the embed under a tweet. For most projects it is a PNG somebody exported from a design tool once, dropped in public/, and never looked at again.
That PNG is wrong within a month. The logo changes, the palette changes, the tagline changes, and the card keeps advertising the version of the product that existed on the afternoon it was exported. Nothing tells you, because nothing checks.
So ours is not a file. It is a React component that reads the logo off disk, names the same colours as the stylesheet, and takes its words out of the same registry the page itself is built from. The idea is simple. Everything underneath it was less simple than I expected, and all of it was interesting.
The card is a route, not an asset
Next's App Router treats opengraph-image.tsx as a convention. Put one in a segment and that segment's pages get an og:image pointing at it. Put one in the root segment and every route that does not declare its own inherits it, which means a page added next month gets a correct card without anyone remembering to ask for one.
Inside, next/og's ImageResponse takes JSX and rasterises it. It is not a browser. It is Satori, which implements a deliberately small subset of CSS: flexbox, absolute positioning, gradients, text. No grid, no floats, no calc. Writing for it feels like writing for an email client, and the constraint turns out to be fine, because a 1200 by 630 card is six boxes stacked in a column.
The fonts had to go backwards a format
This is the part I did not see coming. The site loads Geist through next/font/google, which fetches WOFF2. Satori supports TrueType and OpenType. It does not support WOFF2.
So the font the card uses cannot be the font object the app already has. The four faces we actually render with, regular, semibold, bold, and the mono face, are vendored into assets/fonts/ as .ttf and read off disk at render time. Geist is OFL licensed, so this is allowed, and it is the only route to a card whose type matches the page it links to.
The alternative is a card set in whatever Satori falls back to, which is a card that looks like somebody else's product.
force-static, because the files are on the build machine
export const dynamic = "force-static"
One line, and it is doing two jobs.
The renderer reads five files from the filesystem: one SVG and four fonts. Those files definitely exist on the machine that runs the build. Whether they exist inside a serverless bundle at request time is a question I would rather not have to answer correctly on every deploy. Prerendering the card turns it into a plain PNG sitting on the CDN, and the question stops being a question.
The second job is the one that matters commercially. Crawlers and chat clients fetching a preview are not patient. Several will not wait on a cold function to draw an image before giving up and rendering your link as grey text. A static PNG is what that client wants, and it is what it gets.
Write file paths as literals, or watch your bundle eat the repo
readFile(join(process.cwd(), "assets/fonts/Geist-Regular.ttf"))
That path is one string on purpose. Turbopack works out which files a route needs by reading these calls statically. Give it join(process.cwd(), ...segments) where segments is a variable it cannot evaluate, and it cannot prove which files you meant, so it traces the whole project into the bundle. It warns you when this happens, which is generous of it, and the warning is easy to scroll past.
Four literal strings, slightly repetitive, no mystery. Worth it.
The logo is an <img> with a data URI inside it
Satori does not rasterise raw <svg> children. It does rasterise an <img> whose src is an SVG data URI. So the mug is read as bytes and base64 encoded into the tag:
const svg = await readFile(join(process.cwd(), "public/logo.svg"))
return `data:image/svg+xml;base64,${svg.toString("base64")}`
The reason this matters is not the encoding. It is that the file ships into the card untouched. The obvious workaround, redrawing the logo as Satori friendly JSX, creates a second copy of the logo that diverges from the first one the moment anybody touches public/logo.svg. Two logos is worse than no card.
One card per section, not one per page
There are around seventy public pages. Each of the six content clusters has its own card, and the pages inside a cluster inherit it.
That is a decision, not a gap. A per page card means sixty more strings to keep true, and it buys very little: "GUIDES, How to run a quiz night" is an honest preview of any page in that cluster. What the cluster card does not do is invent its own words. The eyebrow and the supporting line are the cluster's own label and summary from the content registry, and the big line is the hub page's own heading. A card that writes its own copy is a card that eventually advertises something the page no longer says.
The card is a route, so our own auth gate nearly ate it
The sting in the tail. Because these are routes rather than files in public/, they pass through middleware like any other path, and our auth gate redirects anything not on the public allowlist to /login. A crawler asking for the preview image would have received a 307, and every share of the site would have rendered with no image at all.
The fix is a shape match rather than a list, because the filename carries a cache busting suffix in production and a query id in development:
const METADATA_IMAGE_SEGMENT =
/^(opengraph-image|twitter-image|icon|apple-icon)(-[\w-]+)?(\.(png|jpg|jpeg|gif|svg|ico))?$/
It tests the final segment only. That is what stops it being a hole: it cannot make the parent of /dashboard/opengraph-image public, and there is no private data in an image whose entire purpose is to be published to crawlers.
Try it
Pick the og:image out of the HTML and open it:
curl -s https://pub-trivia.app/guides | grep -o 'og:image" content="[^"]*'
You will get a URL like https://pub-trivia.app/guides/opengraph-image?5dbd9805bb5eb7e5. Open it and you are looking at a PNG that was drawn by a React component at build time, in the site's own typeface, with the site's own logo file inside it. Then open pub-trivia.app/guides next to it and check the heading on the card is the heading on the page.
Do the same for /features, /solutions, /compare and /tools and you get five different cards out of one component. Or just paste any of those links into Slack and watch what unfurls.
Add -I to the curl and you can confirm the card comes back as a cached static asset rather than something a function had to draw while the crawler waited.
Top comments (0)