Setting Up Locale Auto-Detect & Redirection in Shopify Hydrogen

Setting Up Locale Auto-Detect & Redirection in Shopify Hydrogen

Introduction

One of the most powerful features of building a headless Shopify storefront with Hydrogen is full control over your internationalization (i18n) routing. Unlike a standard Shopify theme where Markets handles most of this automatically, Hydrogen gives you the control — and the responsibility — of detecting where a visitor is coming from and routing them to the correct locale, currency, and market experience.

Done well, this is invisible to the customer: they land on your store, the system detects they're in Germany, and they seamlessly see German content in EUR without ever clicking a language selector. Done poorly, customers see the wrong currency, checkout in the wrong market, or get stuck in redirect loops that destroy their session.

This guide covers every layer of the implementation: geolocation detection, URL structure, Remix route handling, Shopify Markets integration, redirect logic, and edge cases that catch most developers off guard.


Who This Guide Is For

  • Developers building Shopify Hydrogen storefronts (v2+ with Remix)
  • Teams implementing Shopify Markets in a headless architecture
  • Developers familiar with Remix loaders, actions, and routing but new to i18n
  • Engineers migrating from a themed store to Hydrogen who need to replicate Markets behavior
  • Anyone debugging redirect loops, wrong currency display, or locale persistence issues in Hydrogen

Architecture Overview

Before writing any code, understand the full stack you're working with:


Visitor Request
    ↓
Edge Network (Oxygen / Cloudflare / Vercel)
    ↓
Geolocation Headers (country, region, city)
    ↓
Remix Root Loader — locale detection logic
    ↓
i18n URL Structure (/en-us/, /de-de/, /sv-se/)
    ↓
Shopify Storefront API — market-aware queries
    ↓
Correct prices, translations, availability per market
    ↓
Customer sees localized storefront

Each layer must be correctly implemented and pass context to the next. A failure at any layer breaks the chain.


Step 1 — Define Your i18n URL Structure

First, decide how locales will be expressed in your URLs. There are three common patterns:


yourstore.com/en-us/products/shirt
yourstore.com/de-de/products/shirt
yourstore.com/sv-se/products/shirt

Pros: Clean, SEO-friendly, unambiguous, easy to implement in Remix routing. Cons: Requires root URL redirect for first-time visitors.

Option B — Subdomain


us.yourstore.com/products/shirt
de.yourstore.com/products/shirt
sv.yourstore.com/products/shirt

Pros: Strong market separation, good for domain-based Shopify Markets. Cons: Complex Oxygen/DNS setup, cookie sharing issues across subdomains.

Option C — Country TLD


yourstore.com/products/shirt
yourstore.de/products/shirt
yourstore.se/products/shirt

Pros: Maximum local trust signal. Cons: Requires multiple domains, complex deployment, higher infrastructure cost.

This guide uses Option A (locale prefix) as it is the most practical for most Hydrogen implementations and the pattern used in Shopify's official Hydrogen templates.


Step 2 — Set Up the i18n Type Definitions

Define your supported locales and their mapping to Shopify Markets data.

Create app/lib/i18n.ts:


export interface Locale {
  language: string;       // ISO 639-1: 'EN', 'DE', 'SV'
  country: string;        // ISO 3166-1: 'US', 'DE', 'SE'
  currency: string;       // ISO 4217: 'USD', 'EUR', 'SEK'
  label: string;          // Display name: 'United States'
  pathPrefix: string;     // URL prefix: '/en-us'
}

export type LocaleKey = 'EN-US' | 'DE-DE' | 'SV-SE' | 'EN-GB' | 'FR-FR';

export const SUPPORTED_LOCALES: Record<LocaleKey, Locale> = {
  'EN-US': {
    language: 'EN',
    country: 'US',
    currency: 'USD',
    label: 'United States',
    pathPrefix: '/en-us',
  },
  'DE-DE': {
    language: 'DE',
    country: 'DE',
    currency: 'EUR',
    label: 'Deutschland',
    pathPrefix: '/de-de',
  },
  'SV-SE': {
    language: 'SV',
    country: 'SE',
    currency: 'SEK',
    label: 'Sverige',
    pathPrefix: '/sv-se',
  },
  'EN-GB': {
    language: 'EN',
    country: 'GB',
    currency: 'GBP',
    label: 'United Kingdom',
    pathPrefix: '/en-gb',
  },
  'FR-FR': {
    language: 'FR',
    country: 'FR',
    currency: 'EUR',
    label: 'France',
    pathPrefix: '/fr-fr',
  },
};

