DEV Community

Every page on our site has a twitter:image. Seven of them have an og:image.

We draw our own link preview cards. Not exported from Figma, not a PNG someone remembered to update: a React component that Satori rasterises at build time, so the card a reader sees in Slack is made of the same logo, the same fonts and the same palette as the page they land on.

I was fairly pleased with it. Then I fetched all 72 URLs in our sitemap and counted the meta tags.

  • pages 72
  • with og:image 7
  • with twitter:image 72

Seven. The card exists, it renders, it is on the CDN, and 65 of our 72 pages never mention it.

What is actually built

There are fourteen route files behind those cards, seven opengraph-image.tsx and seven twitter-image.tsx: one pair in the root segment and one pair in each of the six content clusters. They all call into a single 252-line module.

A few things in there are only there because Satori is not a browser:

// next/og rasterises through Satori, which supports TrueType and OpenType but
// not the WOFF2 that next/font/google downloads, so the four faces are
// vendored as .ttf in assets/fonts/ and read off disk.
const [regular, semibold, bold, mono] = await Promise.all([
  readFile(join(process.cwd(), "assets/fonts/Geist-Regular.ttf")),
  // ...
])

Those paths are written out as whole literal strings rather than assembled from segments, because the bundler traces a route's file dependencies by reading the call, and a join(process.cwd(), ...segments) it cannot evaluate makes it give up and trace the entire project.

The logo gets similar treatment. Satori will not rasterise a raw <svg> child, but it will rasterise an <img> whose src is an SVG data URI, so public/logo.svg ships into the card as itself instead of being redrawn in a second, divergent copy:

const svg = await readFile(join(process.cwd(), "public/logo.svg"))
return `data:image/svg+xml;base64,${svg.toString("base64")}`

Every one of the fourteen routes declares export const dynamic = "force-static", because the renderer reads files off disk and disk is a build machine, not a serverless bundle. It also means a crawler gets a PNG off the CDN rather than waiting on a cold function to draw one.

The seven twitter-image.tsx files are the same render under a second name:

import ogImage, { alt as ogAlt, contentType as ogContentType, size as ogSize } from "./opengraph-image"
// `dynamic` is re-declared rather than re-exported: Next parses route segment
// config out of the source statically and rejects a re-exported binding.
export const dynamic = "force-static"
export const alt = ogAlt
export const size = ogSize
export const contentType = ogContentType
export default ogImage

That really is the same picture. All seven pairs are byte-identical, which you can check without trusting me:

  • /opengraph-image 200 91693 image/png af4a4255b588d0f4c8e034121d50cbdf
  • /twitter-image 200 91693 image/png af4a4255b588d0f4c8e034121d50cbdf
  • /guides/opengraph-image 200 72783 image/png 1d6f3a21d03f5711f573ab67b1c80246
  • /guides/twitter-image 200 72783 image/png 1d6f3a21d03f5711f573ab67b1c80246

The seven pages

Here is the part I had wrong. The seven pages carrying an og:image are exactly the seven segments that own an opengraph-image.tsx file: the homepage and the six cluster hubs. Every spoke beneath them has nothing.

Open one of our guides and read the head. The Open Graph block is complete apart from the one tag that matters for a preview:

<meta property="og:title" content="How to Host a Pub Quiz | PubTrivia" />
<meta property="og:description" content="The complete guide to running a quiz night: ..." />
<meta property="og:url" content="https://pub-trivia.app/guides/how-to-host-a-pub-quiz" />
<meta property="og:type" content="website" />

No og:image.

Now grep the same page for the Twitter card and it is there, pointing at the cluster's picture:

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://pub-trivia.app/guides/twitter-image?2a74276a48d27855" />

One of those two tags is present on 72 pages out of 72. The other is present on 7.

Why

Every content page builds its metadata through one helper, which exists to make the canonical URL fall out of naming the path:

return {
  title: absoluteTitle ? { absolute: title } : title,
  description,
  alternates: { canonical: path },
  openGraph: {
    title: fullTitle,
    description,
    type: 'website',
    url: path,
  },
}

Next resolves metadata by walking the segment chain and merging what each level contributes, and that merge is shallow per top-level field. A file-convention image contributes openGraph.images to the segment that owns the file. A page that exports its own openGraph object replaces the inherited one wholesale, images included.

So the rule we accidentally wrote is:

  • the homepage and the six hubs sit in the segment that owns the file, their own metadata merges at the same level, and the image survives
  • every spoke page is one level deeper, declares openGraph, and overwrites the object the parent segment had filled in
  • nothing anywhere declares twitter, so the twitter-image convention is never overwritten and reaches all 72 pages

The helper is not wrong about anything it was written to do. It never mentions images, which is precisely how it deletes them.

What it cost

Facebook, LinkedIn, Slack, WhatsApp, Discord and iMessage read og:image. X reads twitter:image and falls back to og:image. So for 65 of our pages the generated card works on exactly one network, and every other share is a text-only link, for a card that had already been drawn and cached.

The failure mode is unusually good at hiding. You test a link preview by pasting your homepage into Slack. The homepage is one of the seven.

Check it yourself

Everything above is public:

# the card exists
curl -sI https://pub-trivia.app/guides/opengraph-image | head -3
# the page that should point at it does not
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -o 'og:image[^>]*'
# ... while the twitter tag is right there
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -o 'twitter:image[^>]*'
# and the hub, one level up, has both
curl -s https://pub-trivia.app/guides | grep -o 'og:image[^>]*'

Or paste a spoke page and its hub into any Slack channel and watch one of them bring a picture.

The fix is a line in the helper and not an interesting line, so I would rather leave you with the lesson than the diff: if you generate metadata through a shared function and also use file-based metadata conventions, the two are competing for the same keys, and the shallow merge decides. Count the tags in production. The build will never tell you.

If you want to see the card that all of this was for, it is on pub-trivia.app, which is also where you can start a free quiz night without a card. The mug on the preview image is the same 606 bytes of SVG as the one in the header.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.