WhatsAppDamascusSat – Thu·10:00 AM – 7:00 PM

How we shipped 24 pages that called themselves duplicates

Next.js merges metadata key by key, so a route that omits alternates inherits its layout canonical. Ours pointed every page at the homepage.

SEOPublished 3 min read

Twenty-four pages claiming to be the homepage

We found this on our own site, which is why we can describe it honestly.

Next.js App Router merges metadata down the route tree, key by key. A layout exports some metadata, a page exports some more, and the framework combines them — page values win where both define a key, and layout values survive where the page is silent. That is the documented behaviour and it is convenient right up until the key in question is alternates.

Our locale layout set alternates for the homepage, because the homepage is what the locale layout renders at /ar. Every page below it that did not export its own alternates inherited that object wholesale. So /ar/pricing, /ar/services, all nine /ar/services/[slug] pages and their English counterparts each shipped:

html
<link rel="canonical" href="https://qasioun.cloud/ar">

Twenty-four URLs, each telling Google it was a duplicate of the homepage and should not be indexed on its own. Nothing was broken. Every page rendered correctly, returned 200, and appeared in the sitemap. The site was quietly asking to be de-indexed.

Why it is easy to miss

A wrong canonical produces no error, no warning and no visual symptom. It fails in a system you do not control, on a schedule you do not see, and the feedback is a gradual absence of traffic for pages that were never ranked in the first place — which looks exactly like a new site being new.

The hreflang half is worse. An inherited language map does not merely fail to help, it makes a positive false claim: it tells a search engine that the Arabic alternate of /en/pricing is the Arabic homepage. A wrong hreflang is worse than no hreflang, because no hreflang leaves the engine to guess and a wrong one stops it guessing.

The fix, and why it is a function

The rule we now hold: every indexable route calls alternates() in its own generateMetadata. Not "should" — a route that does not is a bug, because metadata inheritance means omission is not neutral.

The second half of the fix matters more than the first. The canonical and the language map are built by one function, from the unprefixed path:

ts
export function alternates(path: string, locale: Locale) {
  const href = (l: Locale) => `/${l}${path === '/' ? '' : path}`;
  const languages = Object.fromEntries(locales.map((l) => [l, href(l)]));
  languages['x-default'] = href(defaultLocale);
  return {canonical: href(locale), languages};
}

Hand-writing the alternates per page would have fixed the twenty-four pages and guaranteed the same bug returns the day a third locale is added. Deriving every spelling of the URL from one place is what makes them unable to disagree — including the trailing slash on a locale home, where /ar and /ar/ are two spellings of one page and the canonical exists precisely to pick one.

What to check on your own site

  • View source on a deep page — not the homepage — and read the canonical. This is a thirty-second check and it is the one nobody runs.
  • Confirm every hreflang points at a URL that returns 200 and is itself canonical. A chain of canonical to redirect to canonical is a wasted signal.
  • Include x-default, and point it at the locale you actually serve to everyone else.
  • Make hreflang reciprocal. If the English page names the Arabic one, the Arabic page must name the English one. One-directional annotations are discarded.
  • Do the same audit after adding any route. The failure mode is silent by construction, so it has to be a habit rather than a discovery.
How we shipped 24 pages that called themselves duplicates · Qasioun Cloud