Add canonical links for SEO

Search engines want a single canonical URL per page, and on a multilingual site they want to know which pages are translations of each other. @nera-static/plugin-canonical-links emits the <link rel="canonical"> tag on every page and hreflang alternate tags between translations. Get these wrong and nothing breaks visibly — the page renders fine, and the mistake only shows up in a search console weeks later. So check the output, as this tutorial does at every step.

1. Install and publish the template

terminal
npm install @nera-static/plugin-canonical-links
npx nera-canonical-links

The second command copies index.pug and its partials/ to theme/views/vendor/plugin-canonical-links/. It skips if that folder already exists, so your edits are safe; npx nera-canonical-links --force overwrites them with the shipped templates after an upgrade.

2. Set your origin

Canonical URLs are absolute, so the plugin needs your site's origin. Set it once in config/app.yaml:

yaml
origin: https://example.com

That is all the configuration a single-language site needs — the plugin's own config file is optional. If you do create config/canonical-links.yaml, its app_origin is used as a fallback when app.yaml has no origin; when both are set, origin in app.yaml wins. A trailing slash is stripped either way.

With no origin at all, the plugin emits no canonical or alternate links and prints one warning during the build:

text
⚠️ plugin-canonical-links: no origin configured, so no canonical links were generated.
    Set `origin` in config/app.yaml, or `app_origin` in config/canonical-links.yaml.

If your site is served from a subdirectory, base_path in config/app.yaml is part of the URL too. Canonical and alternate URLs are built as origin + base path

  • page path:
yaml
origin: https://example.com
base_path: /docs/
html
<link href="https://example.com/docs/getting-started.html" rel="canonical" />

Without base_path the output is unchanged. Versions before 2.2.3 left the base path out and pointed search engines at a URL that does not exist.

3. Emit the tags

The plugin sets meta.canonicalLink on every page. Include the published template in your layout's <head>:

pug
head
  include /vendor/plugin-canonical-links/index

The leading slash makes the path root-absolute: it resolves against theme/views/, wherever the including file sits. The relative form include ../vendor/plugin-canonical-links/index does the same, but only from a layout in theme/views/layouts/ — use it on Nera core 4.1–4.2, where the root-absolute form is not available yet. A bare include vendor/… (no slash, no ../) looks in theme/views/layouts/vendor/ and fails the build.

Build and view the source of pages/getting-started.md — you'll see:

html
<link href="https://example.com/getting-started.html" rel="canonical" />

Alternate-language links need three things. First, list your languages in config/canonical-links.yaml:

yaml
page_identifier: slug
available_languages:
    - en
    - de

page_identifier names the frontmatter key that marks two pages as translations of each other; slug is the default, so you can leave the line out. Second, every page sets its lang. Third, translations share an identical slug:

markdown
---
title: Getting started
slug: getting-started
lang: en
---
markdown
---
title: Erste Schritte
slug: getting-started
lang: de
---

With the English page at pages/getting-started.md and the German one at pages/de/getting-started.md, the build gives the English page:

html
<link href="https://example.com/getting-started.html" rel="canonical" />
<link href="https://example.com/de/getting-started.html" hreflang="de" rel="alternate" />

and the German page:

html
<link href="https://example.com/de/getting-started.html" rel="canonical" />
<link href="https://example.com/getting-started.html" hreflang="en" rel="alternate" />

Each page points at its translations, not at itself. All of this fails silently:

  • Without available_languages you get canonical links only, and meta.alternateLinks is an empty array.
  • A page without lang gets its canonical link but no alternates.
  • A page without a slug is never matched to anything.

If you write your own template instead of the published one, read meta.alternateLinks — a list of { href, hreflang, rel } — and guard for both keys being absent when no origin is set.

5. Cover pages from other plugins

Some plugins create pages while they run — @nera-static/plugin-tags builds its tag overview pages that way (see Tag your content). Plugins run in alphabetical order, and plugin-canonical-links comes before plugin-tags, so those tag pages are built after it has finished and get no canonical link. Move it to the end in config/plugin-order.yaml (Nera core 4.2.0 or newer):

yaml
plugin-order:
    - end:
          - plugin-canonical-links

Now every tag page carries one:

html
<link href="https://example.com/tags/seo.html" rel="canonical" />

Generated pages have no slug, so they get a canonical link and no alternates.

The sitemap plugin, @nera-static/plugin-sitemap, reads the same origin (origin in app.yaml, else app_origin) and the same base_path, so the URLs in your sitemap and your canonical links agree — on a base_path site from plugin-canonical-links 2.2.3 on.

This site uses the plugin as well, set up as this tutorial describes: origin: https://nera.js.org in config/app.yaml, the root-absolute include in its layout, and plugin-canonical-links under end: in config/plugin-order.yaml. Its config/canonical-links.yaml lists only en and de as languages — Spanish is left out until a native-speaker review is done, so no page carries an hreflang="es" alternate yet. View the source of this page to see the result.