We moved a group of small content sites off WordPress onto static hosting (Cloudflare Pages). The migration itself went fine: pages rendered, links resolved, Lighthouse got better, hosting cost went to roughly zero.
Then the traffic curve did not recover the way it should have. The pages were live, indexed, fast — and flat.
The cause was a single line in the template.
The bug
The WordPress theme had emitted an absolute canonical URL. The static templates emitted a relative one:
<!-- what we shipped -->
<link rel="canonical" href="/columns/refund-process/">
<!-- what it should have been -->
<link rel="canonical" href="https://example.jp/columns/refund-process/">
A relative canonical is legal HTML — it resolves against the document base. So every local check passed. It only becomes a problem when the same content is reachable from more than one base, which after a migration it always is:
-
https://and (briefly)http:// - apex and
www. - the production domain and the
*.pages.devpreview domain
On the preview domain, the relative canonical resolves to the preview domain. You end up self-canonicalising a duplicate host instead of consolidating onto the real one. Multiply by 487 pages across the group.
How we found it
Not in the browser. In curl, from outside, per host:
for host in https://example.jp https://www.example.jp https://example.pages.dev; do
echo "== $host"
curl -sL "$host/columns/refund-process/" \
| grep -o '<link rel="canonical"[^>]*>'
done
Three hosts, three different canonicals. That is the whole diagnosis. It takes ten seconds and I now run it as a post-deploy gate.
The second half: URL drift
The same migration silently split some URLs by year segment — a handful of articles existed under both /2025/... and /2026/... paths because the old permalink structure had a date component and the new generator derived the date from front matter that had been touched during the export.
Two URLs, same article, both 200. Neither is "wrong" to a crawler; they just dilute each other. The fix was a 301 from the older path to the current one, plus a build-time check that no two output files share a normalised title slug.
The checks that now run on every deploy
- Canonical is absolute and matches the production origin. Fail the build otherwise — this is a one-line regex, not a crawler.
-
curlthe live production URL after deploy and assert the canonical, not just an HTTP 200. A 200 tells you the file exists; it tells you nothing about what is inside it. - No duplicate slugs in the output directory.
-
Preview deployments are
noindex. If a preview host is crawlable at all, everything above matters twice as much.
// build-time gate
const ORIGIN = 'https://example.jp';
for (const page of pages) {
const m = page.html.match(/<link rel="canonical" href="([^"]+)"/);
if (!m) throw new Error(`no canonical: ${page.path}`);
if (!m[1].startsWith(ORIGIN)) throw new Error(`relative or wrong-origin canonical: ${page.path} -> ${m[1]}`);
}
The lesson I keep relearning
After a migration, verify the shipped artefact from outside, over the network, on every host that serves it. Everything I checked locally was green. The bug only existed in the combination of a legal relative URL and a second reachable host — which is exactly the state a migration creates and then leaves lying around.
One of the sites in that group is 詐欺返金相談, a Japanese consumer-protection information site; it was the one where the flat curve was obvious enough to make me go looking. The fix was one template line and a set of 301s. Finding it took far longer than fixing it, because nothing was broken — it was only ambiguous.
Top comments (0)