Preventing Geo-Redirect Loops During Migration
Problem Statement
Geographic redirection and migration redirects are each correct in isolation and compose badly. A rule that sends visitors detected as German to /de/ and a rule that maps a legacy path to a new one can, between them, produce a cycle that appears in neither configuration — and because the cycle depends on the visitor’s inferred location, it reproduces for some people and not for the team testing it from head office. Worse, unconditional geographic redirection breaks the annotation graph outright: an alternate must return 200 to count, so a locale URL that redirects visitors elsewhere is no longer a valid alternate no matter how correct the annotation is. This page sits under International & hreflang Migrations.
When to Use This Approach
- The site redirects visitors based on inferred country, language header, or a stored preference.
- A migration is changing the URL structure that those geographic rules match against.
- Alternates are annotated with
hreflangand must remain directly reachable. - Support has reported “the site keeps reloading” from a specific market.
- You need a test that reproduces from several regions rather than from one office.
Step-by-Step Instructions
1. Prefer a Suggestion Over a Redirect
The most reliable fix is architectural: offer the local version rather than forcing it. A banner or interstitial that says “we have a German site — switch?” preserves the visitor’s ability to stay where they are, keeps every locale URL directly reachable, and cannot loop. Unconditional geographic redirection is the mechanism that creates every problem on this page, and most estates do not actually need it.
Where a redirect is genuinely required — a legal or licensing constraint rather than a preference — restrict it as tightly as possible: fire on the site root only, never on deep links, and never on a URL that appears in an annotation set.
2. Exempt Crawlers and Annotated URLs
An alternate that redirects is not an alternate. If your geographic rule fires for a request to /de/preise/, that URL cannot serve 200 to a crawler and the whole set is invalidated — which converts a working annotation graph into an inert one without changing a single annotation. Exempt the annotated URLs from geographic redirection explicitly, rather than relying on user-agent detection that will not cover every crawler.
# Never geo-redirect a URL that is a member of an annotation set
map $request_uri $skip_geo {
~^/(en|de|fr)/ 1; # locale roots and everything beneath them
default 0;
}
Persist an explicit choice and honour it above everything else. A visitor who has selected a locale has told you what they want, and any rule that overrides that on a later visit is a rule that will generate support tickets from travellers, expatriates, and anybody using a corporate network that egresses in another country. Store the selection, check it first, and treat inference as the fallback for visitors who have not chosen.
3. Make the Rule Idempotent
A geographic rule must not match its own destination. If the rule that sends a German visitor to /de/ also matches requests for /de/, it will redirect that request to /de/ again, and again, until the client gives up. Anchor the condition so the destination is excluded, and test the rule by feeding it its own output — a two-line check that catches the entire class of loop.
# Only redirect when NOT already inside a locale folder
if ($skip_geo = 0) {
set $geo_target "/$preferred_locale/";
return 302 $geo_target; # 302: the choice is per-visitor, not permanent
}
4. Test From Real Regions, Not From One Office
A loop that depends on inferred location cannot be reproduced by a team in a single country. Drive the check from several regions, following redirects and counting hops, so the composition of geographic and migration rules is exercised the way visitors will exercise it. Where a synthetic monitoring service is available, add a per-region check on the site root and on one deep link.
# Simulate a locale preference and count hops; any high count is a loop
for lang in de fr en-GB; do
printf '%s: ' "$lang"
curl -sIL -H "Accept-Language: $lang" -o /dev/null \
-w '%{num_redirects} hops -> %{url_effective}\n' https://example.com/
done
Test the rule against its own destination as a standing check rather than a one-off. Idempotency is a property that a later edit can silently remove — widening a pattern to catch one extra path frequently widens it to catch the destination too — so the check belongs alongside the redirect validation in CI rather than in somebody’s memory of having verified it once.
5. Re-verify After Every Routing Change
Geographic rules match on paths, so a migration that changes the path structure changes what those rules match. A rule that no longer matches simply stops firing, which is a quiet change nobody chose; a rule that now matches more than intended starts redirecting pages it never touched. Re-run the regional test after the redirect map ships, not only before.
Worked Example
A retailer with /en/, /de/ and /fr/ migrates its product paths from /products/ to /shop/. The geographic rule predates the migration and matches ^/products/, redirecting German visitors to /de/products/…. After the migration that rule no longer matches anything — so German visitors land on the English /shop/ pages and stay there.
The team fixes it by updating the rule to match ^/shop/, and introduces a loop: the new pattern also matches /de/shop/, so a German visitor arriving there is redirected to /de/shop/ again.
$ curl -sIL -H 'Accept-Language: de' -o /dev/null \
-w '%{num_redirects} hops -> %{url_effective}\n' https://example.com/shop/widget
20 hops -> https://example.com/de/shop/widget
Twenty hops is the client giving up. The fix is the exemption in step 2 — the rule must not match a URL already inside a locale folder — plus the idempotency test in step 3, which would have caught it before deploy by feeding the rule its own output.
Add a per-region synthetic check for the duration of the migration at minimum, and preferably permanently. Geographic behaviour is the one part of the routing layer that cannot be verified from a single location, so it is also the part most likely to regress unnoticed — a rule change made months later by somebody with no knowledge of the migration will be tested exactly as thoroughly as their own vantage point allows.
Verification
Confirm both that the intended redirect happens and that it terminates.
# 1. Root request from each region reaches the right locale in one hop
for lang in de fr en; do
printf '%s: ' "$lang"
curl -sIL -H "Accept-Language: $lang" -o /dev/null \
-w '%{num_redirects} %{url_effective}\n' https://example.com/
done
# 2. Every annotated alternate returns 200 with no geographic interference
for u in /en/pricing/ /de/preise/ /fr/tarifs/; do
printf '%s -> %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$u")"
done
# 3. Feeding the rule its own destination produces no further redirect
curl -sI -H 'Accept-Language: de' https://example.com/de/ | head -1
A clean result is at most one hop from the root, 200 on every annotated alternate regardless of the language header, and a plain 200 when the rule’s own destination is requested.
FAQ
Should geographic redirects be 301 or 302?
302, always. The decision is per-visitor and reversible — the same URL legitimately serves different destinations to different people — and a 301 tells browsers and caches that the mapping is permanent for everyone. A permanent geographic redirect cached by a shared proxy or a CDN will then serve one market’s destination to another market entirely, which is a considerably worse failure than the one you were solving.
Why do alternates have to return 200 rather than redirect?
Because a redirecting URL is not the page it claims to be, and an annotation naming it is describing something that does not exist at that address. The requirement is strict and unforgiving: a single locale URL that geo-redirects can invalidate its whole set. This is the main reason unconditional geographic redirection and hreflang sit so badly together.
Can I detect crawlers and exempt them by user agent? You can, and it is a fragile substitute for exempting the URLs themselves. User-agent lists go stale, some crawlers do not identify themselves, and a rule whose behaviour differs for crawlers is difficult to test and easy to get wrong in a way that looks like cloaking. Exempt by path — locale URLs are never geo-redirected, for anybody — and the problem does not arise.
What should the redirect key on: IP geolocation or Accept-Language?
Accept-Language reflects what the visitor has asked for and IP geolocation reflects where they happen to be, which are different questions and frequently give different answers — a German speaker travelling in Spain wants German. Preferring the header, falling back to geolocation, and remembering an explicit choice in a cookie is the ordering that annoys the fewest people. Whichever you choose, make the visitor’s own selection override everything and persist it.
How do I test this without access to multiple regions?
Set the Accept-Language header explicitly, as in the commands above, which exercises the header-based path from anywhere. For geolocation-based rules you need traffic that genuinely originates elsewhere — a synthetic monitoring service with regional probes is the practical answer, and a couple of regional checks on the root and one deep link is enough to catch the loops that matter.
Related
← Back to International & hreflang Migrations