Country-Based Redirects in Edge Middleware
This guide is part of Geolocation and Personalization at the Edge. It covers one task: sending a visitor from a shared entry URL to the storefront or locale that matches their country, using only the geo data the point of presence already resolved.
The problem
The rule sounds trivial — “German visitors go to /de/” — and the first implementation is four lines. Then the reports come in. A user who deliberately clicked “English” is bounced back to German on every navigation. Googlebot, crawling from a US address, indexes only the US storefront and your other markets never rank. Someone behind a corporate VPN in Frankfurt cannot reach the UK site at all. And a subtle one: the redirect itself got cached by a shared tier, so every visitor in the world now receives a 302 to /de/.
Each of these is a distinct failure, and all four come from treating a redirect as a stateless function of the country code.
Root cause: a redirect is a cacheable, crawlable, sticky decision
Three properties of HTTP redirects make them harder than they look at the edge.
Redirects are cacheable responses. A 301 is cached aggressively and semi-permanently by browsers and shared caches alike. If a 301 keyed on country lands in a shared cache without a correct key or Vary, one country’s redirect is served to everyone. This is the single most damaging mistake in this area, and it is why country redirects should be 302 (or 307) and explicitly marked uncacheable at shared tiers.
Crawlers have one location. A search engine crawls from a fixed set of addresses. If you force-redirect based on country, the crawler only ever sees the storefront for its own country, and your other markets are invisible. The standard remedy is not to exempt crawlers by user agent — that is cloaking — but to make every variant independently reachable at a stable URL and declare the relationships with hreflang.
A choice must stick. Once a visitor overrides the guess, the guess must never fire again. That requires a cookie, and it requires checking that cookie before the geo lookup.
Step 1 — Define the storefront map and the guard set
const STOREFRONTS = ['us', 'de', 'fr', 'uk', 'jp'] as const;
type Storefront = (typeof STOREFRONTS)[number];
const DEFAULT_STOREFRONT: Storefront = 'us';
const BY_COUNTRY: Record<string, Storefront> = {
DE: 'de', AT: 'de', CH: 'de',
FR: 'fr', BE: 'fr', LU: 'fr',
GB: 'uk', IE: 'uk',
JP: 'jp',
US: 'us', CA: 'us',
};
const PREFIX_RE = new RegExp(`^/(${STOREFRONTS.join('|')})(/|$)`);
// Paths that must never be redirected: assets, APIs, and well-known files.
const EXEMPT_RE = /^\/(_next|assets|api|favicon\.ico|robots\.txt|sitemap.*\.xml|\.well-known)/;
Deriving PREFIX_RE from the storefront list means adding a market is one edit. Hand-written duplicate lists are how /uk/ ends up being redirected to /us/ forever.
Step 2 — Write the guard chain, cheapest check first
const COOKIE = 'storefront';
export default function middleware(request: Request): Response | undefined {
const url = new URL(request.url);
// 1. Never touch assets or APIs.
if (EXEMPT_RE.test(url.pathname)) return undefined;
// 2. Already on a storefront path — the visitor is where they asked to be.
if (PREFIX_RE.test(url.pathname)) return undefined;
// 3. An explicit stored choice always wins over the guess.
const stored = readCookie(request, COOKIE);
const chosen = isStorefront(stored) ? stored : null;
// 4. Fall back to the country only when nothing above decided.
const target = chosen ?? BY_COUNTRY[readGeo(request).country] ?? DEFAULT_STOREFRONT;
const destination = new URL(`/${target}${url.pathname}${url.search}`, url.origin);
return new Response(null, {
status: 302, // temporary: the guess may change
headers: {
location: destination.toString(),
// A country-dependent redirect must never be stored by a shared cache.
'cache-control': 'no-store',
vary: 'cookie',
},
});
}
function isStorefront(v: string | null): v is Storefront {
return v !== null && (STOREFRONTS as readonly string[]).includes(v);
}
The ordering is the whole design. Checking PREFIX_RE before anything else makes a loop structurally impossible: a redirect always produces a prefixed path, and a prefixed path always exits at step 2.
302 rather than 301 is deliberate. A 301 is remembered by the browser more or less permanently, so a visitor who later travels, or later changes their mind, is stuck with a redirect you can no longer withdraw.
Step 3 — Persist the visitor’s explicit choice
When someone uses your storefront switcher, record it. That single cookie is what stops the guess from fighting the user.
export function switchStorefront(to: Storefront, url: URL): Response {
const destination = new URL(`/${to}${stripPrefix(url.pathname)}${url.search}`, url.origin);
return new Response(null, {
status: 302,
headers: {
location: destination.toString(),
'set-cookie': `storefront=${to}; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax; Secure`,
'cache-control': 'no-store',
},
});
}
function stripPrefix(pathname: string): string {
return pathname.replace(PREFIX_RE, '/').replace(/^\/\//, '/');
}
This cookie is a preference, not a credential, so it does not need HttpOnly — client-side code may legitimately want to read it to highlight the current market in a switcher.
Step 4 — Keep every market crawlable
Do not special-case crawlers by user agent; that is cloaking and carries real ranking risk. Instead, make the variants independently addressable and declare them.
<link rel="alternate" hreflang="de-DE" href="https://example.com/de/pricing" />
<link rel="alternate" hreflang="fr-FR" href="https://example.com/fr/pricing" />
<link rel="alternate" hreflang="en-GB" href="https://example.com/uk/pricing" />
<link rel="alternate" hreflang="en-US" href="https://example.com/us/pricing" />
<link rel="alternate" hreflang="x-default" href="https://example.com/us/pricing" />
Because the guard chain exits early on any already-prefixed path, a crawler that follows these links reaches every market directly and never sees a redirect. That is the crawlability fix — not an exemption, just a rule that happens to be correct for everyone.
Configuration snippet
export const config = {
runtime: 'edge',
// Only un-prefixed, non-asset paths can possibly need a redirect.
matcher: ['/((?!_next|assets|api|favicon.ico|robots.txt|sitemap.xml).*)'],
};
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
| Country field | Absent — always falls through to the default | Populated by the PoP |
Cookie Secure flag |
May be rejected over plain HTTP | Always honoured over HTTPS |
| Redirect caching | No shared cache in front | A shared tier will store anything cacheable |
| Crawler traffic | None | Constant, from a fixed set of regions |
| Loop symptoms | Instant and obvious | Instant and obvious, but only for some countries |
x-forwarded-for |
Your LAN address | The real client address, PoP-resolved |
The first row is the trap: locally you only ever exercise the default branch, so a bug in BY_COUNTRY is invisible until deploy. Use a geo override during development and step through each market.
Validating with Vitest
import { describe, expect, it } from 'vitest';
function req(path: string, opts: { country?: string; cookie?: string } = {}) {
const headers = new Headers();
if (opts.country) headers.set('x-vercel-ip-country', opts.country);
if (opts.cookie) headers.set('cookie', opts.cookie);
return new Request(`https://example.com${path}`, { headers });
}
describe('country redirect', () => {
it('redirects an un-prefixed path for a mapped country', () => {
const res = middleware(req('/pricing', { country: 'DE' }))!;
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe('https://example.com/de/pricing');
});
it('never redirects an already-prefixed path', () => {
expect(middleware(req('/uk/pricing', { country: 'DE' }))).toBeUndefined();
});
it('honours an explicit cookie over the detected country', () => {
const res = middleware(req('/pricing', { country: 'DE', cookie: 'storefront=uk' }))!;
expect(res.headers.get('location')).toBe('https://example.com/uk/pricing');
});
it('falls back to the default storefront for an unmapped country', () => {
const res = middleware(req('/pricing', { country: 'ZZ' }))!;
expect(res.headers.get('location')).toBe('https://example.com/us/pricing');
});
it('marks the redirect uncacheable', () => {
const res = middleware(req('/pricing', { country: 'FR' }))!;
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('leaves assets alone', () => {
expect(middleware(req('/assets/app.css', { country: 'DE' }))).toBeUndefined();
});
it('preserves the query string', () => {
const res = middleware(req('/search?q=shoes', { country: 'JP' }))!;
expect(res.headers.get('location')).toBe('https://example.com/jp/search?q=shoes');
});
});
The prefixed-path test is the one that matters most. Add a market to STOREFRONTS and it automatically extends, which is exactly why the regex is derived rather than hand-written.
Common pitfalls and resolutions
ERR_TOO_MANY_REDIRECTS — the prefixed-path guard is missing or its regex does not match the prefix you emit. Derive the regex from the storefront list.
Everyone gets the same country’s storefront — the 302 was cached by a shared tier. Add cache-control: no-store.
Switcher has no effect — the cookie is read after the country, or written without Path=/. Read it before, and always scope it to the root.
Only one market is indexed — crawlers are being redirected. Ensure prefixed paths pass through untouched and publish hreflang alternates.
Assets 302 to /de/assets/... — the matcher or exempt regex is too narrow. Exclude asset prefixes in both places, since middleware may still run.
Query string lost after redirect — url.search omitted when building the destination. Always carry it forward.
Production deployment checklist
Frequently Asked Questions
Should a country redirect be 301 or 302?
- A 301 is cached by browsers more or less permanently, so a visitor who travels or changes their mind stays stuck on a redirect you can no longer withdraw. The mapping is a guess and guesses must stay revocable.
How do I stop a redirect loop?
Check whether the path already carries a storefront prefix before doing anything else, and return without a redirect if it does. Derive that prefix regex from the same list you use to build destinations so the two cannot drift apart.
Will country redirects hurt search rankings?
They will if crawlers are forced through them, because a crawler has one location and will only ever see one market. Keep every prefixed URL directly reachable, publish hreflang alternates, and never branch on user agent.
Why does everyone get the same country's page?
The redirect response was stored by a shared cache tier and replayed to every visitor. Any response whose content depends on the requester’s country must carry cache-control no-store, or be keyed on that dimension explicitly.
Does the preference cookie need HttpOnly?
No. It records a display preference rather than a credential, and client-side code often needs to read it to highlight the active market in a switcher. Keep SameSite=Lax and Secure, and scope it to Path=/.