PubTrivia submits 72 URLs in its sitemap. Seven of them tell a crawler what picture to use when the page is shared. The other sixty-five say nothing.
That is not the interesting part. This is: every single one of the 72 has a twitter:image, drawn by the same mechanism, in the same directory, at the same build.
You can check both halves from a terminal.
$ curl -s https://pub-trivia.app/features | grep -o 'og:image" content="[^"]*"'
og:image" content="https://pub-trivia.app/features/opengraph-image?5817fb5bcaf9c1d8"
$ curl -s https://pub-trivia.app/features/live-leaderboard | grep -o 'og:image" content="[^"]*"'
$
Nothing on the second one. Now the Twitter tag on the same two pages:
$ curl -s https://pub-trivia.app/features | grep -o 'twitter:image" content="[^"]*"'
twitter:image" content="https://pub-trivia.app/features/twitter-image?2a74276a48d27855"
$ curl -s https://pub-trivia.app/features/live-leaderboard | grep -o 'twitter:image" content="[^"]*"'
twitter:image" content="https://pub-trivia.app/features/twitter-image?2a74276a48d27855"
Same card. Same segment. The spoke page inherits the Twitter one and loses the Open Graph one.
The setup
The app directory holds fourteen metadata image files: an opengraph-image.tsx and a twitter-image.tsx at the root and in each of the six content cluster segments.
app/opengraph-image.tsx app/twitter-image.tsx
app/features/opengraph-image.tsx app/features/twitter-image.tsx
app/solutions/opengraph-image.tsx app/solutions/twitter-image.tsx
app/guides/… app/guides/…
app/quiz-questions/… app/quiz-questions/…
app/tools/… app/tools/…
app/compare/… app/compare/…
Every page's metadata comes from one helper, because the canonical has to be built from the path and nobody can be trusted to remember to write one:
export function pageMetadata({ path, title, description, absoluteTitle = false }) {
const fullTitle = absoluteTitle ? title : `${title} | ${SITE_NAME}`
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
openGraph: {
title: fullTitle,
description,
type: 'website',
url: path,
},
}
}
Every one of the 72 pages goes through that. The hubs do, the spokes do, the legal documents do.
Why the Twitter tags survive and the Open Graph ones do not
Metadata in the App Router merges from the root layout downwards, and the merge is per key, not deep. A segment that sets openGraph replaces whatever openGraph was resolved above it. A segment that never mentions twitter inherits twitter untouched.
The helper sets openGraph. It does not set twitter. That is the whole difference.
The file convention is the part that makes it visible. An opengraph-image.tsx contributes to the resolved openGraph.images of the segment it sits in, and that resolved object flows down to nested pages the same way the rest of the metadata does. On /features, the page's own openGraph and the file live in the same segment, and the file convention wins, so the image is kept. On /features/live-leaderboard, the page's openGraph arrives from a deeper segment with no images key in it, overwrites the one that had the image in it, and the card is gone.
twitter-image.tsx does exactly the same thing on the twitter key. Nothing overwrites it, so it reaches all 72.
Two more things go out with it
The root layout also sets these:
openGraph: {
type: "website",
locale: "en_GB",
siteName: "PubTrivia",
},
Count them across the live site and the result is the same shape of answer:
| tag | pages that emit it |
|---|---|
twitter:card |
72 |
twitter:image |
72 |
og:image |
7 |
og:site_name |
0 |
og:locale |
0 |
og:type survives only because the helper happens to set type: 'website' again on its way past. locale and siteName were written once in the root layout in June, and have never appeared in a single response since.
Zero is the number that makes the diagnosis certain. A field that is missing from 65 pages could be a dozen things. A field that is declared once at the top of the tree and emitted nowhere at all can only be one.
The fix is one spread, and the comment was the warning
The file comment on each cluster's image route says it is "inherited by every page beneath" that segment. That sentence is true of the mechanism and false of this app, because the app overwrites the thing doing the inheriting.
There is no way for a page to say "add to the inherited openGraph". The helper has to put back what it is replacing:
openGraph: {
type: 'website',
locale: 'en_GB',
siteName: SITE_NAME,
title: fullTitle,
description,
url: path,
},
The images cannot be restored that way, because a page does not know which ancestor segment has an image file. That one is a choice between moving the image files down to each page, dropping openGraph from the helper and letting the layouts carry what is shared, or accepting seven cards.
Worth checking on your own site
If you use a metadata helper and a metadata image file in the same App Router project, these two commands answer the question in about four seconds:
curl -s https://your-site/some/nested/page | grep -c 'og:image'
curl -s https://your-site/some/nested/page | grep -c 'twitter:image'
Two different numbers means a key you set yourself is eating one of them.
Ours are live if you want to see both states next to each other. The features hub is one of the seven with a card. The live leaderboard page under it is one of the sixty-five without, and the tools hub is another of the seven. View source on any of them and the Twitter tags are there every time.
Top comments (0)