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 redirect | rel="canonical" | |
|---|---|---|
| Old URL still loads? | No | Yes |
| Where it happens | HTTP response header | Inside the page’s <head> |
| Obeyed by Google? | Enforced — it cannot serve a page it never receives | A hint; Google can and does override it |
| Works for non-HTML (PDFs, images)? | Yes | Only via an HTTP header, not a tag |
| Right when… | The page genuinely moved | Two 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/postvs/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 variants —
www.example.comvsexample.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.htmlreturns the identical document with a different URL. - Query-string variants —
?utm_source=newsletterand?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 originbaseURLmust be the exact canonical origin — protocol, host, and trailing slash included. If you canonicalize to the apex domain, do not putwwwhere, and vice versa.- 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/ 301Rules 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:splatpattern 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
| Code | Permanence | Method preserved | When to use |
|---|---|---|---|
| 301 | Permanent | No (may become GET) | Default for moved content pages |
| 302 | Temporary | No | A/B tests, short-lived campaign swaps |
| 307 | Temporary | Yes | Temporary API endpoint moves |
| 308 | Permanent | Yes | Permanent 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-guideAnything 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
- Enable “Always Use HTTPS” and pick one host (www or apex); 301 the other at the edge.
- Set
baseURLto that exact origin and add the self-referencing canonical partial. - Regenerate your sitemap and confirm it contains only canonical, 200-status URLs.
- Export legacy URLs (server logs or the old sitemap), map them one-hop in
_redirects, and spot-check twenty withcurl -IL. - 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.
MarketLens