Every page on your Next.js site may be sharing the homepage's link
Next merges page metadata into the layout's, and openGraph is where that goes wrong. Three metadata bugs you cannot see from inside your own site, and the one helper that closes all of them.
This one was invisible from inside the site for weeks. Every page rendered correctly, every title was right in the browser tab, and nothing in the build complained. The bug only appeared when somebody pasted a link into Slack.
Merging is per-field, and openGraph is a field
Next merges a route's metadata into its parent layout's. That word merge does a lot of quiet work, because the merge happens at the top level of the object and not inside it. A page that exports only a title and a description therefore inherits the layout's entire openGraph object exactly as written, including og:url.
On our site that meant every service page and every case study was shipping an og:url pointing at the homepage, along with the homepage's og:title and og:description. Shared to LinkedIn or Slack, a case study rendered its own image above the homepage's headline and the homepage's link. The image was right, which is exactly why nobody looked twice. Those images are generated at build time, so they were the one part nobody suspected.
The fix that makes it worse
The obvious response is to override openGraph on the page and set the right title. That is what our blog posts did, and it introduced a second bug: overriding replaces the whole object rather than merging into it, so those pages fixed og:title and dropped og:url entirely.
// layout.tsx
export const metadata = {
openGraph: {
url: "https://example.com/en",
title: "Homepage title",
description: "Homepage description",
images: ["/en/opengraph-image"],
},
};
// page.tsx — inherits the layout's ENTIRE openGraph object.
// og:url is the homepage. So is og:title.
export const metadata = {
title: "Case study",
description: "What we built",
};
// page.tsx — the "fix". Replaces the object outright:
// og:url and og:images are now gone.
export const metadata = {
openGraph: { title: "Case study" },
};Set the four together, in one place
The only version of this that stays fixed is a single helper that every route calls, which always emits title, description, canonical and the full openGraph object together. Not because a helper is tidier, but because the failure mode is a partial object, and a helper is the only thing that makes a partial object unrepresentable.
While you are in there: stop writing descriptions twice
The related bug is one string doing two jobs. A service paragraph written to be read on the page ran to 231 characters as a meta description, and Google cut it mid-clause. The answer is not to shorten the paragraph. It is to derive the snippet from it: as many whole sentences as fit, so it ends where the writer ended rather than at a character count.
const MAX_DESC = 158;
export function metaDescription(text: string, max = MAX_DESC): string {
const clean = text.replace(/\s+/g, " ").trim();
if (clean.length <= max) return clean;
// Keep the ender attached to the sentence it closes. The Arabic full
// stop and question mark are in the class because the same helper runs
// on all three locales, and a Latin-only split silently returns the
// whole paragraph on an Arabic page.
const sentences = clean.match(/[^.!?۔؟]+[.!?۔؟]*\s*/g) ?? [clean];
let out = "";
for (const s of sentences) {
if (out && (out + s).trim().length > max) break;
out += s;
}
out = out.trim();
if (out) return out;
// One sentence longer than the limit: trim at the last whole word.
const cut = clean.slice(0, max - 1);
return cut.slice(0, cut.lastIndexOf(" ")).trim() + "…";
}Titles want the same treatment for a different reason. Appending a brand suffix blindly gave us About | The team behind Growna | Growna on twelve pages, because several titles already carried the brand. And when the suffix will not fit inside the roughly sixty characters Google renders, dropping it beats truncating it: a half-cut brand name at the end of a result reads worse than no brand name, and the domain is shown beside it either way.
How to actually check
None of these are visible in a browser, which is the whole reason they survive. Fetch the raw HTML and read the tags. Do it for a child route, not the homepage, because the homepage is the one page where inheriting the layout's openGraph happens to be correct.
# Read what you actually ship, from a child route.
curl -s https://example.com/en/work/some-case-study \
| grep -oE '<meta property="og:[^"]*" content="[^"]*"|<link rel="canonical"[^>]*>'
# What you want to see:
# og:url the CHILD url, not the homepage
# og:title the child's own title
# og:image the child's own image
# canonical self-referencing, and matching og:url
