• Aug 14, 2026
  • 6 min read

One Canonical Address Per Site: Apex vs www and pages.dev Redirects on Cloudflare

Looking at analytics for one of my sites, I noticed visits attributed to fossengineerpapermod.pages.dev. The same pages were also live on the apex domain and on www. Another site’s www returned a 522 error. A third had an apex record pointing at a private LAN address.

Each site was “working”, but each page had several public addresses. Here is how I fixed that across three properties, and the runbook I now follow.

Why one address matters

A Cloudflare Pages site can be reachable on three hostnames at once:

example.com            ← custom domain
www.example.com        ← custom domain (or a CNAME you added)
project.pages.dev      ← always exists, created by Pages

When all three serve content:

  • Search splits ranking signals across duplicates and may show the pages.dev URL.
  • Analytics splits traffic across hosts.
  • Shared links carry whichever host the person landed on.
  • A www CNAME that Pages doesn’t know about returns 522 — a broken first impression.

The goal: one canonical host answers 200; every other host answers 301 to it, keeping the path and query string.

Rule 1: keep what is already canonical

Apex versus www is mostly taste for SEO. Apex is shorter; www keeps the main site’s cookies away from the brand’s other subdomains. The decisive question is simpler: what is already indexed? The site generator decides the canonical tags and the sitemap:

GeneratorSetting
Astrosite in astro.config.mjs
HugobaseURL in the config

I ended up with two apex sites and one www site, because that is what each already used. Changing an indexed canonical just for consistency is churn.

Rule 2: three layers, each with one job

This was the mental model that made everything click:

LayerWhere in CloudflareJob
DNS record, proxiedDomain → DNSSends the hostname through Cloudflare
Pages custom domainWorkers & Pages → project → Custom domainsMakes Pages answer for that hostname
RedirectDomain → Rules → Redirect Rules; Account → Bulk RedirectsSends non-canonical hosts to the canonical one at the edge

The 522 was the second layer missing: www had a CNAME, but the hostname was not attached to the Pages project, so nothing answered.

And redirects live in two different places for a reason: zone Redirect Rules only see hostnames in your zone. pages.dev is not your zone, so its redirect needs an account-level Bulk Redirect.

The runbook

1. Canonical in the code

Set Astro site or Hugo baseURL, rebuild, and confirm that <link rel="canonical"> and the sitemap use it.

2. Attach both hostnames to the Pages project

Add both example.com and www.example.com as custom domains — including the non-canonical one. The redirect still needs that hostname to resolve and to have a certificate. DNS records must be proxied.

3. Zone redirect: apex ↔ www

Domain → Rules → Redirect Rules (or the built-in “Redirect from WWW to root” template):

Canonical = apexCanonical = www
Request URL https://www.example.com/*Request URL https://example.com/*
Target https://example.com/${1}Target https://www.example.com/${1}

Both: status 301, preserve query string on.

4. Bulk redirect: project.pages.dev

Account home → Bulk Redirects. I keep one shared list for all sites, with one entry per project:

FieldValue
Source URLproject.pages.dev
Target URLthe canonical, e.g. https://example.com
Status301
Preserve query stringon
Subpath matchingon
Preserve path suffixon
Include subdomainsoff

“Include subdomains” off is deliberate: preview deployments live at <hash>.project.pages.dev, and I still want to open those.

Always test a deep URL with a query string, never just /. A rule that redirects the homepage but drops the path or the UTM tags is half broken.

A check script instead of clicking around

I keep one line per expected behaviour and let curl verify all of them:

CHECKS=(
  "https://fossengineer.com/posts/?utm_source=check|200|-"
  "https://www.fossengineer.com/posts/?utm_source=check|301|https://fossengineer.com/posts/?utm_source=check"
  "https://fossengineerpapermod.pages.dev/posts/?utm_source=check|301|https://fossengineer.com/posts/?utm_source=check"
)

for line in "${CHECKS[@]}"; do
  IFS='|' read -r url want_code want_loc <<<"$line"
  headers=$(curl -sI -m 15 "$url" | tr -d '\r')
  code=$(printf '%s\n' "$headers" | awk '/^HTTP\// && $2 != "103" {print $2; exit}')
  loc=$(printf '%s\n' "$headers" | awk 'tolower($1)=="location:" {print $2; exit}')
  ...
done

Two details in there are earned: Cloudflare can send a 103 Early Hints response first, so the script skips it to find the real status; and it compares the full Location, so a lost query string fails the check. The script exits non-zero on any failure. Ten checks across all my properties run in a few seconds.

Gotchas I hit

SymptomCauseFix
www returns 522CNAME exists, hostname not attached to the Pages projectAdd it under Custom domains
Can’t find redirect settingsLooking inside the Pages projectRedirect Rules are on the zone; Bulk Redirects on the account
Rule shows Active, still 200A leading space pasted into the URL field; the rules list showed r" https://…"Retype both fields until the list shows r"https://…"
Dashboard warns it “may not be proxying traffic for https”It parsed that malformed URL field as a hostnameFix the field; don’t accept the offer to create a DNS record (it would be named https)
New bulk entry still 200PropagationWait ~30 seconds and test again
Apex resolves to a private LAN addressAn old DNS recordPoint the apex at the site; never publish private IPs in public DNS

The leading space cost me the most time. The rule looked correct in the editor, was enabled, and silently matched nothing.

One deliberate exception

My PWA on an app. subdomain still answers on its pages.dev address, on purpose: it is a single-page app that isn’t meant to rank, nothing links to the pages.dev origin, and service workers are per-origin — a redirect would only orphan the offline cache of anyone who used that origin. A rule is a decision, not a reflex.

Takeaways

  • Every Pages site has at least two public hosts; decide which one is real.
  • Keep the indexed canonical; redirect everything else with 301, path and query string preserved.
  • Three layers: proxied DNS, Pages custom domain, redirect. A 522 means the middle one is missing.
  • pages.dev needs Bulk Redirects at account level, with subdomains off to keep previews.
  • Script the verification with deep links; a dashboard saying “Active” proves nothing.

More Cloudflare surprises from the same week are collected in debugging notes from a week on Cloudflare, and scheduled publishing is covered in future-dated posts with a cron Worker.

Next: debugging notes from a week on Cloudflare.