Guides · July 7, 2023
Internationalization & Multi-Currency in Headless Stores
Building i18n and multi-currency into a Next.js commerce storefront means getting locale routing, currency detection, and content translation right at the architecture level, not bolting them on later. Here is the full pattern, end to end.
By Polo Themes
Internationalizing a headless Next.js store comes down to three layers working together: locale-aware routing (URL structure and middleware), a currency and pricing layer that is decoupled from locale, and a translation pipeline for both static UI strings and dynamic commerce content (products, collections, CMS copy). Get the routing and data-fetching boundaries right early, because retrofitting i18n onto a storefront that was never designed for it usually means rewriting the routing layer, the data-fetching layer, and every hardcoded string at once. This tutorial walks through the full pattern used by production Next.js commerce sites, with concrete guidance for the App Router, and calls out the multi-currency traps that catch most teams the first time.
Why i18n Has to Be an Architecture Decision, Not a Feature
On a traditional, template-driven storefront (classic Shopify Liquid, WordPress/WooCommerce), internationalization is usually a plugin or an app: it wraps the existing rendering layer and swaps strings at render time. Headless commerce removes that safety net. When your storefront is a Next.js app pulling data from a commerce API (Shopify's Storefront API, Medusa, Commerce Layer, Saleor, or a custom backend), locale and currency have to be threaded through routing, data fetching, caching, and SEO metadata as first-class concerns from day one. There is no plugin to install after the fact — you are the plugin.
The practical upshot is that i18n decisions made in week one — how URLs encode locale, whether currency is tied to locale or set independently, how translated content is stored — are expensive to reverse in week twenty. This guide treats i18n as an architecture question first, then a translation/content question second, because that ordering is what actually survives a real launch.
Step 1: Decide Your Locale URL Strategy
Next.js's App Router internationalization pattern is built around route groups and a dynamic "locale" segment, not a built-in i18n router (that was a Pages Router feature and does not carry over directly). You have three realistic URL strategies for a commerce site, and the choice affects SEO, CDN caching, and how much middleware complexity you carry.
Subpath routing: /en/products, /fr/products, /de-de/products
This is the strategy most Next.js commerce sites should default to. Every localized page lives under a locale route segment, locale is explicit in the URL, and each locale gets its own crawlable, indexable path with a clean hreflang story. It plays well with static generation — you can pre-render a matrix of locale × product pages at build time or via Incremental Static Regeneration, and a CDN can cache each locale's pages independently without needing to vary on cookies or headers.
Subdomain routing: fr.store.com, de.store.com
Subdomains give you the option to run each locale as an independent deployment (useful if regional teams manage their own content or if you need per-region infrastructure), but they multiply your DNS, SSL, and deployment surface area. For most mid-size headless stores this is more operational overhead than the benefit justifies — reach for it only when a specific locale genuinely needs isolated infrastructure or a separate content team with separate deploy cadence.
Domain-per-market: store.com, store.de, store.fr
Country-code top-level domains carry the strongest geo-targeting signal to search engines and the clearest trust signal to shoppers, but they are the most expensive to run — separate domains, separate SSL certificates, separate SEO authority that has to be built up independently rather than consolidated under one root domain. Reserve this for large multi-national brands with dedicated regional marketing budgets, not for a first international launch.
For the majority of headless storefronts launching international support for the first time, subpath routing is the right default: lowest operational cost, best static-generation compatibility, and a well-understood SEO pattern with hreflang alternates pointing between locale variants of the same page.
Step 2: Locale Detection and Middleware
With subpath routing chosen, Next.js middleware handles the redirect from a bare root request to the right locale-prefixed path. The detection order that works best in practice is: an explicit cookie (the shopper previously chose a locale) first, then the Accept-Language header, then a configured default. Never make locale detection silently override a shopper's explicit choice on every request — that is the single most common i18n bug reported by users ("the site keeps switching back to Spanish").
- Read a first-party cookie (something like NEXT_LOCALE) if present — this is a returning visitor's explicit choice and should always win.
- Fall back to parsing the Accept-Language header for a supported locale match, using a small library rather than hand-rolled parsing (the header's quality-value syntax has more edge cases than it looks).
- Fall back to a configured default locale if neither of the above resolves to a supported one.
- Set the cookie on every response so the choice sticks, and exclude static assets (build output, images, API routes) from the middleware matcher so you are not running locale logic on every asset request.
Keep this middleware fast and dependency-light — it runs on the edge, on every navigational request, and a heavyweight locale-negotiation library or a network call inside middleware will show up directly in your Core Web Vitals and Time to First Byte.
Step 3: Decouple Currency From Locale
This is the trap that catches almost every team building multi-currency for the first time: locale and currency are not the same axis, and conflating them breaks real shopper scenarios. A French-speaking shopper in Canada wants French UI copy but likely CAD pricing, not EUR. A UK-based shopper who prefers English might genuinely want to check out in USD if they are a gift-buyer shipping to the US. Treating currency as a derived property of locale (assuming a French locale always implies euros) works for a v1 demo and breaks the first time a real international shopper visits.
The correct model is two independent, both explicitly settable, values: locale (governs translated UI strings, date/number formatting, and which localized product content to fetch) and currency/market (governs price display and, on most commerce platforms, which regional catalog, tax, and inventory rules apply). Store both independently — typically locale in the URL path and currency in a cookie or a query parameter — and let a shopper change either one without forcing a change to the other. Most commerce APIs (Shopify's Storefront API with its in-context directive, Medusa's regions, Commerce Layer's markets) already model "market" as a concept distinct from language for exactly this reason; your storefront should mirror that separation rather than flatten it.
Currency conversion vs. real regional pricing
There are two fundamentally different things people mean by "multi-currency," and they have very different engineering and business implications. Display conversion takes a single base price and multiplies it by a live or periodically-updated exchange rate purely for display — cheap to build, but the price a shopper sees at checkout can drift from what they saw browsing if rates update between page load and payment, and it does not let you round to clean price points or account for regional tax/duty differences. Real regional pricing stores an actual, merchant-set price per market and currency (what Shopify calls Markets pricing, and what Medusa models as per-region price sets) — more setup work, but the price is authoritative, stable, and can reflect actual local market conditions, competitor pricing, and psychological price points (99 versus 100 endings that make sense in that currency).
For a serious international launch, prefer real regional pricing sourced from your commerce backend over client-side conversion. If you must ship display conversion as a stopgap, be explicit in the UI that the checkout currency may differ, and never let converted prices be what actually gets charged — charge in the currency the payment processor and backend agree on, and treat the converted number as informational only.
Step 4: Formatting — Let the Platform Do the Work
Do not hand-roll currency or number formatting. The Intl.NumberFormat API, built into every modern JavaScript runtime and available natively in both server and client components, handles currency symbol placement, decimal precision (Japanese yen has no decimals; Bahraini dinar has three), thousands separators, and locale-specific digit grouping correctly across locales — problems that are genuinely hard to get right by hand and easy to get right by delegating.
The pattern is straightforward: format with the shopper's locale for punctuation and digit grouping, but the shopper's chosen currency for the currency code — again reinforcing that these are two independent inputs, not one derived from the other. The same discipline applies to dates (Intl.DateTimeFormat) for delivery estimates and order history, and to Intl.RelativeTimeFormat for anything like "ships in 3 days." All three are zero-dependency, tree-shaking-friendly, and already correct for locales you have never tested — a meaningfully better position than shipping a hardcoded formatting library that only covers the locales your team happened to think of.
Step 5: Translating Static UI vs. Dynamic Commerce Content
Split translation work into two clearly different pipelines, because they have different update cadences and different tooling needs. Static UI strings — button labels, form validation messages, checkout step labels, navigation — change rarely and are best managed as JSON or YAML translation files per locale, loaded through a library like next-intl or react-intl, with translations either maintained by a localization vendor or run through a professional machine-translation-plus-human-review pass. These strings ship at build time or are loaded once per locale and cached aggressively.
Dynamic commerce content — product titles, descriptions, collection copy, blog and CMS content — lives in your commerce backend or CMS and needs per-locale fields at the data-model level, not a runtime translation layer bolted onto English content. Most modern headless commerce platforms support this natively: Shopify's Translate and Adapt / Markets model, Medusa's product-level locale metadata, or a headless CMS's built-in localized-field support (Contentful, Sanity, and similar all model this directly). Fetch the correctly-localized record for the active locale server-side, rather than fetching English content and running it through a client-side or on-the-fly machine translation call — the latter is slow, inconsistent between requests, and terrible for SEO since crawlers see un-cached, non-deterministic content.
A concrete data-fetching pattern
In a Next.js App Router server component, the locale segment from the URL params should flow straight into your data-fetching call, and the currency/market from a cookie should flow alongside it: fetch the product with both the locale and the market as query parameters or context directives to your commerce API, so the server component renders fully-localized, fully-priced HTML on the first response — no client-side fetch-and-replace flash of the wrong language or currency. This is also what keeps the page statically generatable per locale, since the locale is known at request or build time rather than resolved client-side after hydration.
Step 6: SEO — hreflang, Sitemaps, and Structured Data
A subpath-routed storefront needs hreflang alternate links on every localized page, pointing to the equivalent page in every other supported locale, plus an x-default fallback for unmatched locales. In the App Router, this is set through the alternates/languages field of the generateMetadata export on each page, generated from the same locale list your middleware uses — keep this list in one shared config so routing, metadata, and sitemap generation cannot drift out of sync with each other.
Sitemaps should enumerate every locale variant of every page (most sitemap generators support this via a locale loop or an alternate-refs field), and structured data (Product, Offer, BreadcrumbList JSON-LD) should reflect the localized price and currency for that specific page, not a single hardcoded currency across all locale variants — search engines that surface price directly in results will show stale or wrong prices otherwise.
Common Pitfalls Worth Naming Explicitly
- Hardcoding currency symbols in templates via naive string concatenation instead of Intl.NumberFormat. This silently breaks the moment a non-dollar currency is added, and someone always finds it in production.
- Deriving currency from locale instead of storing it independently. Breaks the moment a shopper's language preference and shipping market genuinely differ, which is a normal, common case, not an edge case.
- Client-side-only locale switching that does not update the URL. Breaks deep links, breaks the back button, and breaks SEO since crawlers only ever see the default locale's content.
- Caching pages without varying on locale/currency, which serves the wrong language or price to a segment of visitors — get your CDN and ISR cache keys right before traffic scales, since this bug is invisible in local development and only shows up under real, mixed-locale traffic.
- Translating content at runtime with on-the-fly machine translation instead of storing translated fields, which produces inconsistent copy between requests and is effectively invisible to search engines.
Where This Fits With AI-Assisted and Design-to-Code Workflows
As more storefront UI gets scaffolded from Figma via AI design-to-code tools, it is worth building your component library so that every string-bearing component takes translated text as props rather than hardcoding English copy inline — that discipline is what makes a component genuinely portable into an i18n pipeline later instead of requiring a retrofit pass across every file. This is one of the reasons a well-structured Figma source file pays off downstream: if your design system separates content from layout cleanly at the design stage (using components and variants rather than one-off hardcoded text layers), the generated code inherits that separation, and wiring in a translation library later becomes a mechanical exercise rather than a rewrite. Our Figma UI kits are built with that component discipline in mind for teams heading in a headless, code-generation-forward direction.
Frequently Asked Questions
Should I use subpath, subdomain, or ccTLD routing for a first international launch?
Subpath routing (paths like /en/ and /de/) is the right default for almost every first launch — lowest operational overhead, best static-generation and CDN-caching compatibility, and a well-understood SEO pattern via hreflang. Reserve subdomains or ccTLDs for cases with a specific regional-infrastructure or brand-trust requirement that outweighs the added operational cost.
Can I just use exchange-rate conversion instead of setting up real regional pricing?
You can as a stopgap, but be explicit in the UI that it is a converted estimate and never let the converted number be what actually gets charged. Real, merchant-set regional pricing (Shopify Markets, Medusa regions, or equivalent) gives you stable, authoritative prices and lets you round to sensible local price points — it is worth building toward even if you launch with conversion first.
Does Next.js have built-in i18n routing in the App Router?
Not as a single built-in feature the way the older Pages Router config did. The App Router pattern is a locale dynamic segment plus middleware for detection and redirects, typically paired with a translation library for message loading and formatting helpers — you are assembling the pieces yourself, which is more flexible but means the architecture decisions in this guide matter more, not less.
Should currency ever be inferred automatically for a shopper?
A reasonable default inferred from IP geolocation or the Accept-Language header on first visit is fine, but always let the shopper override it explicitly and persist that override — never silently re-infer currency on a later visit once a shopper has made an explicit choice, for the same reason you should not silently override an explicit locale choice.