Three "Duplicate — Google chose a different canonical than the user" notices arrived in Search Console. All three sites split by locale path (/ko, /en, /ja).
Three identical notices, so I assumed one cause. The three had different causes, and two weren't defects at all.
Are you collapsing three identically-worded alerts into one cause?
Two were success signals
Two of the sites had already completed a migration from subdomains to path-based locales.
The canonical Google picked was the destination we intended. The notice only arrived now because the last crawl was three months ago — a snapshot from before the redirect existed.
On a property mid-migration, this notice should be read as a success signal. Time spent here fixes nothing.
The third was real
The root redirect was a 307 (temporary).
googleCanonical = https://<site>/ ← Google's pick (indexed)
userCanonical = https://<site>/ko ← our declaration → excluded as duplicateA 307 is not a consolidation signal — see also the accept-language routing bug at the same redirect point, and a root canonical that duplicated every child, so Google keeps the original URL — the root — as canonical. Eight of ten sampled URLs showed the same pattern. Switch to 308 (permanent) and it consolidates onto the destination.
But the bigger loss was elsewhere
In 9 of 10 samples, /en was "URL is unknown to Google."
The reason is simple. The server-rendered HTML of /ko contained no <a> tag pointing at another locale. Language switching was client-side. For a crawler there is no path.
Two things had been reassuring me, and neither is a path.
<link rel="alternate" hreflang>alone does not get you indexed.- Neither does being in the sitemap. One site had three URLs in its sitemap and two of them were undiscovered.
The fix was a server-rendered language nav in the footer.
<nav aria-label="language"><a href="/en">English</a> · <a href="/ja">日本語</a></nav>What would you do?
You need to turn a 307 into a 308. Where do you edit?
- The page component's
redirect()— the first place you notice in the code. - The framework config's
redirects()— handled before the request reaches a page. - Both — without checking which one actually runs.
In 27 of 30 apps the config's redirects() handles the root, and the page component's redirect() is a fallback that never executes. Fix only the page side and ship, and live is still 307.
That gives a diagnostic rule: if a footer change from the same deploy is live but the redirect didn't change, it isn't a cache problem — the code you fixed is unreachable.
One site must not become a 308
I stopped short of applying it everywhere. One site branches at the root on the visitor's language preference. Make that response permanent and whichever language the first visitor received gets frozen into caches.
Finding one exception in a bulk edit usually doesn't mean there's exactly one. I re-checked the root behaviour of the other 29.
Three checks
- Is locale switching client-side? Then confirm a real
<a href>exists in the server HTML. Without one, that language stays undiscovered. - Is your root redirect a 307 or a 308? One
curl -sI <root>tells you. If you want consolidation, it's 308. - Fixed a redirect and live didn't change? Before suspecting caches, confirm that code is even on the executing path.
The honest part
The thing I spent longest on was the two that weren't defects — because the wording was identical, so I assumed the cause was too.
And the real loss, two languages undiscovered, never appeared in the notices at all. An unindexed URL isn't reported as a duplicate either. To find a loss that isn't reported, I had to inspect URLs one at a time rather than wait for an alert.