- 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.devURL. - Analytics splits traffic across hosts.
- Shared links carry whichever host the person landed on.
- A
wwwCNAME 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:
| Generator | Setting |
|---|---|
| Astro | site in astro.config.mjs |
| Hugo | baseURL 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:
| Layer | Where in Cloudflare | Job |
|---|---|---|
| DNS record, proxied | Domain → DNS | Sends the hostname through Cloudflare |
| Pages custom domain | Workers & Pages → project → Custom domains | Makes Pages answer for that hostname |
| Redirect | Domain → Rules → Redirect Rules; Account → Bulk Redirects | Sends 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 = apex | Canonical = 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:
| Field | Value |
|---|---|
| Source URL | project.pages.dev |
| Target URL | the canonical, e.g. https://example.com |
| Status | 301 |
| Preserve query string | on |
| Subpath matching | on |
| Preserve path suffix | on |
| Include subdomains | off |
“Include subdomains” off is deliberate: preview deployments live at
<hash>.project.pages.dev, and I still want to open those.
5. Verify with a deep link
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
| Symptom | Cause | Fix |
|---|---|---|
www returns 522 | CNAME exists, hostname not attached to the Pages project | Add it under Custom domains |
| Can’t find redirect settings | Looking inside the Pages project | Redirect Rules are on the zone; Bulk Redirects on the account |
| Rule shows Active, still 200 | A 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 hostname | Fix the field; don’t accept the offer to create a DNS record (it would be named https) |
| New bulk entry still 200 | Propagation | Wait ~30 seconds and test again |
| Apex resolves to a private LAN address | An old DNS record | Point 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.devneeds 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.