Medusa's regions handle currency, tax and availability. They do not handle language — a region is a commercial concept, and translation is a content concept. Conflating the two produces a storefront where changing language changes the price, which is not what anyone wanted.
Keep them separate and layer language on top.
URL structure
Three workable patterns:
| Pattern | Example | Use when |
|---|---|---|
| Locale-country prefix | `/de-at/products/x` | Multiple languages per country |
| Country prefix only | `/at/products/x` | One language per country |
| Subdomain | `de.example.com` | Separate teams or infrastructure |
The locale-country prefix is the most flexible: de-at and de-de share a language and differ in region, de-ch and fr-ch share a region and differ in language.
export async function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const { language, countryCode } = parseLocale(locale)
const region = await getRegionByCountry(countryCode)
return (
<html lang={language}>
<body>
<RegionProvider region={region}>{children}</RegionProvider>
</body>
</html>
)
}Set lang on <html> from the language, not the locale. lang="de-at" is valid but lang="de" is what screen readers and translation tools expect unless the regional variant genuinely differs.
Interface translation
Ordinary i18n: next-intl or next-i18next with JSON message catalogues per locale. Nothing Medusa-specific, and nothing worth reinventing.
Product content translation
This is the part Medusa leaves to you, and there are three options.
A translation module. Build a module storing translations keyed by resource and locale, linked to products:
import { model } from "@medusajs/framework/utils"
export const Translation = model.define("translation", {
id: model.id().primaryKey(),
resource_type: model.text(), // "product" | "category"
resource_id: model.text().index(),
locale: model.text().index(),
field: model.text(), // "title" | "description"
value: model.text(),
}).indexes([
{ on: ["resource_type", "resource_id", "locale", "field"], unique: true },
])Full control, and merchandisers edit translations in the admin next to the product.
A CMS. Sanity, Contentful or Storyblok own translated copy while Medusa owns commerce data. Better editorial tooling and translation workflow; one more system. See CMS integration.
Locale-suffixed metadata. metadata.title_de. Fast and it will hurt later — no validation, no querying, no editorial interface. Fine for a two-language proof of concept and nothing more.
For anything beyond two languages, the module or the CMS. Choose the CMS if non-technical people manage the copy.
hreflang
The part most stores get wrong. Rules:
- Every page lists all its language variants, including itself.
- Annotations must be reciprocal — if A points at B, B must point at A, or both are ignored.
- Use
x-defaultfor the fallback. - Use the language variant of the URL, not a redirect target.
export async function generateMetadata({ params }) {
const { locale, handle } = await params
return {
alternates: {
canonical: `/${locale}/products/${handle}`,
languages: {
...Object.fromEntries(
SUPPORTED_LOCALES.map((l) => [l, `/${l}/products/${handle}`]),
),
"x-default": `/en-us/products/${handle}`,
},
},
}
}Only list locales where the page actually exists. Pointing at a URL that 404s or redirects invalidates the whole annotation set.
Region mapping
Locale determines language; country determines region:
export const LOCALE_TO_COUNTRY: Record<string, string> = {
"en-us": "us",
"en-gb": "gb",
"de-de": "de",
"de-at": "at",
"fr-fr": "fr",
}
export function parseLocale(locale: string) {
const [language, country] = locale.split("-")
return { language, countryCode: country ?? "us" }
}Then resolve the Medusa region from the country code. A German customer buying in Austria sees German copy and euro pricing under Austrian tax rules, which is correct. See multi-region setup.
Do not auto-redirect on IP
Detecting a country and forcing a redirect is a persistent source of complaints and an SEO problem — crawlers see one variant and cannot reach the others.
Suggest instead: a dismissible banner offering the detected locale, with the choice remembered in a cookie. Traveling customers and crawlers both keep access to every version.
Practical guidance
Translate metadata, not just body copy. Untranslated titles and descriptions in search results undo the work.
Localise formats. Currency, dates and number separators. Intl.NumberFormat with the region's currency, not a hardcoded symbol.
Do not translate slugs mid-life. Localised URLs are good for SEO but changing them later means another redirect map. Decide at launch.
Include every locale in the sitemap with its xhtml:link alternates.
Translation workflow
The technical model is the easy half. What breaks in month three is the process.
Extract before you translate. Interface strings belong in message catalogues from day one, even in a single-language build. Retrofitting them means auditing every component.
Version the source. When English copy changes, translations become stale silently. Store a hash or timestamp of the source string against each translation, and surface staleness in the CMS so translators see what needs revisiting.
Do not machine-translate product copy and publish it unreviewed. For interface strings it is usually fine. For product descriptions in a market you want to sell in, unreviewed output reads as untrustworthy — which is exactly the wrong signal on a purchase page.
Give translators context. A string reading "Free" is a different word depending on whether it means "no cost" or "available". Comments in the catalogue cost minutes and prevent embarrassing mistranslations.
Right-to-left languages
If you plan to support Arabic, Hebrew or Farsi, decide early — retrofitting RTL is genuinely expensive.
<html lang={language} dir={RTL_LANGUAGES.includes(language) ? "rtl" : "ltr"}>Then build with logical CSS properties from the start: margin-inline-start rather than margin-left, padding-inline-end rather than padding-right, text-align: start rather than left. Modern browsers flip these automatically with dir, and a codebase written with them supports RTL almost for free.
Directional icons — arrows, chevrons, progress indicators — need explicit flipping, and numerals and prices stay left-to-right inside RTL text. Test with a real speaker rather than assuming the mirror looks right.
URL slugs per locale
Localised slugs — /de-de/produkte/leder-geldboerse rather than /de-de/products/leather-wallet — help in-market search, and they add work.
The path segment (products versus produkte) is straightforward: a route-segment map per locale, resolved in middleware.
The product handle is harder, because Medusa's handle is single-valued. Store localised handles in your translation module or CMS, resolve incoming requests through a lookup, and keep the canonical Medusa handle as the fallback.
The rule that matters: decide before launch. Changing slugs later means another redirect map and another ranking dip, for a benefit you could have had from the start.
Multi-language commerce is mostly discipline rather than difficulty. We are happy to plan one.
Frequently asked questions
Does Medusa support multiple languages out of the box?
Not for content. Medusa handles regions, currencies and tax, but product titles and descriptions are single-valued. Translations come from a custom translation module, a CMS, or locale-suffixed metadata for very small cases.
How should multi-language URLs be structured?
A locale prefix such as `/de-at/products/x`, where the language drives translation and the country drives the Medusa region. It keeps every variant crawlable and gives each a distinct, canonical URL.
What is the difference between a region and a locale in Medusa?
A region is commercial — currency, tax rules, available payment and shipping methods. A locale is linguistic. One region can serve several locales, and one language can span several regions, so they should be modelled independently.
How do I implement hreflang correctly?
List every language variant of the page including a self-reference, make the annotations reciprocal across all variants, add an `x-default`, and only reference URLs that exist and return 200. A single broken reference invalidates the set.
Should I redirect users based on their IP address?
No. Automatic redirects frustrate travelling customers and prevent crawlers from reaching other variants. Show a dismissible suggestion instead and remember the choice in a cookie.
Where should product translations be stored?
In a dedicated translation module linked to products, or in a CMS that owns editorial content. Locale-suffixed metadata works for a two-language prototype but gives you no validation, no querying and no editorial interface.
Medusa Storefront SEO: Structured Data, Canonicals and Facets
Headless means you own every SEO decision, including the ones a hosted platform used to make. Metadata, Product schema, canonical rules and the faceted-navigation trap.
Integrating Medusa with Sanity: Who Owns Which Field
The hard part of a commerce-plus-CMS setup is not the sync. It is deciding which system owns each field — and then never letting both own one.
Medusa Multi-Region: Currencies, Tax and Selling Internationally
Regions carry currency, tax, payment methods and shipping. How to model international selling without accidentally creating twelve stores.


