Hydrogen i18n: Put the Locale in the URL Path

Hydrogen i18n comes down to one decision: where the locale lives. Put it in the URL path (/fr-ca/products/...), read it from the request in app/lib/context.ts, pass it to createHydrogenContext as i18n, and rename your routes to carry a ($locale) segment. Hydrogen then fills $country and $language on every Storefront API query that declares them, and search engines get one indexable URL per market.

Everything below is written against the React Router line of Hydrogen, @shopify/[email protected], with the skeleton that @shopify/[email protected] scaffolds (React Router 7.16.0, Storefront API 2026-04). As of October 2026 the latest tag on npm is 2026.10.x, the framework-agnostic SDK, which has none of this and where shopify hydrogen setup markets is explicitly unsupported. Pin 2026.4 if you want what follows.

Because nothing downstream can see it. Shopify’s own locale detection guidance is blunt: page caching ignores locale cookies, headers and search params, and crawlers mostly arrive from the US with no cookies and a fixed accept-language. A storefront that picks the market from oxygen-buyer-country serves Googlebot the US catalogue forever and serves a cached French page to the next Canadian who hits the same URL.

The header is still useful. Oxygen sets oxygen-buyer-country and friends on every request (the runtime docs list twelve of them, all possibly undefined), and the right use is a banner that offers to switch, not a redirect that does it for the customer.

That leaves three URL structures, and the CLI will scaffold any of them with npx shopify hydrogen setup markets: subfolders (example.com/fr-ca/...), subdomains (de.example.com) and top-level domains (example.jp). The command writes an app/lib/i18n.ts and rewires the i18n property in context.ts; the subfolder scaffold is the version with no DNS work, and the one the rest of this post uses.

How does Hydrogen inject country and language into queries?

Through createHydrogenContext. The skeleton ships with i18n: {language: 'EN', country: 'US'} hard-coded and a comment telling you to detect it instead. The markets cookbook recipe replaces that line with a function of the request:

// app/lib/i18n.ts
import type {
  CountryCode,
  LanguageCode,
} from '@shopify/hydrogen/storefront-api-types';

export type Locale = {
  language: LanguageCode;
  country: CountryCode;
  pathPrefix: string;
};

export const DEFAULT_LOCALE: Locale = {
  language: 'EN',
  country: 'GB',
  pathPrefix: '/',
};

export const SUPPORTED_LOCALES: Locale[] = [
  DEFAULT_LOCALE,
  {language: 'EN', country: 'IE', pathPrefix: '/EN-IE'},
  {language: 'FR', country: 'FR', pathPrefix: '/FR-FR'},
  {language: 'DE', country: 'DE', pathPrefix: '/DE-DE'},
];

const RE_LOCALE_PREFIX = /^[A-Z]{2}-[A-Z]{2}$/i;

export function getLocaleFromRequest(request: Request): Locale {
  const firstPathPart = new URL(request.url).pathname
    .split('/')
    .at(1)
    // React Router loader requests arrive as /fr-fr.data
    ?.replace(/\.data$/, '')
    ?.toUpperCase();

  if (!firstPathPart || !RE_LOCALE_PREFIX.test(firstPathPart)) {
    return DEFAULT_LOCALE;
  }

  const [language, country] = firstPathPart.split('-') as [
    LanguageCode,
    CountryCode,
  ];
  return {language, country, pathPrefix: '/' + firstPathPart};
}

export function localeMatchesPrefix(segment: string | null): boolean {
  const prefix = '/' + (segment ?? '');
  return SUPPORTED_LOCALES.some(
    (l) => l.pathPrefix.toUpperCase() === prefix.toUpperCase(),
  );
}
// app/lib/context.ts (the only lines that change)
import {getLocaleFromRequest} from './i18n';

const hydrogenContext = createHydrogenContext(
  {
    env,
    request,
    cache,
    waitUntil,
    session,
    i18n: getLocaleFromRequest(request),
    cart: {queryFragment: CART_QUERY_FRAGMENT},
  },
  additionalContext,
);

The .data line matters. After hydration, React Router fetches loader data from /fr-fr.data rather than /fr-fr, so a parser that only splits on / sees FR-FR.DATA, fails the regex and silently drops to the default locale on every client-side navigation, while the first server render was correct. That mismatch is hard to spot in the browser and easy to prevent with the one replace.

What the storefront client then does with i18n is narrower than “Hydrogen injects the values” suggests. Reading createStorefrontClient in the 2026.4.7 build: for each storefront.query or storefront.mutate, if the document text contains $country and you did not pass a country variable, it sets variables.country = i18n.country; the same for $language. Nothing else. A query that never declares those variables goes out uncontextualised and comes back with shop-default prices and untranslated titles, which is the usual cause of a French page that quotes pounds. Declaring them is the whole fix:

query ProductByHandle(
  $handle: String!
  $country: CountryCode
  $language: LanguageCode
) @inContext(country: $country, language: $language) {
  product(handle: $handle) {
    title
    priceRange { minVariantPrice { amount currencyCode } }
  }
}

