Skip to content
Skip to main content
Novel Systems home
Decision log
D-108August 19, 2026

A canonical URL is declared per route, never once at the root, and a build check enforces it

Affects: app/layout.tsx (the alternates block, removed), app/signup/layout.tsx, app/login/layout.tsx, app/dashboard/layout.tsx (all new), scripts/check-canonical.mjs (new), package.json (prebuild, verify).

The problem

Search Console's page indexing report listed one URL under "Alternate page with proper canonical tag": https://www.novelsystems.ca/signup, last crawled 8 August 2026. That category is not a warning about a crawl failure. It means the page was fetched, parsed, and excluded from the index because the page itself asked to be excluded.

It had. app/layout.tsx declared alternates: { canonical: "/" }, and Next merges that field by replacement rather than by path — a route that declares no alternates block of its own inherits the parent's entire block verbatim. Exactly three routes declared none: /signup, /login and /dashboard. Read out of the built output of f487a75, all three served:

<title>Novel Systems | Enterprise CPQ & Field Service Management for Specialty Trade Contractors</title>
<meta name="robots" content="index, follow"/>
<link rel="canonical" href="https://www.novelsystems.ca"/>
<link rel="alternate" hrefLang="en-CA" href="https://www.novelsystems.ca"/>

Three distinct pages, each carrying the homepage's default title and each declaring that it *was* the homepage. Google did what the specification requires and honoured the canonical.

Why it mattered on /signup specifically

config/routes.ts lists /signup at priority 0.9, above /contact and level with /pricing, and states the reason in the file: it is the only route on the site that converts without a human on the other end. app/sitemap.ts publishes it from that register. So the site was asking a crawler to index the page in one file and refusing it in another, in the same response, and the refusal won.

Nothing was misspelled and nothing failed to compile. Every file involved was individually correct; the defect existed only in the inheritance between them, and the only place it was visible was a third-party console, weeks later.

What was decided

The root layout declares no canonical. It is a fact about one URL and there is exactly one URL it can be true for — and the homepage already declares its own in app/page.tsx, where it is true. The en-CA hreflang was removed alongside it: the site has one language, so a lone self-referential alternate says nothing Google acts on, and inheriting it leaked the homepage URL onto three more pages for no benefit.

The three auth routes are "use client" and so cannot export metadata themselves. Each got a sibling layout.tsx carrying it — the same boundary trick app/fsm/layout.tsx uses, run in reverse: interactivity stays below, metadata sits above where the framework can read it.

/signup now declares canonical: "/signup" and a title and description of its own, matching what the page visibly says. /login and /dashboard declare robots: { index: false, follow: true } — there is nothing on either for a search visitor, and follow is left on because both link onward and there is no reason to strand those links. Both keep a self-referential canonical rather than none, so that if the noindex is ever lifted the page starts from the truth about its own address.

Why a build check and not just the fix

Because the fix repairs three files and the class of bug survives them. Any route added tomorrow without an alternates block would have inherited the same lie, and the failure is silent in the type system, silent in the build, and silent in review.

scripts/check-canonical.mjs asserts three things: the root layout declares no canonical; every app/**/page.tsx resolves one from itself or a sibling layout; and every path in PUBLIC_ROUTES canonicalises to itself. The third is the one that would have caught this — the first two only describe how it happened. It reads the files as text rather than importing them, for the reason check-hosts.mjs gives: a check that needs the thing it checks to compile first stops running exactly when it is most wanted.

All three rules were exercised against deliberately broken copies before the script was wired in, rather than trusted because they were written carefully.

What was rejected

Removing `/signup` from `PUBLIC_ROUTES` to resolve the contradiction. It would have made the two signals agree, and it would have agreed on the wrong answer. The contradiction was evidence that the canonical was wrong, not that the sitemap was.

Leaving `/login` and `/dashboard` on `index, follow` now that their canonicals are honest. A sign-in form and a per-user quote list have nothing to rank for. Indexable-but-empty pages are how a small site spends crawl budget on nothing, and 107 URLs are already sitting in "Discovered — currently not indexed".

`robots: { index: false, follow: false }` on those two. nofollow on a page that carries the site navigation discards internal links for no gain. /login and /signup are two of the three pages Google used to discover / in the first place.

What would have to change for this to be wrong

If the site ever serves a genuinely duplicated route — the same content at two addresses, as /sandbox already does by canonicalising to /platform/cpq — rule 3 still holds, because such a route belongs outside PUBLIC_ROUTES and /sandbox is. A route that needs to be both in the sitemap and canonicalised elsewhere would be a contradiction the check is right to reject.

Rule 3 reads string literals only. app/corrections/page.tsx uses a constant and the decision pages build theirs from a template, and neither can be resolved by reading text. Those are counted and reported as unverified rather than assumed correct — a jump in that number is the signal that the check is quietly covering less than it appears to.

What this does not promise

/signup was excluded on our instruction, so removing the instruction removes the cause. It does not schedule the recrawl. The page has to be fetched again before the index reflects any of this, and on a site where 107 URLs are waiting in "Discovered — currently not indexed", that is a queue rather than a transaction.