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.

Country redirect decision tree The middleware checks in order: is the path already locale-prefixed, is there a stored preference cookie, and only then does it consult the country to issue a temporary redirect. Incoming request Path already locale-prefixed? Preference cookie present? Country maps to a storefront? pass through honour the choice 302 → /de/ no-store default storefront yes yes yes no no no
The country check is the last question asked, not the first. Every earlier branch exits without a redirect.

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.

Country-to-storefront mapping Groups of country codes map onto five storefronts. Countries with no entry in the map flow to the default storefront rather than producing an error. DE · AT · CH FR · BE · LU GB · IE JP US · CA ZZ · unmapped /de/ /fr/ /uk/ /jp/ /us/ unmapped countries fall through to the default storefront one prefix per market
The dashed edge is the one people forget. Every country not in the map must resolve somewhere, not throw.

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.

Loop with and without the prefixed-path guard Without the already-prefixed guard, a request to a storefront path is redirected again and again. With the guard, the second request passes straight through. Without the guard /pricing /de/pricing ERR_TOO_MANY_REDIRECTS With the guard /pricing /de/pricing 200 OK — guard exits early Check "am I already on a storefront path?" before anything else.
One guard, checked first, is the difference between a working redirect and a browser error page.

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 redirecturl.search omitted when building the destination. Always carry it forward.

Production deployment checklist

Frequently Asked Questions

Should a country redirect be 301 or 302?
  1. 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=/.