An country selector - #3959
Conversation
Resolves a request's locale from its URL path prefix (PRD FR-1). Pure and synchronous: the URL is the only input, so the same URL always resolves to the same locale — rendering stays CDN-cacheable and deterministic. Strict mode compares the first path segment against each supported locale's canonical prefix (dictionary lookup, no shape parsing), which makes underscore language codes (PT_BR -> /pt-br-br) unambiguous. The default locale is served only unprefixed; its own prefix resolves like an unknown one so every page has one canonical URL. Core stays framework-agnostic: callers normalize framework URL shapes (e.g. React Router single-fetch .data suffixes) before matching, per the integration recipes in the PRD skill section. Round-trip property covered: match(getLocalizedPath(x)) === x for all supported locales. Permissive mode (no supportedLocales) lands next.
…code sets Makes supportedLocales optional on matchLocaleFromRequest (PRD FR-1). When omitted, prefixes are validated against runtime mirrors of the ShopifyCountryCode/ShopifyLanguageCode types — the intersection of the Storefront and Customer Account API enums — so any real ISO pair matches with zero per-store configuration, while nonsense prefixes (/zz-zz is real, /fr-qq is not) resolve to the default locale. The code sets are generated by scripts/generate-iso-codes.ts (wired into postcodegen) and guarded by a drift test that recomputes the intersection from the introspection schemas, so the artifact can never silently diverge from the types. Segment parsing splits on the last hyphen, which maps hyphenated URL forms back to underscore language codes (pt-br-br -> PT_BR + BR) without regex ambiguity.
Opt-in cross-visit locale persistence (PRD FR-1). Redirects unprefixed requests to the buyer's saved locale with a 302; prefixed URLs always win and are never redirected, so shared links stay deterministic and redirect loops are unrepresentable. The session changes which URL a buyer lands on, never what a URL renders — rendering stays a pure function of the URL. Redirect responses are marked private, no-store so shared caches never serve one buyer's locale to another. Session values are untrusted: malformed data degrades to no-redirect with a warn log, and a throwing session read degrades with an error log, per the error-reporting policy. The resolveLocaleUrl option is the escape hatch for subdomain/domain-per- market URL schemes.
Adds the SFAPI localization data layer (PRD FR-2): a default query
covering the current country/language, active market handle, and
availableCountries with per-country currency and languages, plus
makeLocalizationQueries({fragments}) following the predictive-search
fragment-override pattern (contract-checked LocalizationCountryFragment/
LocalizationLanguageFragment with minimal defaults).
queryLocalization returns the data; fetchLocalization also exposes
response headers so the upcoming GET handler can forward cache headers.
$country/$language are auto-injected from the request context i18n by
the storefront client. The new files join the existing oxlint
consistent-type-assertions opt-out group, matching the generic-boundary
pattern used by predictive-search and cart.
buildVariables unconditionally overwrote user-supplied country/language variables with the request context i18n, so passing them explicitly was a silent no-op. Injection is now a default rather than an override: variables the caller sets win, and only missing ones are filled in. Explicit-over-implicit matches the package API principles, and the localization GET endpoint needs it to forward ?country/?language query params to @incontext for translated country names.
Adds createLocalizationServerHandlers() with both endpoints (PRD FR-3), registered through the existing handleShopifyRoutes handlers array. GET /localization returns a locale-anonymous payload (availableCountries + market, never the active locale) so the response is identical for every buyer at a given URL and public caching cannot leak locale across buyers. Default policy is max-age=3600 with stale-while-revalidate, configurable via cacheControl. Optional country/language query params are validated against the generated ISO sets and forwarded to @incontext, keeping the cache key honest (the full URL). With supportedLocales configured, the payload is the pair-wise intersection of live Markets data with the list, and a drift warning logs when the merchant enables markets the config does not cover. POST /localization validates submissions (form shape, ISO codes, live Markets data, supportedLocales pairs), syncs the cart buyer identity country when a cart cookie exists, writes the selection to the session, commits it, and 303-redirects to the localized equivalent of redirectTo (same-origin only; cross-origin falls back to the root). Cart sync and session persistence are soft failures: the redirect always proceeds and errors are logged per the error-reporting policy. Because the endpoint is unprefixed, its request context i18n is the app's default locale, so no separate defaultLocale option exists to misconfigure. When language is omitted, the buyer's current language (recovered from redirectTo's prefix) is kept if the target country offers it. queryLocalization gains explicit country/language overrides, and matchLocalePathname is extracted for handler reuse. server-handlers.ts joins the oxlint type-assertion opt-out group like its predictive-search counterpart.
…rect getLocaleRedirect redirected any unprefixed request, including POSTs — and a 302 after POST is followed with GET, silently dropping the form body. A returning international buyer submitting any unprefixed form (including the localization form itself) would lose the submission. Client fetch() calls (JSON endpoints, framework data requests) were equally at risk of being relocated away from the URL they asked for. Guard on navigations only: GET/HEAD methods that accept text/html. Sec-Fetch-Mode was deliberately rejected as the signal: it is a forbidden header that fetch-based proxy hops rewrite to 'cors' (verified against mini-oxygen's dev proxy), while Accept survives proxies and cleanly separates document navigations from data requests. This also makes the helper order-independent relative to handleShopifyRoutes instead of requiring a 'call after route handling' footnote every integration could get wrong.
Integrating the selector into examples/hydrogen surfaced a divergence bug: an SSR loader querying localization data directly bypasses the GET handler's supportedLocales intersection, so the rendered selector could offer countries the router and POST validation reject. Extract the pair-wise intersection into a public, pure helper used by both the server handlers and SSR consumers — one definition of 'which locales may be offered', applied at every surface that feeds a selector. Drift warning stays in the handlers, so per-page SSR filtering does not spam logs.
Lightweight end-to-end demonstration of the localization module — every piece works with JavaScript disabled: - app/lib/i18n.ts swaps the hand-rolled locale map for matchLocaleFromRequest with a DEFAULT_LOCALE + SUPPORTED_LOCALES config defined once and shared with the handlers. React Router single-fetch .data URLs are normalized here, at the integration boundary, keeping the package matcher framework-agnostic. - server.ts registers createLocalizationServerHandlers in the existing handlers array and adds getLocaleRedirect so returning buyers landing on unprefixed URLs are taken back to their chosen locale. - The root loader fetches the country list (Cache.long) and applies the same getSupportedCountries intersection as the endpoints, so the selector can never offer a locale the router won't serve. - CountrySelector renders country and language as separate plain HTML forms (the Liquid theme pattern): a country switch never submits a language the new country might not offer — the server resolves the best language instead. Language options render their endonym with a lang attribute per the accessibility contract. Verified against a dev store: POST 303s to the localized redirectTo, cart buyer identity syncs, the session round-trips, returning navigations 302 to the saved locale, fetch/data requests are never redirected, and prefixed URLs always win over the session.
…ontrol Selecting a country or language now submits immediately (full document navigation — every rendered price changes with the locale), with the submit buttons hidden after hydration. The buttons stay in server markup so the zero-JS baseline keeps working unchanged; the upcoming client store will add debounced selection on top of the same markup. The two forms render as a single bordered control with an inner divider and sit in the header CTAs, left of the sign-in link.
frandiox
left a comment
There was a problem hiding this comment.
Looking at what this PR does, I think it makes sense to have a primitive / server handler for this, especially considering it also handles cart buyer identity automatically and is optional and tree-shakeable.
Some random thoughts:
- Changing locale should update
window.Shopify.routes.rootandwindow.Shopify.locale. Perhaps a hard navigation is the best for changing locales since it's not a common thing? - I think handling redirects properly is challenging. There might be localized pathnames, and even localized resource handles. We could do the simple redirect and let the user handle it with a second redirect in-app (I think that's what the example does right now). Other alternatives are:
- Allow options in the localization server handler to resolve the redirect target (like a function hook they can run at that point and return the target).
- Do it ourselves: accept a boolean option to enable this behavior, do an SFAPI request to get the localized handle, and accept a map of
routeTemplates(indexed by locale) so that we can match in one language and create the target for another one.
- Localization could also happen in subdomains or tlds like
ca.example.comorexample.ca. User session cookies might not transfer in those. I think the utilities proposed here only for for localization in pathnames but perhaps we should also keep these in mind?- Maybe we could have separate utilities like
createPathLocaleStrategy(...),createOriginLocaleStrategy({ origins: {'es-ES': 'ca.example.com' | 'example.ca'}), and those would help us dostrategy.matchRequest(request)or similar? - Or maybe we only handle the pathname ones but still allow overwriting the matching (I think this might be already the case in this PR?).
- Maybe we could have separate utilities like
I don't have full opinions on the above, just wanted to point them out early.
| * and `fetch()` callers do not. `Sec-Fetch-Mode` is deliberately not used: it is a forbidden | ||
| * header that fetch-based proxy hops (dev servers, edge runtimes) silently rewrite to `cors`. | ||
| */ | ||
| function isNavigationRequest(request: Request): boolean { | ||
| if (!NAVIGATION_METHODS.has(request.method)) return false; | ||
|
|
||
| return request.headers.get(ACCEPT_HEADER)?.includes(NAVIGATION_ACCEPT_VALUE) ?? false; | ||
| } |
There was a problem hiding this comment.
Not sure why is sec-fetch-mode "forbidden"? It's sent by modern browsers and we are able to read it. I think if it's available then it's a stronger signal than accept header?
There was a problem hiding this comment.
This was an agent implementation but it recommended it for this reason:
Browsers do send it, the problem is what happens between the browser and the worker. Forbidden header means the fetch implementation owns it, so any fetch-based proxy hop rewrites it to the mode of its own fetch call. I verified this against mini-oxygen's dev proxy: a real browser navigation arrives at the worker with
sec-fetch-mode: cors, so the redirect never fires in dev while working in prod. Accept survives proxies.That said we could OR the signals:
sec-fetch-mode: navigateis a definite yes (page JS can't spoof it), and fall back to GET/HEAD +Accept: text/htmlotherwise. Happy to do that if you prefer the stronger positive signal.
fredericoo
left a comment
There was a problem hiding this comment.
one more pass and this would be great! left some requests
| path?: string; | ||
| /** Cache-Control for the GET endpoint. Defaults to `DEFAULT_LOCALIZATION_CACHE_CONTROL`. */ | ||
| cacheControl?: string; | ||
| /** Same list passed to `matchLocaleFromRequest` (strict mode); omit for permissive mode. */ |
There was a problem hiding this comment.
can we share one immutable locale/routing config across these primitives while keeping preference persistence on the existing sessionManager?
const i18n = {
defaultLocale,
supportedLocales,
routing: {
match(url) {},
resolveUrl({request, redirectTo, locale}) {},
},
} satisfies LocalizationConfig;match needs to accept an arbitrary URL/path rather than only the endpoint Request, because the POST handler resolves the source locale from redirectTo, not /localization. The existing functions can stay composable and accept this config, while persistence-aware functions continue accepting sessionManager directly. A pathname helper can provide the defaults; an origin strategy can return absolute URLs; and resolveUrl may need to be async when localised handles require an SFAPI lookup.
There was a problem hiding this comment.
This is a good call! Will see what can be done here.
| * Locales the app serves under `/{language}-{country}` prefixes (strict mode). When omitted, | ||
| * any prefix built from valid Shopify country/language codes matches (permissive mode). | ||
| */ | ||
| supportedLocales?: readonly SupportedLocale[]; |
There was a problem hiding this comment.
I don't think permissive mode is safe enough to be the no-config fallback, maybe it shouldn't even exist.
going to /fr-de/products resolves as French/Germany just because FR and DE each exist in their respective enums. That creates cacheable, indexable URL variants which are not backed by the store's Markets configuration.
requiring the configured locale list, or delegating matching to the configured strategy, would also remove the generated ISO arrays, codegen script, drift test, and runtime sets. That's a large amount of machinery for accepting combinations we cannot prove the storefront supports.
There was a problem hiding this comment.
I decided to include permissive mode as the default so merchants' market changes reflect immediately instead of being blocked on a developer update (closer to Liquid behavior). Invalid combos in a selector itself isn't really a risk here since in permissive mode it should ideally be powered by the live Markets API, so it only ever offers real combos.
Without permissive mode, the routing config and Markets have to be kept in sync manually. If a team wants that control, that's exactly what strict mode is for.
I think permissive is still a good option to have. Maybe something like an explicit supportedLocales: "all" opt-in, so it's a deliberate choice?
Agreed, hard navigation seems like the right behavior. This is how it is implemented today. POST to /localization → if valid locale we 303 a full document navigation
|
…zationConfig
Review feedback on the PR surfaced two config problems:
Permissive matching as the no-config default meant any valid ISO pair
(/fr-de/...) minted a routable, cacheable, indexable URL variant the
store's Markets config never backed — an SEO and cache-key hazard that
could be reached by simply omitting an option. Permissive mode survives
because it is what lets merchants launch markets from admin without a
deploy (Liquid parity), but it is now an explicit supportedLocales:
"all" opt-in and can never happen by accident.
The same locale facts were also threaded into each primitive through
separate option fields, leaving room for matching, the endpoints, and
selector filtering to disagree. All primitives now accept one immutable
LocalizationConfig ({defaultLocale, supportedLocales}); the POST handler
takes defaultLocale from it instead of requiring requestContext.i18n,
and getLocaleRedirect uses it to ignore stale session locales pointing
at removed markets rather than redirecting onto a prefix the router
resolves as the default.
matchLocalePathname is now public: the POST handler resolves the source
locale from a redirect target path, not a Request, and integrations hold
bare paths in the same situations.
The example threads a single LOCALIZATION_CONFIG from app/lib/i18n.ts
through matching, the server handlers, and selector data filtering.
…llback Review feedback correctly noted Sec-Fetch-Mode is a stronger navigation signal than Accept: it is a forbidden header page scripts cannot spoof. It cannot be the only signal, though — forbidden also means fetch-based proxy hops own it, and mini-oxygen's dev proxy rewrites a browser's 'navigate' to 'cors' before the worker sees it (verified empirically; relying on it alone made session redirects work in prod but never in dev). getLocaleRedirect now takes the best of both: a present 'navigate' is trusted immediately, anything else falls back to GET/HEAD with an HTML Accept header, which survives proxies. The method guard stays in front of both signals — a form POST is a navigation too, but redirecting it would drop the body.
| * makes every code pair a routable URL variant, so canonical/hreflang tags become the | ||
| * app's responsibility. | ||
| */ | ||
| supportedLocales: readonly SupportedLocale[] | "all"; |
There was a problem hiding this comment.
@fredericoo reverse it where strict mode is first, and the SupportedLocale prevents and non-valid ISO codes.
| */ | ||
| export function matchLocaleFromRequest( | ||
| request: Request, | ||
| config: LocalizationConfig, |
There was a problem hiding this comment.
Moved to one localization config to pass. Can utilize this as a way to eventually support redirect callbacks etc.
| import { routeTemplates } from "~/lib/route-templates"; | ||
|
|
||
| const predictiveSearchHandlers = createPredictiveSearchServerHandlers(); | ||
| const localizationHandlers = createLocalizationServerHandlers(LOCALIZATION_CONFIG); |
There was a problem hiding this comment.
THe localizationHandler now takes in one config, and this will contain defaultLocale, meaning our localization POST handler will not have to pass in the request context
| if (!sessionLocale || isSameLocale(sessionLocale, options.i18n)) return null; | ||
| // A session locale the config no longer serves (e.g. a removed market) is simply ignored; | ||
| // redirecting to its prefix would land on a URL the router resolves as the default locale. | ||
| if (!isSupportedLocale(sessionLocale, options.config.supportedLocales)) return null; |
There was a problem hiding this comment.
@fredericoo to your point about invalid locales, this will now check the strict config and redirect back to a valid locale, rather then just an "incorrect" locale looking URL
| * runtimes) silently rewrite it to `cors`, so its absence falls back to `Accept: text/html`, | ||
| * which browsers send for document requests, survives proxies, and `fetch()` callers do not. | ||
| */ | ||
| function isNavigationRequest(request: Request): boolean { |
There was a problem hiding this comment.
@frandiox to your comment earlier. I now trust sec-fetch-mode: navigate first when it's present.
There was a called out catch which any fetch-based proxy hop could rewrites it (mini-oxygen's dev proxy turns a browser navigation into cors), so when it's missing or mangled we fall back to GET/HEAD + Accept: text/html, which survives proxies. Best of both for now.
TL;DR: Every Hydrogen storefront hand-rolls the same country/currency switching boilerplate: a static country list, a locale-from-URL utility, a resource route, and a form action that updates the cart and redirects. This PR adds a first-class
localizationdomain module to@shopify/hydrogenthat absorbs that boilerplate behind small, framework-neutral primitives, and wires a working selector intoexamples/hydrogen. The design mirrors the Liquid localization form: everything works with JavaScript disabled, and JS only enhances it.Before
Apps ship their own static locale map and matching logic (
examples/hydrogen/app/lib/i18n.ts):There is no selector, no way for a buyer to change country, and no cart currency sync.
After
Locale resolution, the selector endpoints, and cross-visit persistence come from the package:
A plain HTML form posts to
/localization; the server validates the selection against live Markets data, updates the cart buyer identity, saves the choice to the session, and 303-redirects back to the same page under the new locale's path prefix.What this changes
core/localizationmodule with:matchLocaleFromRequest— pure, synchronous URL-prefix matching (/{language}-{country}). Rendering is a function of the URL only, so pages stay CDN-cacheable. All primitives share one requiredLocalizationConfig({defaultLocale, supportedLocales}), so matching, the endpoints, and selector filtering can never disagree.supportedLocalesis an explicit list (strict allowlist) or"all", a deliberate opt-in that validates prefixes against generated Shopify ISO code sets so markets added in admin route without a deploy. Permissive matching is never reachable by omitting config (review feedback).matchLocalePathnamecovers callers holding a path rather than aRequest.getLocalizedPath— re-homes a path from one locale prefix to another, preserving search and hash.getLocaleRedirect— opt-in session persistence. Redirects unprefixed page navigations to the buyer's saved locale; prefixed URLs always win, form posts /fetch()calls are never redirected, and stale session locales the config no longer serves are ignored.createLocalizationServerHandlers— GET returns a locale-anonymous, publicly cacheable payload of available countries (intersected withsupportedLocales, with a drift warning when Markets config grows beyond it). POST validates the submission, syncs cart buyer identity, writes the session, and 303-redirects. Cart and session failures are soft: the redirect always proceeds.localizationQueries/makeLocalizationQueries(fragment overrides),queryLocalization,getSupportedCountries, and exported constants for the endpoint path and form field names.iso-codes.ts): the intersection of the Storefront and Customer Account API enums, produced by a new codegen step and guarded by a drift test, so enum-typed input can be validated at runtime boundaries.$country/$languagevariables now win over auto-injection. Previously they were silently overwritten by the request context i18n.examples/hydrogenintegration: the hand-rolledlib/i18n.tsis replaced with package utilities, the handlers and redirect helper are wired intoserver.ts, and a progressively enhanced country/language selector renders in the header.Developer impact
New public exports on
@shopify/hydrogen(all additive):matchLocaleFromRequest,matchLocalePathname,getLocalizedPath,getLocaleRedirect,createLocalizationServerHandlers,queryLocalization,fetchLocalization,getSupportedCountries,localizationQueries,makeLocalizationQueries, plus localization constants and types.One behavior change in the storefront client: user-supplied
$country/$languagevariables are no longer clobbered by i18n auto-injection. Passing them was a silent no-op before, so existing apps should be unaffected.A minor changeset still needs to be added before this leaves draft (new additive exports, one client behavior fix).
UX impact
examples/hydrogengets a country/language selector in the header, left of the sign-in link, rendered as a single bordered control:endonymName) with alangattribute per option.redirectTopreserves path and query), and returning buyers hitting an unprefixed URL are redirected to their saved locale.Out of scope
Deferred to stacked PRs, per the implementation plan:
templates/react-routerintegration,examples/corereference partial, and thecountry-selectoragent skillRisk
$country/$languageexplicitly, which previously did nothing.getLocaleRedirecttrustsSec-Fetch-Mode: navigatewhen present (page scripts cannot spoof it) and falls back to GET/HEAD +Accept: text/html, because fetch-based proxy hops rewriteSec-Fetch-Modetocors(verified against mini-oxygen's dev proxy). Requests matching neither signal are never redirected.cacheControl.How to Test
pnpm installandpnpm --filter @shopify/hydrogen buildfrom the repo root.pnpm devinexamples/hydrogenand open the printed local URL./en-caand prices show CAD./fr-caand the selector shows both selects./in the address bar. Confirm a redirect back to/fr-ca(session persistence)./en-cadirectly while the session says fr-CA. Confirm it renders English Canada with no redirect (the URL wins)./localizationdirectly. Confirm a JSON payload of available countries limited to the configured locales, with public cache headers, and no active-locale field.