Canonical URLs: Managing Duplicate Content and the Canonical Google Chooses
rel=canonical is a signal, not a command: Google picks the URL on which redirects, canonicals, the sitemap and internal links agree. We write a canonical tag helper in ASP.NET Core that ignores the Host header and drops needless parameters, and look at the Link header for PDFs, a single canonical in Angular SSR and Google's December 2025 JavaScript rule, with real outputs.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10 Razor Pages) and Angular 22.2; the canonical rules are explained based on Google Search Central as of November 2026.
Guess First
At Bookstore, Dune's page opens from all of these addresses: https://bookstore.example/books/dune, http://bookstore.example/books/dune, https://www.bookstore.example/books/dune, /books/dune?utm_source=newsletter, /books/dune/print. All of them return 200 with the same content. An SEO (Search Engine Optimization) consultant suggests "pointing the canonical of every book page to the home page to consolidate signals".
Two questions. If the consultant's suggestion is applied, what does Google do with the book pages? And if the canonical tag on the page points to address A while the sitemap lists B and the site's internal links go to C, which URL (Uniform Resource Locator) does Google show in results?
In the first post of the series we set the goal of one permanent URL, and in the previous post we said "put only canonical URLs in the sitemap". In this post we open up the concept of canonical: where duplicate content comes from, what Google looks at when choosing a canonical, and how to produce the canonical link correctly in ASP.NET Core and Angular.
Where Does Duplicate Content Come From?
Google groups URLs carrying the same or very similar content into a cluster and picks one of them as canonical; that's the URL shown in results and where signals are consolidated, and the others are crawled less often as duplicates. Most duplicates come not from bad intent but from infrastructure:
- Protocol and host variants:
http/https, with and withoutwww. - Path variants: case, trailing slash,
index.html. - Query parameters: tracking parameters (
utm_source,ref), sort order, session IDs (Identifiers). - Different presentations of the same content: a print page, an AMP (Accelerated Mobile Pages) version, an old mobile subdomain.
- Language and region variants: the same English content published under
/en-us/and/en-gb/. - Pagination and filter combinations: the same product list shown in different orders.
One important distinction: a translation isn't a duplicate. According to Google's documentation, localized versions count as duplicates only if the main content is left untranslated. Turkish and English pages aren't each other's canonical; each is its own canonical, and hreflang describes the relationship between them.
Canonical Signals: Strong, Weak and Consistent
rel=canonical is a signal, not a command. Google chooses the canonical by looking at several signals, and its documentation ranks them by strength:
| Method | Strength | When |
|---|---|---|
| Permanent redirect (301, 308) | Strong | When the duplicate URL should no longer be used |
rel="canonical" link element or HTTP (HyperText Transfer Protocol) header | Strong | When the duplicate URL must stay reachable (tracking parameter, print page) |
| Being listed in the sitemap | Weak | On large sites, in addition to the other methods |
Beyond these, Google also looks at internal links, its HTTPS (HTTP Secure) preference (Google prefers HTTPS by default) and reciprocal hreflang clusters. Here's the answer to the second opening question: when A, B and C contradict each other, which one Google picks is unpredictable; the canonical tag doesn't win on its own. The documentation says it plainly: don't specify different canonicals for the same page with different techniques, and link internally to the canonical URL. When all signals point to the same URL, the choice becomes almost certain.
Other rules from the documentation: the canonical must be an absolute URL, the canonical page should point to itself (self-referencing), and robots.txt, the Removals tool or noindex must not be used for canonicalization. This also shows what's wrong with the consultant's suggestion: Dune's page isn't a duplicate of the home page; canonicalizing every book page to the home page tells Google "these pages are actually copies of the home page". Since the contents are obviously different, Google will most likely ignore that signal; if it doesn't, the book pages disappear from the results. Neither outcome is wanted.
A Canonical Tag Helper in ASP.NET Core
Instead of writing the canonical by hand on every page of Bookstore's Razor Pages application, a tag helper generates it. The rules: host and protocol come from configuration, the path is lowercased, only the query parameters that really change the content are kept, in a fixed order, and default values are dropped.
using System.Web;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Options;
namespace Bookstore.Canonical.TagHelpers;
public sealed class SiteOptions
{
public required string BaseUrl { get; set; } // "https://bookstore.example"
}
[HtmlTargetElement("canonical-link", TagStructure = TagStructure.WithoutEndTag)]
public sealed class CanonicalLinkTagHelper(IHttpContextAccessor accessor, IOptions<SiteOptions> site) : TagHelper
{
// query keys that really change the content, in a fixed order
public string KeepQuery { get; set; } = "";
public override void Process(TagHelperContext context, TagHelperOutput output)
{
HttpRequest request = accessor.HttpContext!.Request;
string path = (request.PathBase + request.Path).Value!.ToLowerInvariant();
List<string> parts = [];
foreach (string key in KeepQuery.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
string? value = request.Query[key].FirstOrDefault();
if (string.IsNullOrEmpty(value) || (key == "page" && value == "1"))
continue; // drop defaults: ?page=1 is the same page
parts.Add($"{key}={HttpUtility.UrlEncode(value)}");
}
// the base URL comes from configuration, never from the Host header
string href = site.Value.BaseUrl + path + (parts.Count > 0 ? "?" + string.Join('&', parts) : "");
output.TagName = "link";
output.TagMode = TagMode.SelfClosing;
output.Attributes.SetAttribute("rel", "canonical");
output.Attributes.SetAttribute("href", href);
}
}
On pages it's a single line; the list page states for itself which parameters go into the canonical:
@page "/books"
<!DOCTYPE html>
<html lang="en">
<head>
<title>Books | Bookstore</title>
<canonical-link keep-query="genre,page" />
</head>
<body><h1>Books</h1></body>
</html>
$ curl -s "http://localhost:20106/books/dune" | grep canonical
<link rel="canonical" href="https://bookstore.example/books/dune" />
$ curl -s "http://localhost:20106/Books/Dune?utm_source=newsletter&ref=home" | grep canonical
<link rel="canonical" href="https://bookstore.example/books/dune" />
$ curl -s "http://localhost:20106/books?genre=scifi&sort=price&page=2&utm_campaign=bf" | grep canonical
<link rel="canonical" href="https://bookstore.example/books?genre=scifi&page=2" />
$ curl -s "http://localhost:20106/books?page=1&genre=scifi" | grep canonical
<link rel="canonical" href="https://bookstore.example/books?genre=scifi" />
$ curl -s "http://localhost:20106/books?sort=title" | grep canonical
<link rel="canonical" href="https://bookstore.example/books" />
The uppercase request with tracking parameters lands on the clean address, the five-parameter list comes down to genre and page, and page=1 becomes the parameterless address. The sort parameter is always dropped, because showing the same list in a different order isn't new content. The & in the HTML (HyperText Markup Language) is the correctly encoded form of & inside an attribute; browsers and crawlers read it as &. The second page of the pagination (page=2) carries its own canonical; canonicalizing page two to page one is a common mistake that weakens the discovery of the books on page two.
The naive version is the easiest way to show why the base URL comes from configuration rather than the Host header. The classic shortcut: the canonical is whatever URL the request came in on.
@page "/naive/{slug}"
<link rel="canonical" href="@($"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}")" />
$ curl -s -H "Host: evil.example" "http://localhost:20106/naive/dune?utm_source=x"
<link rel="canonical" href="http://evil.example/naive/dune?utm_source=x" />
$ curl -s -H "Host: evil.example" "http://localhost:20106/books/dune" | grep canonical
<link rel="canonical" href="https://bookstore.example/books/dune" />
The naive version makes three mistakes at once: it carries the tracking parameter into the canonical, it produces an http canonical on an HTTPS site because Request.Scheme is http behind a reverse proxy, and it writes the Host header sent by the client straight into the page. The last one is a cache poisoning risk: if a cache in between stores this response, a page whose canonical points to another domain gets served to everyone. Filling AllowedHosts in appsettings.json with your real domains instead of * enables host filtering and rejects this request with 400; still, code that builds URLs not trusting the Host header is a separate layer of defense. Forwarded headers for the correct scheme and host behind a reverse proxy are covered in the Web Servers and Hosting series.
For non-HTML files, the canonical is given in the HTTP header with Link. At Bookstore, the same catalog PDF lives at both the old /downloads/catalog.pdf and the new /uploads/catalog-2026.pdf:
app.MapGet("/downloads/catalog.pdf", (HttpContext ctx) =>
{
ctx.Response.Headers.Link = "<https://bookstore.example/uploads/catalog-2026.pdf>; rel=\"canonical\"";
return Results.Bytes("%PDF-1.4\n%%EOF\n"u8.ToArray(), "application/pdf");
});
$ curl -s -D - -o /dev/null http://localhost:20106/downloads/catalog.pdf | grep -iE "^HTTP|^link|^content-type"
HTTP/1.1 200 OK
Content-Type: application/pdf
Link: <https://bookstore.example/uploads/catalog-2026.pdf>; rel="canonical"
Google recommends choosing a single method per page: if the same HTML page has both a <link> and a Link header pointing to different URLs, that's an error.
JavaScript and Angular: One Canonical, in the First HTML
In December 2025 Google clarified the canonical rule for JavaScript: the canonical link should ideally be in the HTML; if it's set with JavaScript, its value must match the canonical in the original HTML, or the original HTML must not have a canonical at all. Producing more than one canonical tag or changing an existing one isn't recommended. In practice there are two dangerous scenarios: a home-page canonical hard-coded into index.html that JavaScript changes later, and code that adds a new <link> on every navigation without removing the old one.
Bookstore's Angular SeoService manages the canonical through the DOCUMENT token; that way the same code runs both on the server (SSR, Server-Side Rendering) and in the browser, and always updates a single element:
private setCanonical(url: string): void {
// a single canonical link: update it if it exists, create it otherwise
let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
On pages given noindex, the service removes the canonical entirely; noindex and a canonical on the same page are two conflicting signals. Looking at the HTML the server sends:
$ curl -s http://localhost:20110/books/dune | grep -o '<link rel="canonical"[^>]*>'
<link rel="canonical" href="https://bookstore.example/books/dune">
$ curl -s http://localhost:20110/books/dune | grep -c 'rel="canonical"'
1
$ curl -s http://localhost:20110/client/books/dune | grep -c 'rel="canonical"'
0
On the prerendered book page, the canonical is in the first HTML and there's only one. On the route that serves the same component with RenderMode.Client, there's no canonical in the first HTML; it's added once JavaScript runs. That's the second case Google's rule allows (no canonical in the original HTML), but it depends on rendering: if Google doesn't render the page, it never sees the canonical. For pages you want indexed, the first choice should be SSR or prerendering; we go deeper into that in the eighth and ninth posts of the series. Why the DOCUMENT token is used instead of the global document is covered there too.
The Canonical Google Chooses
You declare the canonical, Google chooses it. The URL Inspection tool in Search Console shows the canonical Google selected for a URL; if it differs from what you declared, look for the reason in the signals. Google's canonicalization troubleshooting page lists the common causes: serving nearly the same content to different regions without hreflang, a CMS (Content Management System) or plugins producing wrong canonicals or redirects, misconfigured servers serving one domain's content for another domain, cross-domain canonicals injected into hacked sites, content republished on partner sites, and sites copying content without permission.
After a fix, patience is needed: the same document says that Google may keep pages in the same duplicate cluster for up to two weeks even after the content problems are fixed; clear differences between pages help the clusters split faster. Its recommendation for syndication (republishing content on partner sites) is notable too: ask the partner to block indexing rather than relying on a canonical.
Canonicals on mertozen.com
In this site's source code, the canonical is built from SiteUrl in configuration (https://www.mertozen.com) and the page's own path; the Host header isn't consulted, so the problem of the naive version above doesn't exist. When the canonical is built for the blog list, parameters are placed in a fixed order, page=1 and the default sort are dropped, and the on-site search term (q) never makes it in. Thanks to that, the search results page points not to its own canonical but to the list with the term dropped.
One point is open to debate: if the sort order differs from the default (the sort= parameter), it goes into the canonical. Different orders of the same post list thus become separate canonical URLs; Google's URL structure page lists sort parameters among the parameters that create duplicates. Dropping the sort from the canonical, as the tag helper above does, would consolidate that cluster into one URL. Category, series and tag, on the other hand, change the content and should stay in the canonical. The decision to change it is the site owner's.
The Turkish and English pages correctly carry their own canonicals and are linked to each other with hreflang; the details of the multilingual setup are in the tenth post of the series.
Production Notes
Test your canonicals. A template bug can turn the canonical of every page into a single URL, and you won't see it in the browser. Asserting the expected canonical for a few representative URLs in an integration test is cheap insurance.
The canonical URL itself must return 200. Pointing the canonical to a URL that redirects, returns 404 or carries noindex means telling Google "this is the right address" and then closing that address. It's the most common mistake after site migrations: canonicals pointing to the old domain are forgotten in a template.
The sitemap, internal links and canonical must use the same URL form: same scheme, same host, same trailing slash rule. The normalization middleware from the first post and this tag helper should apply the same rules; feeding both from a single helper class keeps the rules from drifting apart.
Check the "Google-selected canonical" field in URL Inspection every now and then for important template types (book, category, author). If there's a difference, look at signal consistency first.
Common Pitfalls
Canonicalizing every page to the home page. If the pages differ, Google ignores the signal; if it doesn't, the pages drop out of the index.
Pointing the canonical to a noindex or redirecting URL. The signals cancel each other out.
Writing a relative canonical. href="/books/dune" may work, but Google recommends absolute URLs; a relative URL can be resolved with the wrong host or protocol.
Copying the canonical from the request (Host, scheme, query). Tracking parameters, http and foreign hosts leak into the canonical.
Adding a second canonical with JavaScript or changing the one in the HTML. Under Google's December 2025 rule, the value in JavaScript must match the HTML or the HTML must have none.
Canonicalizing every page of a paginated list to page one. Discovery of the items on page two and beyond weakens; every page should carry its own canonical.
A Small Experiment
★☆☆ Request five URLs on your own site with a tracking parameter, uppercase letters and a trailing slash added, and compare the canonicals returned. Do they all come down to the same clean URL?
★★☆ Add an Href property to CanonicalLinkTagHelper: let a page set its canonical explicitly when it wants (for example /books/dune for the print page /books/dune/print). Discuss the canonical for the print page and whether the same page should also get noindex.
★★★ Tie the normalization middleware from the first post of the series and this tag helper to a single CanonicalUrlBuilder class: the middleware turns the incoming URL into the canonical with this class and returns 301 if they differ, and the tag helper writes the result of the same class. Then show with a test the rule that tracking parameters must not be redirected (so analytics data isn't lost) but must be dropped from the canonical.
A canonical chooses between duplicate URLs; but whether a URL exists, has moved or is temporarily broken is told by the status code. In the next post we'll look at how Google interprets 404, 410, 5xx and 429, soft 404s, and the problem of SPAs (Single-Page Applications) returning 200 for everything, with ASP.NET Core and Angular SSR.