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.

Writing

Want this done on your site?

Book a call

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" },
};
Both of these look correct in review and neither is. The first inherits too much, the second replaces too much.

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() + "…";
}
The multilingual detail matters more than it looks. A sentence splitter that only knows Latin punctuation does not throw on Arabic, it just never matches, and returns the entire paragraph.

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
Add it to CI over a handful of representative routes. It is a two-line check that catches a class of bug no amount of local browsing will show you.

Let's build the site your business deserves.

Send what you have now and where you want it to go. You get a straight answer on scope, timeline and cost, usually within the hour.

Working across North America, Europe and the Middle East.