Passing country explicitly still wins, so you can query another market from inside a route when you need to. The Storefront API international pricing guide covers what @inContext does on the other side, including the fallback: countries not enabled in Markets default to the store currency. Our write-up on @inContext goes into the cache-key consequences, which Hydrogen does not handle for you.

How do ($locale) routes work in React Router?

Wrapping a file-route segment in parentheses makes it optional, so app/routes/($locale).products.$handle.tsx matches both /products/hat and /fr-fr/products/hat, with params.locale set in the second case and undefined in the first. You rename every public route this way: _index.tsx becomes ($locale)._index.tsx, cart.tsx becomes ($locale).cart.tsx, and so on through collections, blogs, pages, policies, search and the account routes. Leave [robots.txt].tsx, [sitemap.xml].tsx and cart.$lines.tsx alone; they are not pages.

The React Router file route conventions carry a warning that bites here: optional segments match eagerly. /american-flag-speedo matches ($lang)._index.tsx with params.lang = 'american-flag-speedo', not your catch-all. The recipe’s fix is a parent route, app/routes/($locale).tsx, whose only job is to reject prefixes you do not support:

// app/routes/($locale).tsx
import type {Route} from './+types/($locale)';
import {localeMatchesPrefix} from '~/lib/i18n';

export async function loader({params}: Route.LoaderArgs) {
  if (!localeMatchesPrefix(params.locale ?? null)) {
    throw new Response('Invalid locale', {status: 404});
  }
  return null;
}

No default export is needed; React Router renders the child through an implicit outlet. With that in place, /fr-fr/ renders the French home page, /xx-yy/ is a 404, and /some-old-handle is also a 404, not a mis-localised home page. Handles that moved still belong to storefrontRedirect in your catch-all; see our Hydrogen redirects post.

Two places lose the prefix if you let them: every <Link to="/products/hat"> in the app, and the cart.

For links, the recipe adds a Link wrapper around React Router’s Link and NavLink that reads the selected locale and prepends pathPrefix to any string to that does not already carry one. The locale reaches the client through the root loader, which returns selectedLocale: args.context.storefront.i18n, and a key on PageLayout built from language and country so the layout remounts on a switch. Set <html lang> from the same value; the skeleton hard-codes lang="en".

The cart is the part people forget. Cart pricing does not use @inContext; it uses the cart’s own buyerIdentity.countryCode, which is persisted on the cart. Switch the URL to /fr-fr without touching the cart and the product page quotes euros while the drawer still totals in pounds. The recipe’s country selector therefore posts to the cart route instead of linking to the new path:

// Inside a CountrySelector: one form per supported locale
const action = `${locale.pathPrefix.replace(/\/+$/, '')}/cart`;
const cartFormInput = JSON.stringify({
  action: CartForm.ACTIONS.BuyerIdentityUpdate,
  inputs: {buyerIdentity: {countryCode: locale.country}},
});

<Form method="POST" action={action}>
  <input type="hidden" name="redirectTo" value={newPath} />
  <input type="hidden" name="cartFormInput" value={cartFormInput} />
  <button type="submit">{locale.language}-{locale.country}</button>
</Form>

The skeleton’s cart action already handles both halves: CartForm.ACTIONS.BuyerIdentityUpdate calls cartBuyerIdentityUpdate, and a redirectTo field in the form data turns the response into a 303 to the new path, so there is nothing to add on the server.

What does Hydrogen give you for hreflang and sitemaps?

Two utilities, both in @shopify/hydrogen. getSeoMeta takes an alternates array of {language, url, default} and emits <link rel="alternate" hreflang> tags, with default: true producing x-default. Google’s localised versions guidance requires every version to list itself and all the others with fully qualified URLs, so build the array from SUPPORTED_LOCALES in the root meta, not by hand per route.

getSitemap does the same for the sitemap. The skeleton’s sitemap.$type.$page[.xml].tsx already shows the shape, locales: ['EN-US', 'EN-CA', 'FR-CA'] plus a getLink callback that builds ${baseUrl}/${locale}/${type}/${handle}; swap in your own locale list and the generated XML carries the xhtml:link alternates for each entry.

What I would do

For a store with two or more markets configured in Shopify Markets, run npx shopify hydrogen setup markets subfolders on day one of the build, before the route list grows. Treat the ($locale) rename as part of creating any new route. Keep the default market unprefixed so existing URLs survive, and use oxygen-buyer-country for a suggestion banner only.

Skip all of this when there is one market and one language. The optional segment, the Link wrapper and the cart form are a tax on every route, and a single-market store pays it for nothing. Add it when the second market is configured in the admin, not while it is still a plan.

If you are building or re-platforming a multi-market storefront on Hydrogen, that is work we do. We can set up the locale routing, the Markets configuration behind it and the hreflang and sitemap plumbing so each market indexes cleanly; see our Shopify development services.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch