Thirty pages. One card. Nobody told me.
For about a week, every link to this site previewed identically. Post a blog article to X, you got my homepage. Post a free tool, you got my homepage. Same title, same description, every time.
Here is the part worth your attention: every tag I would have thought to check was correct.
Why nobody reports this
Nobody sees two of your links side by side. A reader sees one card, assumes it's what you meant, and scrolls. You only notice by posting two different pages and catching that they look the same — which I did, once, by accident, while working on something else.
The cost is invisible and it compounds. A share is somebody vouching for a specific thing you made. If the card shows a generic homepage instead, that recommendation lands like an ad. You don't lose the click you can measure. You lose the one that never happened.
The half that was fine
Every social platform reads Open Graph tags. X reads those too, but prefers its
own twitter: tags when they exist.
In Next.js, page metadata merges over the root layout's, key by key. Set
openGraph on a page and omit twitter, and it does not derive one from the
other. It takes the layout's.
// Looks complete. Isn't.
export async function generateMetadata({ params }) {
const post = getPost((await params).slug);
return {
title: post.title,
openGraph: { type: "article", title: post.title },
// no twitter block → silently inherits the site-wide default
};
}So every page shipped a correct, specific og:title and an identical, wrong
twitter:title. Fetch the page and grep for og: — perfect. Grep for
twitter: — the same string on all thirty URLs.
No error. No warning. No failing test. The only signal was a card looking slightly less specific than expected, on a platform I don't control, seen by people who were never going to mention it.
The fix I didn't make
The obvious repair is to add the missing block to the six files that needed it.
I didn't, because that's the same discipline that had already failed six times. "Remember to add the tag" is not a fix. It's the thing that didn't happen.
Two values that must always agree should not be typed twice. So one function now builds both from a single input:
export function pageMetadata({ title, description, canonical }) {
const shared = { title, description };
return {
title,
alternates: { canonical },
openGraph: { ...shared, url: canonical },
twitter: { card: "summary_large_image", ...shared },
};
}Every page calls that. There is nowhere left to forget.
Then the part that actually matters — a test that reads the source, not the output:
it("routes every openGraph block through pageMetadata", () => {
const offenders = pageFiles()
.filter((f) => /openGraph\s*:/.test(f.source))
.filter((f) => f.path !== "layout.tsx")
.filter((f) => !f.source.includes("@/lib/metadata"));
expect(offenders).toEqual([]);
});A unit test on the helper can't catch a page that never calls the helper. This one fails the build and names the file.
The shape of it
The failures that survive longest are not the loud ones. They're the ones where the half you check is fine.
That pattern shows up everywhere once you look for it. A form that says your download is on its way, wired to a mailer that was never configured. An analytics tag firing on every page except checkout. A backup job that runs nightly, succeeds, and writes an empty file.
Each one passes the test you thought to run. None of them announce anything.
The defence isn't more care. It's asking a different question: not "is this right?" but "what would it look like if it were wrong?" For share cards, the answer was "identical previews," and that takes about ninety seconds to check across five URLs.
Worth doing on your own site now, while you're thinking about it. Pull any two
pages and compare twitter:title. If they match, they've been matching for a
while.