Canonical Wrong After Domain Change

Switched domains but old canonical still appears — config + cache.

You moved from oldsite.com to newsite.com, pointed DNS over, and the new domain loads — but view-source on a production page still shows <link rel="canonical" href="https://oldsite.com/...">. Search Console will keep indexing the old domain as primary and search traffic will never fully move. The bug is almost never a single root cause; it’s a stack of hard-coded URLs + CDN cache + Google not having re-crawled yet. This article peels them off layer by layer.

Common causes

Ordered by hit rate.

1. Site root URL hard-coded in source

astro.config.mjs / next.config.js / templates have a literal https://oldsite.com that gets baked into every page’s canonical, og:url, and sitemap.xml at build time. Even after the host domain changes, the HTML still ships the old one.

Typical:

// astro.config.mjs
export default defineConfig({
  site: 'https://oldsite.com', // ← never updated
});

How to spot it: grep -r "oldsite.com" src/ astro.config.mjs next.config.js public/ and count the hits.

2. CDN / host is serving cached HTML

Code is fixed and redeployed, but Cloudflare / Vercel Edge / Fastly is still serving the previous HTML. curl -I shows cf-cache-status: HIT or x-vercel-cache: HIT — you’re seeing the cache.

How to spot it: curl -s https://newsite.com/some-page | grep canonical still shows oldsite, and curl -I shows a cache HIT.

3. Sitemap and robots.txt aren’t updated

sitemap.xml still lists https://oldsite.com/... URLs. Once submitted, Google keeps crawling the old URLs. robots.txt referencing an absolute sitemap URL on the old domain has the same problem.

How to spot it: curl https://newsite.com/sitemap.xml | head -50 and check the <loc> entries.

4. 301 redirect is only half-configured

oldsite.com → newsite.com 301s are in place, but pages on the new domain still canonical back to oldsite.com — Google sees a 301 → canonical → 301 loop and refuses to index either.

How to spot it: curl -I https://oldsite.com/page shows the 301 target, then curl https://newsite.com/page | grep canonical shows the canonical. If they point at each other, you have a loop.

5. Search Console primary property not changed

The GSC property is still https://oldsite.com, with no https://newsite.com property added and no “Change of address” submitted. Google can crawl the new domain but doesn’t know it’s the successor to the old one — migration signals don’t transfer.

How to spot it: Log into Search Console, check both properties exist, and verify Settings → Change of address has been submitted.

Shortest path to fix

Step 1: Grep the old domain and switch to a single env var

grep -rIn "oldsite\.com" . \
  --exclude-dir=node_modules \
  --exclude-dir=.git \
  --exclude-dir=dist

Replace every hard-coded occurrence with an env var:

// astro.config.mjs
export default defineConfig({
  site: process.env.SITE_URL || 'https://newsite.com',
});
// src/lib/seo.ts
export const SITE_URL = import.meta.env.SITE_URL || 'https://newsite.com';

// In templates
<link rel="canonical" href={`${SITE_URL}${Astro.url.pathname}`} />

Set SITE_URL=https://newsite.com on the host (Vercel / Netlify / Cloudflare) for both preview and production.

Step 2: Rebuild, redeploy, verify the artifact

# Verify locally first
npm run build
grep -rIn "oldsite.com" dist/ || echo "clean"

# Verify in production after deploy
curl -s https://newsite.com/ | grep -E '<link rel="canonical"|og:url'

Every match should be https://newsite.com/....

Step 3: Purge CDN / host cache

  • Cloudflare: dashboard → Caching → Configuration → Purge Everything
  • Vercel: Deployments → latest → Redeploy (do not reuse build cache); Edge cache invalidates on new deploy
  • Cloudflare Pages: Settings → Builds & deployments → Purge cache
  • Netlify: Deploys → Clear cache and deploy site

Then curl -I should show cf-cache-status: MISS or EXPIRED, and curl should return the new canonical.

Step 4: Update sitemap, robots, and 301 redirects

public/robots.txt:

User-agent: *
Allow: /
Sitemap: https://newsite.com/sitemap.xml

Configure a full-path 301 from old to new on the old domain (pick one — Cloudflare Page Rule / Vercel vercel.json / Nginx):

// vercel.json (on the oldsite project)
{
  "redirects": [
    { "source": "/(.*)", "destination": "https://newsite.com/$1", "permanent": true }
  ]
}

Verify:

curl -I https://oldsite.com/some-page
# HTTP/2 301
# location: https://newsite.com/some-page

Step 5: Submit “Change of address” in Search Console

  1. Add https://newsite.com as a property in GSC and verify.
  2. In the old property https://oldsite.com, go to Settings → Change of address and walk the wizard, selecting the new property.
  3. For the top 20 URLs, run URL Inspection → “Request indexing” to force a re-crawl.
  4. Submit the new sitemap: https://newsite.com/sitemap.xml.

Google takes 4-8 weeks to fully transfer migration signals. The old domain’s 301s must stay live the whole time.

Prevention

  • Read the site root URL from one env var only; ban any literal domain string from source
  • Add a CI rule: grep -r "oldsite.com" src/ returning a match fails the build
  • After every big change (domain, route shape, canonical template), run a canonical smoke test on 10 representative URLs
  • Generate sitemap.xml via @astrojs/sitemap (or equivalent) so it picks up site automatically — never hand-write
  • Keep the old domain’s 301s live for at least 6 months to give Google time to consolidate signals

Tags: #Hosting #Debug #Troubleshooting #SEO