Generate Report →

Canonical URLs & 301 Redirect Architecture for Static Sites

Stop duplicate-URL dilution on Hugo sites: self-referencing canonicals, Cloudflare Pages _redirects rules, and one-hop 301 maps that preserve link equity.

A static site feels immune to duplicate content. There is one Markdown file, one rendered HTML page — how could Google possibly index the wrong URL? Easily, it turns out. The same page routinely answers at example.com/post/, example.com/post, www.example.com/post/, and example.com/post/index.html, and every backlink, social share, and crawler visit picks one of those variants at random. Your ranking signals get split four ways before you have written a single word.

Redirects are the other half of the problem. Most Hugo and Eleventy sites are migrations from WordPress, and the moment you flatten /2023/06/14/my-post/ into /blog/my-post/, every old URL becomes a 404 unless you build a deliberate 301 architecture. This guide covers both layers: canonical tags that consolidate the variants you cannot eliminate, and edge-level redirects that carry legacy equity to the new structure.

By the end you will have a self-referencing canonical partial for Hugo, a working Cloudflare Pages _redirects file, and a checklist for keeping every hop count at one.

Canonical vs 301 Redirect: The Short Answer

These two tools do a similar job — telling Google which URL should get the credit — but they are not interchangeable, and the difference comes down to a single question: should the old URL still work?

  • A 301 redirect removes the old URL. It happens at the HTTP layer, before any HTML is sent, and everyone who requests the old address — visitor, Googlebot, AI crawler — is sent to the new one instead. The old page stops existing.
  • A canonical tag keeps the old URL working. It is a line of HTML inside a page that still loads normally, and it says: index this other URL instead of me, and give it my ranking signals. Both addresses stay reachable; only one gets indexed.
301 redirectrel="canonical"
Old URL still loads?NoYes
Where it happensHTTP response headerInside the page’s <head>
Obeyed by Google?Enforced — it cannot serve a page it never receivesA hint; Google can and does override it
Works for non-HTML (PDFs, images)?YesOnly via an HTTP header, not a tag
Right when…The page genuinely movedTwo URLs must both work, but only one should rank

The rule of thumb: if a visitor should never see the old URL again, redirect it. If they legitimately might — a page reachable with campaign tracking parameters, a product listed under two categories, a printable variant — canonicalize it.

The consequence of that “hint versus enforced” row is the one people get bitten by. A 301 is a fact about your server; Google has no choice. A canonical is a request, and Google weighs it against your internal links, your sitemap, and your redirects before deciding. If those signals disagree with your canonical tag, Google follows the majority and your tag is ignored. That is why the rest of this guide treats canonicals, sitemaps, internal links, and redirects as one system rather than four settings.

A third option exists and is almost always wrong for this problem: noindex. It removes a duplicate from the index but passes no ranking signals to the survivor — the equity is simply destroyed rather than consolidated. Reach for it only when you want a page gone from search entirely, not when you want its value moved somewhere else.

Why One Page Can Have Five URLs

Static hosts are permissive by default. Cloudflare Pages, Netlify, and GitHub Pages will all happily serve content for several spellings of the same path. The usual offenders:

  • Trailing slash variants/blog/post vs /blog/post/. Hugo generates directory-style URLs (/blog/post/index.html), and most hosts 308 the non-slash version, but misconfigured proxies can serve both as 200s.
  • Host variantswww.example.com vs example.com. Without an explicit redirect rule, both resolve if both DNS records exist.
  • Protocol variants — HTTP still answers on hosts where “Always Use HTTPS” is not enabled.
  • File-suffix variants/blog/post/index.html returns the identical document with a different URL.
  • Query-string variants?utm_source=newsletter and ?fbclid=... parameters create infinite synthetic duplicates from campaign traffic.

Google’s indexer groups these into a duplicate cluster and elects one canonical itself if you do not. Sometimes it elects well. Sometimes it indexes the www variant while all your internal links point at the apex, and your Search Console reports fill with “Duplicate without user-selected canonical” warnings. The fix costs one line of template code.

Implementing Self-Referencing Canonicals in Hugo