// Default locale for unrecognized countries
export const DEFAULT_LOCALE: Locale = SUPPORTED_LOCALES['EN-US'];

// Map ISO country codes to locale keys
export const COUNTRY_TO_LOCALE: Record<string, LocaleKey> = {
  US: 'EN-US',
  CA: 'EN-US',    // Canada → English US (or create EN-CA)
  DE: 'DE-DE',
  AT: 'DE-DE',    // Austria → German
  CH: 'DE-DE',    // Switzerland → German (or create DE-CH)
  SE: 'SV-SE',
  GB: 'EN-GB',
  IE: 'EN-GB',    // Ireland → English GB
  FR: 'FR-FR',
  BE: 'FR-FR',    // Belgium → French (or create FR-BE)
  // Add all countries you serve
};

export function getLocaleFromCountry(countryCode: string): Locale {
  const key = COUNTRY_TO_LOCALE[countryCode.toUpperCase()];
  return key ? SUPPORTED_LOCALES[key] : DEFAULT_LOCALE;
}

export function getLocaleFromPath(pathname: string): Locale | null {
  for (const locale of Object.values(SUPPORTED_LOCALES)) {
    if (pathname.startsWith(locale.pathPrefix)) {
      return locale;
    }
  }
  return null;
}

Step 3 — Configure Remix Routes for Locale Prefixes

Hydrogen uses Remix's file-based routing. Add an optional locale segment to your route structure.

Recommended route structure:


app/routes/
  ($locale)._index.tsx          → Homepage
  ($locale).products.$handle.tsx → Product pages
  ($locale).collections.$handle.tsx → Collections
  ($locale).cart.tsx            → Cart
  ($locale).account.tsx         → Account

The ($locale) segment is a Remix optional segment — it matches both /en-us/products/shirt and /products/shirt.

In each route file, extract and validate the locale:


// app/routes/($locale)._index.tsx
import { json, type LoaderFunctionArgs } from '@shopify/remix-oxygen';
import { getLocaleFromPath, DEFAULT_LOCALE } from '~/lib/i18n';

export async function loader({ params, context, request }: LoaderFunctionArgs) {
  const { locale: localeParam } = params;
  const pathname = new URL(request.url).pathname;

  // Resolve locale from URL path
  const locale = getLocaleFromPath(pathname) ?? DEFAULT_LOCALE;

  // Pass locale to Storefront API queries
  const { storefront } = context;

  const data = await storefront.query(HOMEPAGE_QUERY, {
    variables: {
      country: locale.country,
      language: locale.language,
    },
  });

  return json({ locale, data });
}

Step 4 — Implement Geolocation Detection

This is where automatic locale detection happens. The approach depends on your hosting environment.

Oxygen automatically injects geolocation data into request headers. Access it in your root loader:


// app/root.tsx — root loader
export async function loader({ request, context }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const pathname = url.pathname;

  // Oxygen provides these headers automatically
  const country =
    request.headers.get('oxygen-buyer-country') ??
    request.headers.get('cf-ipcountry') ??    // Cloudflare fallback
    'US';                                       // Final fallback

  const region =
    request.headers.get('oxygen-buyer-region') ?? '';

  const detectedLocale = getLocaleFromCountry(country);
  const pathLocale = getLocaleFromPath(pathname);

  return json({
    detectedLocale,
    pathLocale,
    country,
  });
}

Available Oxygen geolocation headers:

Header Value Example
oxygen-buyer-country ISO 3166-1 alpha-2 DE
oxygen-buyer-region Region/state code BY (Bavaria)
oxygen-buyer-city City name Munich
oxygen-buyer-latitude Decimal latitude 48.1351
oxygen-buyer-longitude Decimal longitude 11.5820

On Cloudflare Workers / Pages


// Cloudflare provides CF-IPCountry header
const country = request.headers.get('cf-ipcountry') ?? 'US';

On Vercel Edge


// Vercel provides x-vercel-ip-country header
const country = request.headers.get('x-vercel-ip-country') ?? 'US';

Self-Hosted / Node.js Fallback

If no geolocation headers are available, use a geolocation API:


import maxmind from 'maxmind'; // or use a lightweight IP API

async function getCountryFromIP(ip: string): Promise<string> {
  // Option A: MaxMind GeoLite2 (local database, free)
  const lookup = await maxmind.open('./GeoLite2-Country.mmdb');
  const result = lookup.get(ip);
  return result?.country?.iso_code ?? 'US';

  // Option B: IP API (external call, adds latency)
  // const response = await fetch(`https://ipapi.co/${ip}/country/`);
  // return await response.text();
}

const clientIP =
  request.headers.get('x-forwarded-for')?.split(',')[0] ??
  request.headers.get('x-real-ip') ??
  '127.0.0.1';

const country = await getCountryFromIP(clientIP);

Performance note: External IP geolocation API calls add 50–200ms of latency per request. Use a local database (MaxMind GeoLite2) or rely on edge-provided headers whenever possible.


Step 5 — Implement the Redirect Logic

This is the most delicate part. The redirect logic must handle first-time visitors, returning visitors, and manual locale selection — without creating redirect loops.

Create app/lib/redirect.ts:


import { redirect } from '@shopify/remix-oxygen';
import {
  getLocaleFromPath,
  getLocaleFromCountry,
  DEFAULT_LOCALE,
  type Locale
} from './i18n';

const LOCALE_COOKIE = 'user_locale';
const LOCALE_OVERRIDE_PARAM = 'locale';

export function shouldRedirectToLocale(
  request: Request,
  detectedCountry: string
): Response | null {
  const url = new URL(request.url);
  const pathname = url.pathname;

  // 1. Check if user manually selected a locale (query param)
  //    e.g., ?locale=de-de from a country selector click
  const localeOverride = url.searchParams.get(LOCALE_OVERRIDE_PARAM);
  if (localeOverride) {
    const targetPath = `/${localeOverride}${pathname}`;
    const response = redirect(targetPath, { status: 302 });
    // Set cookie to remember manual selection
    response.headers.set(
      'Set-Cookie',
      `${LOCALE_COOKIE}=${localeOverride}; Path=/; Max-Age=31536000; SameSite=Lax`
    );
    return response;
  }

  // 2. Check if user has a saved locale preference cookie
  const cookieHeader = request.headers.get('Cookie') ?? '';
  const savedLocale = parseCookie(cookieHeader, LOCALE_COOKIE);
  if (savedLocale) {
    const isOnCorrectLocale = pathname.startsWith(`/${savedLocale}`);
    if (!isOnCorrectLocale && pathname === '/') {
      return redirect(`/${savedLocale}`, { status: 302 });
    }
    return null; // Already on correct path or cookie handles it
  }

  // 3. Check if URL already has a valid locale prefix
  const pathLocale = getLocaleFromPath(pathname);
  if (pathLocale) {
    return null; // Already on a localized URL — no redirect needed
  }

  // 4. First-time visitor with no cookie and no locale in URL
  //    Redirect to geo-detected locale
  if (pathname === '/' || pathname === '') {
    const detectedLocale = getLocaleFromCountry(detectedCountry);
    if (detectedLocale.pathPrefix !== DEFAULT_LOCALE.pathPrefix) {
      return redirect(detectedLocale.pathPrefix, { status: 302 });
    }
  }

  // 5. No redirect needed
  return null;
}

