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:
Option A — Locale Prefix (Recommended)
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.
On Shopify Oxygen (Recommended)
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:
-
Only redirect from
/— never from already-localized paths - Check for redirect target === current URL before redirecting
-
Add a
redirected=1query 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) -
/__healthcheckor 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-defaulthreflang points to your primary content - The sitemap includes all locale variants of every URL
-
No
noindextags 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 |