Hugo exposes the fully qualified permalink of every page, so the canonical tag belongs in your head partial:

{{/* layouts/partials/head.html */}}
<link rel="canonical" href="{{ .Permalink }}" />

Two configuration details determine whether .Permalink emits the URL you actually want:

# hugo.toml
baseURL = "https://www.example.com/"   # the ONE canonical origin
  1. baseURL must be the exact canonical origin — protocol, host, and trailing slash included. If you canonicalize to the apex domain, do not put www here, and vice versa.
  2. Never rely on relative canonicals. <link rel="canonical" href="/blog/post/"> inherits whatever host the page was fetched from, which silently canonicalizes each duplicate to itself — the opposite of consolidation.

Remember, as the comparison above set out, that rel="canonical" is a strong hint rather than a directive. Google honors it in the large majority of clusters, but it will override a canonical that contradicts other signals (internal links, sitemap entries, redirects). Consistency across all four signal types is what makes consolidation stick — your sitemap should list only canonical URLs, and internal links should never point at a variant that immediately redirects. The discipline is the same one that resolves two of your own pages competing for a single query: canonicals, links, and redirects all voting for the same URL.

Building the _redirects File on Cloudflare Pages

Cloudflare Pages reads a plain-text _redirects file from the build output directory (put it in Hugo’s static/ folder so it ships with every build). Each line is source destination status:

# static/_redirects
# --- host + legacy platform cleanup ---
/feed/burst-cache          /index.xml            301
/wp-content/uploads/*      /images/:splat        301

# --- WordPress date archive flattening (dynamic rule) ---
/20*/                      /blog/                301
/2023/06/14/pricing-guide/ /blog/pricing-guide/  301

# --- renamed slugs, one hop each ---
/blog/old-slug/            /blog/new-slug/       301

Rules that matter in production:

  • Order is top-down; first match wins. Put specific one-to-one mappings above wildcard rules, or the splat will swallow them.
  • Limits: the file caps both the number of static rules and — far more tightly — the number of dynamic rules (lines containing * or :placeholder). Confirm the current figures in Cloudflare’s Pages documentation before planning a large migration. The design implication does not change: a 900-post WordPress migration should not be 900 lines. Collapse the date structure with one :splat pattern and enumerate only the slugs that actually changed.
  • Set the status explicitly. Do not leave the column blank and assume a 301; ambiguity between temporary and permanent handling is exactly what you are trying to eliminate.
  • Redirects execute at the edge, before cache lookup — a redirected URL never touches your HTML, which is why this beats any in-page solution for both users and crawlers.

_redirects has a sibling file, _headers, that ships the same way and controls caching and security headers per path; we cover it in edge caching and Cloudflare WAF rules for static sites. Keeping both in version control means your routing and caching policy is reviewed like code rather than clicked into a dashboard nobody remembers changing.

Why Hugo Aliases Are Not Real Redirects

Hugo’s aliases front-matter field looks like the native answer, but it works by generating a stub HTML page containing <meta http-equiv="refresh" content="0; url=...">. That stub returns HTTP 200. Google eventually treats meta refresh at 0 seconds as a soft redirect, but “eventually” is doing heavy lifting: the stub gets crawled, indexed, and evaluated as a page first. Answer-engine crawlers are stricter — a bot fetching pages for retrieval-augmented generation wants a clean 3xx header, not an HTML page it must parse to discover the redirect. Use aliases only on hosts where you have no server-level option.

Choosing the Right Status Code

CodePermanenceMethod preservedWhen to use
301PermanentNo (may become GET)Default for moved content pages
302TemporaryNoA/B tests, short-lived campaign swaps
307TemporaryYesTemporary API endpoint moves
308PermanentYesPermanent moves of form/POST endpoints

For a content site, the practical rule is: 301 for everything that moved forever, 302 only when you genuinely intend to bring the URL back. Search engines transfer signals on both permanent codes; what they punish is instability — flipping a URL between 302 and 200 repeatedly teaches crawlers to keep rechecking it.

Redirect Chains, Crawl Budget, and AI Fetchers