function parseCookie(cookieHeader: string, name: string): string | null {
  const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`));
  return match ? match[1] : null;
}

Use it in your root loader:


// app/root.tsx
export async function loader({ request, context }: LoaderFunctionArgs) {
  const country = request.headers.get('oxygen-buyer-country') ?? 'US';

  // Check if a redirect is needed
  const redirectResponse = shouldRedirectToLocale(request, country);
  if (redirectResponse) return redirectResponse;

  // Continue with normal loader logic...
  const detectedLocale = getLocaleFromCountry(country);
  const url = new URL(request.url);
  const pathLocale = getLocaleFromPath(url.pathname) ?? detectedLocale;

  // Store locale in context for child routes
  context.storefront.i18n = {
    language: pathLocale.language,
    country: pathLocale.country,
  };

  return json({ locale: pathLocale });
}

Step 6 — Configure the Storefront API Client for Market-Aware Queries

Every Storefront API query must include the correct country and language buyer context to return market-specific prices, availability, and translations.

In app/lib/createStorefrontClient.ts:


import { createStorefrontClient } from '@shopify/hydrogen';

export function createClient(request: Request, env: Env) {
  const url = new URL(request.url);
  const pathLocale = getLocaleFromPath(url.pathname) ?? DEFAULT_LOCALE;

  return createStorefrontClient({
    storeDomain: env.PUBLIC_STORE_DOMAIN,
    publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
    privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
    storefrontApiVersion: '2024-10',
    // Set buyer context for all queries
    i18n: {
      language: pathLocale.language as LanguageCode,
      country: pathLocale.country as CountryCode,
    },
  });
}

In your GraphQL queries, always include the @inContext directive:


# Product query with market context
query ProductDetails(
  $handle: String!
  $country: CountryCode
  $language: LanguageCode
) @inContext(country: $country, language: $language) {
  product(handle: $handle) {
    id
    title
    description
    priceRange {
      minVariantPrice {
        amount
        currencyCode    # Returns market-specific currency
      }
    }
    variants(first: 10) {
      nodes {
        id
        availableForSale  # Market-specific availability
        price {
          amount
          currencyCode
        }
      }
    }
  }
}

Without @inContext, all queries return your store's default market pricing regardless of which locale URL the customer is on — a very common bug.


Step 7 — Build the Locale Selector Component

Give customers the ability to manually switch locales. This is important for cases where geolocation is wrong (VPNs, travelers, expats).


// app/components/LocaleSelector.tsx
import { Form, useLocation } from '@remix-run/react';
import { SUPPORTED_LOCALES, type Locale } from '~/lib/i18n';

export function LocaleSelector({ currentLocale }: { currentLocale: Locale }) {
  const location = useLocation();

  // Strip current locale prefix from path to get base path
  const basePath = currentLocale.pathPrefix
    ? location.pathname.replace(currentLocale.pathPrefix, '') || '/'
    : location.pathname;

  return (
    <div className="locale-selector">
      <select
        onChange={(e) => {
          const selectedPrefix = e.target.value;
          // Navigate to same page in new locale
          window.location.href = `${selectedPrefix}${basePath}`;
          // Set cookie to remember preference
          document.cookie = `user_locale=${selectedPrefix.replace('/', '')}; path=/; max-age=31536000`;
        }}
        defaultValue={currentLocale.pathPrefix}
      >
        {Object.values(SUPPORTED_LOCALES).map((locale) => (
          <option key={locale.pathPrefix} value={locale.pathPrefix}>
            {locale.label} ({locale.currency})
          </option>
        ))}
      </select>
    </div>
  );
}

Step 8 — Handle Shopify Markets Checkout Correctly

The most critical locale-aware step is checkout. If the cart and checkout aren't tied to the correct market, customers may be charged in the wrong currency or denied checkout.

Create cart with buyer identity:


// When creating or updating the cart, include buyer identity
const CREATE_CART_MUTATION = `#graphql
  mutation cartCreate(
    $input: CartInput!
    $country: CountryCode
    $language: LanguageCode
  ) @inContext(country: $country, language: $language) {
    cartCreate(input: $input) {
      cart {
        id
        checkoutUrl    # This URL is market-aware
        buyerIdentity {
          countryCode
        }
        cost {
          totalAmount {
            amount
            currencyCode  # Should match market currency
          }
        }
      }
      userErrors {
        field
        message
      }
    }
  }
`;

// When creating the cart
const cart = await storefront.mutate(CREATE_CART_MUTATION, {
  variables: {
    input: {
      lines: cartLines,
      buyerIdentity: {
        countryCode: locale.country,  // Critical — ties cart to market
      },
    },
    country: locale.country,
    language: locale.language,
  },
});

Update buyer identity when locale changes:


const UPDATE_BUYER_IDENTITY = `#graphql
  mutation cartBuyerIdentityUpdate(
    $cartId: ID!
    $buyerIdentity: CartBuyerIdentityInput!
    $country: CountryCode
    $language: LanguageCode
  ) @inContext(country: $country, language: $language) {
    cartBuyerIdentityUpdate(
      cartId: $cartId
      buyerIdentity: $buyerIdentity
    ) {
      cart {
        id
        buyerIdentity { countryCode }
        cost {
          totalAmount { amount currencyCode }
        }
      }
      userErrors { field message }
    }
  }
`;

// Call this when the customer switches locale
await storefront.mutate(UPDATE_BUYER_IDENTITY, {
  variables: {
    cartId,
    buyerIdentity: { countryCode: newLocale.country },
    country: newLocale.country,
    language: newLocale.language,
  },
});

Step 9 — Handle SEO: hreflang Tags and Canonical URLs

For international SEO, every page must declare its locale variants via hreflang tags.

In your root route's meta export:


// app/root.tsx
export function meta({ data }: MetaArgs) {
  const { locale } = data;
  const baseUrl = 'https://yourstore.com';

  // Generate hreflang tags for all supported locales
  const hreflangLinks = Object.values(SUPPORTED_LOCALES).map((l) => ({
    tagName: 'link',
    rel: 'alternate',
    hrefLang: `${l.language.toLowerCase()}-${l.country.toLowerCase()}`,
    href: `${baseUrl}${l.pathPrefix}`,
  }));

  return [
    { title: 'Your Store' },
    // Canonical URL for current locale
    {
      tagName: 'link',
      rel: 'canonical',
      href: `${baseUrl}${locale.pathPrefix}`,
    },
    // x-default points to your primary locale
    {
      tagName: 'link',
      rel: 'alternate',
      hrefLang: 'x-default',
      href: baseUrl,
    },
    ...hreflangLinks,
  ];
}

Step 10 — Handle Common Edge Cases


Edge Case 1 — VPN and Proxy Users

Geolocation is unreliable for VPN users. Always:

  • Offer a visible locale selector
  • Never hard-redirect from a non-root URL (only auto-redirect the homepage)
  • Respect the locale in the URL over geolocation — if a customer shares a /de-de/ link, don't redirect them away from it

// Rule: If URL has a valid locale prefix, ALWAYS honor it
// Only auto-detect on the root path "/"
if (pathname !== '/' && getLocaleFromPath(pathname)) {
  return null; // Never redirect away from an explicit locale URL
}

Edge Case 2 — Redirect Loops

The most common Hydrogen i18n debugging nightmare. Loops happen when:

  • The redirect target itself triggers another redirect
  • Middleware and route loaders both redirect
  • Cookie and header detection conflict

Prevention rules:

  1. Only redirect from / — never from already-localized paths
  2. Check for redirect target === current URL before redirecting
  3. Add a redirected=1 query parameter on the redirect and skip detection if present:

// Prevent loops
if (url.searchParams.get('redirected')) {
  return null; // Already redirected once — stop
}

// On redirect, add the flag
return redirect(`${targetPath}?redirected=1`, { status: 302 });

Edge Case 3 — Static Assets and API Routes

Locale prefixes should never be applied to:

  • /cdn/ paths (Shopify CDN assets)
  • /api/ routes (your custom API endpoints)
  • /__healthcheck or other utility paths
  • Shopify Webhook endpoints

const SKIP_LOCALE_PATHS = ['/cdn/', '/api/', '/__', '/favicon'];

function shouldSkipLocaleDetection(pathname: string): boolean {
  return SKIP_LOCALE_PATHS.some((skip) => pathname.startsWith(skip));
}

Edge Case 4 — Search Engine Crawlers

Googlebot and other crawlers don't have a geographic location. They typically crawl from US IPs. Ensure:

  • Your default locale (/en-us/) is fully crawlable with no login or restriction
  • x-default hreflang points to your primary content
  • The sitemap includes all locale variants of every URL
  • No noindex tags on locale-prefixed pages

Edge Case 5 — Locale Persistence Across Sessions

When a returning visitor comes back, their locale preference should persist. Use a combination of:


// Priority order for locale resolution:
// 1. URL path (highest priority — explicit)
// 2. user_locale cookie (returning visitor preference)
// 3. Accept-Language header (browser preference)
// 4. Geolocation from edge headers (location-based)
// 5. Default locale (fallback)

function resolveLocale(request: Request, geoCountry: string): Locale {
  const url = new URL(request.url);

  // 1. URL path
  const pathLocale = getLocaleFromPath(url.pathname);
  if (pathLocale) return pathLocale;

  // 2. Cookie
  const cookie = request.headers.get('Cookie') ?? '';
  const savedLocale = parseCookie(cookie, 'user_locale');
  if (savedLocale && SUPPORTED_LOCALES[savedLocale.toUpperCase()]) {
    return SUPPORTED_LOCALES[savedLocale.toUpperCase() as LocaleKey];
  }

  // 3. Accept-Language header
  const acceptLanguage = request.headers.get('Accept-Language') ?? '';
  const browserLang = acceptLanguage.split(',')[0].split('-')[0].toUpperCase();
  // Map browser language to locale...

  // 4. Geolocation
  return getLocaleFromCountry(geoCountry);
}

Testing Your Implementation

Local testing (no real geolocation headers):


// In development, mock geolocation headers
if (process.env.NODE_ENV === 'development') {
  // Override detected country for testing
  const devCountry = process.env.DEV_COUNTRY ?? 'US';
  // Use devCountry instead of header
}

Test matrix — run all of these:

Test Expected Outcome
Visit / from US IP Redirect to /en-us/
Visit / from DE IP Redirect to /de-de/
Visit /de-de/ from US IP Stay on /de-de/ — URL wins
Switch locale via selector Cookie set, redirect to new locale
Return visit with cookie Cookie locale used, not geo
Visit /products/shirt (no locale) Redirect to /en-us/products/shirt or default
Product price on /de-de/ Shows EUR price
Product price on /en-us/ Shows USD price
Checkout from /de-de/ Cart buyer identity = DE
hreflang on product page All locale variants listed
Googlebot visits / No redirect, served default locale

Back to blog

Leave a comment