DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A canonical on the root layout tells Google every page is a duplicate of the homepage

Metadata in the Next.js App Router inherits. A value set on the root layout applies to every page beneath it unless that page overrides it. For the site name, the locale, the icons, the Twitter card type and the metadata base URL, this is exactly what you want: say it once, never think about it again.

For a canonical URL it is a quiet disaster, because a canonical is per page by definition. Set one on the layout and every page that does not override it inherits the homepage's, and each of those pages then ships markup that says "I am a duplicate of the homepage, index that instead".

So the root layout of PubTrivia has a comment where the canonical would be, and the canonical itself comes from somewhere that cannot forget.

Rule one: do not let a per-page value inherit

The root layout sets the things that genuinely are site-wide, and nothing else:

export const metadata: Metadata = {
  title: {
    default: "PubTrivia | Run Live Pub Quiz Nights",
    template: "%s | PubTrivia",
  },
  openGraph: { type: "website", locale: "en_GB", siteName: "PubTrivia" },
  twitter: { card: "summary_large_image" },
  metadataBase: new URL(SITE_URL),
  // No `alternates` here on purpose.
}
Enter fullscreen mode Exit fullscreen mode

The useful test when adding anything to a root layout's metadata is to ask whether the value would still be correct on a page three levels down that you have not written yet. The site name passes. A URL does not.

Rule two: remembering is not a mechanism

The obvious follow-up is "every page sets its own canonical". That is not a rule, it is a hope. An earlier pass at this site left six of the eight URLs then in the sitemap with no canonical at all, which is its own problem: without one, every query string and tracking parameter someone appends to your URL is a separate candidate page.

The fix is to make the canonical fall out of something a page cannot avoid doing. Every page has to name its own path for the helper to build its metadata, and the path is the canonical:

export function pageMetadata({ path, title, description, absoluteTitle = false, noIndex = 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 },
    ...(noIndex ? { robots: { index: false, follow: false } } : {}),
  }
}
Enter fullscreen mode Exit fullscreen mode

A page that forgets to call this has no description and no title either, which is the kind of mistake that gets noticed in the first five seconds. That is the whole design: attach the invisible thing to the visible thing.

You can confirm it held across the site. Every one of these answers with its own URL:

for p in "" pricing about faq guides compare/kahoot-for-pub-quiz quiz-questions/music; do
  curl -s "https://pub-trivia.app/$p" | grep -o 'rel="canonical" href="[^"]*"'
done
Enter fullscreen mode Exit fullscreen mode
rel="canonical" href="https://pub-trivia.app"
rel="canonical" href="https://pub-trivia.app/pricing"
rel="canonical" href="https://pub-trivia.app/about"
rel="canonical" href="https://pub-trivia.app/faq"
rel="canonical" href="https://pub-trivia.app/guides"
rel="canonical" href="https://pub-trivia.app/compare/kahoot-for-pub-quiz"
rel="canonical" href="https://pub-trivia.app/quiz-questions/music"
Enter fullscreen mode Exit fullscreen mode

Rule three: the title template does not apply to everything called a title

This one cost an afternoon. The root layout's template turns a page's short title into the full form, so a page sets "Pricing" and the browser tab reads Pricing | PubTrivia.

The template is not applied to openGraph.title. A page that sets a short title and nothing else gets a correct tab and a link preview headed with a bare Pricing, which in a chat client, stripped of the tab it came from, names no product at all.

That is why the helper composes the two separately: the short form goes to title so the template can do its work, and the full form is written out for the card. Same page, two strings, on purpose.

Check any page and the two agree:

curl -s https://pub-trivia.app/pricing | grep -oE '<title>[^<]*|og:title" content="[^"]*'
Enter fullscreen mode Exit fullscreen mode
<title>Pricing | PubTrivia
og:title" content="Pricing | PubTrivia
Enter fullscreen mode Exit fullscreen mode

The homepage is the exception that proves the helper needs an opt-out, since it already names the site in its own title and the template would produce PubTrivia | Run Live Pub Quiz Nights | PubTrivia. One boolean switches to the absolute form.

The separator is a pipe rather than a comma, which is a house-style decision this project treats as settled rather than open: a comma reads wrongly in a browser tab and in a search result, where the two halves are not a clause but a label and its owner. Two test files assert the exact rendered format, so changing it is a deliberate act rather than a stray edit.

Rule four: one string, not three copies of it

Content pages go one step further and do not type their title at all. They name a path, and the title and description are read from the registry node for that path:

export function contentMetadata(path: string): Metadata {
    const node = requireNode(path)
    return pageMetadata({
        path: node.path,
        title: node.title,
        description: node.description,
        absoluteTitle: node.path === '/',
    })
}
Enter fullscreen mode Exit fullscreen mode

The words in the tab, the words in the link preview and the words another page uses when it links here are then one string in one place. Before the registry they were three copies, and two of them had already drifted, which is the normal outcome: nobody updates a title in three files, they update it in the one they were already looking at.

The bonus: a boolean nobody has to remember

One more inheritance question with a non-obvious answer. Preview and staging deploys serve the same pages as production, and if they are indexable they put a second copy of every page into the index, competing with the first.

The root layout therefore gates index and follow on whether this deployment is actually the production site, which is derived by comparing the resolved base URL against the production origin:

export const IS_PRODUCTION_SITE = SITE_URL === PRODUCTION_URL
Enter fullscreen mode Exit fullscreen mode

Derived, not configured. There is no extra environment variable to set correctly on every preview, which matters because the one you forget is the one that gets indexed. The same boolean also gates the publisher's structured data, so a preview deploy cannot claim the production organisation's identifier and put a second node for the same entity into the graph.

Private sections additionally set index: false on their own layouts, because a robots.txt disallow stops crawling and not indexing, and those two facts need stating in two places.


Everything here is readable from outside with view source or the curl commands above. Pages worth looking at, since each one is built by the helper and reads its words from the registry: pricing, the guides index, a comparison page and a question pack page. The app they describe runs live pub quiz nights, and the free tier does not ask for a card.

Top comments (0)