Every migration accumulates sediment: HTTP→HTTPS, then apex→www, then /2023/06/14/post//blog/post/, then a slug rename. Chain those and a legacy backlink now traverses four hops. Googlebot will follow a chain for a limited number of hops before giving up, and it discounts its crawl scheduling for chained URLs long before it reaches that ceiling. More urgently, AI crawlers operate on tight fetch timeouts, and each hop adds a full round trip — a bot on a short budget can abandon a chained URL entirely, which means the page behind it never enters the retrieval pool at all. Which of those crawlers reach you in the first place is set in robots.txt; we cover those directives in robots.txt rules for AI crawlers.

The audit loop is simple and worth running quarterly:

# Follow the chain and print every hop
curl -sIL -o /dev/null -w '%{url_effective} %{http_code} hops:%{num_redirects}\n' \
  https://example.com/2023/06/14/pricing-guide

Anything reporting hops:2 or more gets its first rule rewritten to point directly at the final destination. The old intermediate rules stay (external links may target them), but no rule should ever point at another redirect.

A One-Afternoon Canonical Hardening Checklist

  1. Enable “Always Use HTTPS” and pick one host (www or apex); 301 the other at the edge.
  2. Set baseURL to that exact origin and add the self-referencing canonical partial.
  3. Regenerate your sitemap and confirm it contains only canonical, 200-status URLs.
  4. Export legacy URLs (server logs or the old sitemap), map them one-hop in _redirects, and spot-check twenty with curl -IL.
  5. Recheck Search Console’s Page Indexing report in three weeks — duplicate-cluster warnings should decay steadily.

If you are mid-migration from WordPress, do this before DNS cutover, not after — our walkthrough on migrating heavy WordPress sites to Hugo sequences the redirect map as step one for exactly this reason. And if you want a second set of eyes, a MarketLens Standard Audit includes a full duplicate-variant and redirect-chain crawl with the literal offending URLs listed line by line.

Run this article on your site

Audit my Hugo static site for duplicate URL variants (trailing slash vs non-slash, www vs apex, http vs https, /index.html suffixes). Add a self-referencing <link rel="canonical" href="{{ .Permalink }}"> to the head partial, verify baseURL is the canonical origin, then generate a Cloudflare Pages _redirects file that 301s every legacy WordPress-style date URL to its new slug in exactly one hop, using dynamic :splat rules to stay within Cloudflare's published static rule limit.

Paste into Claude Code, ChatGPT, Cursor or Gemini. It executes the steps above against your own site.

Frequently Asked Questions

Do static sites really need canonical tags if every page only has one file?

Yes. One file can still resolve at four or more URLs — with and without a trailing slash, on www and the apex domain, over HTTP and HTTPS, and at /index.html. Each variant Google discovers is a separate URL competing with the others, so a self-referencing canonical tells crawlers which one to consolidate signals onto.

Should I use Hugo aliases or a Cloudflare _redirects file for 301 redirects?

Use the _redirects file whenever you can. Hugo aliases generate small HTML pages with a meta refresh, which returns a 200 status — not a genuine 301 — so search engines treat it as a soft redirect. Cloudflare Pages _redirects rules respond at the edge with a true 301 header before any HTML is served.

What is the difference between a 301 and a 308 redirect?

Both are permanent redirects that pass link equity. A 308 additionally guarantees the HTTP method is preserved (a POST stays a POST), while a 301 permits clients to switch to GET. For static content pages fetched with GET, a 301 is the conventional and universally supported choice.

How many redirect rules does Cloudflare Pages support?

A single _redirects file supports a capped number of static rules plus a much smaller number of dynamic (wildcard or placeholder) rules; check Cloudflare's current Pages documentation for the exact figures, as they have been revised. Either way, large migrations should collapse patterns into dynamic splat rules — redirecting an entire /yyyy/mm/dd/ date structure with one line instead of hundreds.

Do 301 redirects lose PageRank?

Google has stated for years that 301s pass full ranking signals. What does cost you is redirect chains: every extra hop adds latency, wastes crawl budget, and increases the chance an AI crawler with a tight fetch timeout abandons the request. Keep every legacy URL to exactly one hop.

Continue the track — Static Architecture & Performance