Crystallize logo

Languages, markets and routing in a storefront

Put the language, and with it the market, in the URL. Crystallize paths are translated, so the same item has a different path in each language, and the storefront joins them on the item id. This page covers routing, hreflang, the language switcher and redirects for renamed paths.

Language and market in the path

Start every storefront URL with a segment that decides the language and the market: /en, /no, /sv. The market decides currency, price variant and VAT rate, so one table in your code maps each segment to all of them:

export const MARKET = {
  en: { currency: 'EUR', priceVariant: 'default', vat: 0.21, locale: 'en-IE', hreflang: 'en' },
  no: { currency: 'NOK', priceVariant: 'nok', vat: 0.25, locale: 'nb-NO', hreflang: 'nb' },
  sv: { currency: 'SEK', priceVariant: 'sek', vat: 0.25, locale: 'sv-SE', hreflang: 'sv' },
} as const;

Why the path and not a cookie: crawlers and link previews don't send cookies, and neither do most AI agents. With a cookie market they all see the default market's prices, and your structured data has to say so. With the market in the path, every market's prices are a URL that can be indexed, shared and quoted.

Only the bare root (/) needs a decision. Redirect it from a saved choice, then Accept-Language, then your default language:

import { NextResponse, type NextRequest } from 'next/server';

const LANGS = ['en', 'no', 'sv'];

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (LANGS.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return NextResponse.next();
  const cookie = request.cookies.get('lang')?.value;
  const accepted = (request.headers.get('accept-language') ?? '').toLowerCase();
  const fromHeader = LANGS.find((l) => accepted.startsWith(l) || (l === 'no' && /^(nb|nn)/.test(accepted)));
  const lang = LANGS.includes(cookie ?? '') ? cookie : (fromHeader ?? 'en');
  request.nextUrl.pathname = `/${lang}${pathname === '/' ? '' : pathname}`;
  return NextResponse.redirect(request.nextUrl);
}

// Leave the API, Next internals, files and icon routes alone, or they redirect to /en/…
export const config = { matcher: ['/((?!_next|api|favicon.ico|icon|apple-icon|.*\\..*).*)'] };

Translated paths

When an item has a name in several languages, Crystallize generates a path per language. They differ, and they change when an item is renamed. Two rules follow:

  • Join languages on `itemId`, never on the slug. Read the item's path in every language with one aliased Discovery query (see Request path). Folders and products are matched the same way.
  • Look up by the full path. A slug is unique only within its folder.

hreflang and canonicals

  • The canonical is the item's own path in the current language, without filter or sort parameters.
  • hreflang lists the same item in every language it is published in, plus x-default (usually your main language).
  • Use BCP 47 codes in hreflang and `<html lang>`. A tenant language code is not always one. Norwegian Bokmål is nb, even when the tenant language and your URL segment are no.
export function alternates(paths: Partial<Record<'en' | 'no' | 'sv', string>>, lang: 'en' | 'no' | 'sv') {
  const abs = (p: string) => `https://your-store.example${p}`;
  return {
    canonical: abs(paths[lang]!),
    languages: {
      ...Object.fromEntries(Object.entries(paths).map(([l, p]) => [MARKET[l as 'en'].hreflang, abs(p!)])),
      'x-default': abs(paths.en ?? paths[lang]!),
    },
  };
}

Here paths holds /{lang}{path} for every language the aliased query found.

Renamed paths

Renaming an item gives it a new path and keeps the old one in its path history (history on the hit). The path argument of search and browse matches canonical paths, aliases, history and shortcuts, unless you restrict it with pathResolutionMethod. When a page is not found by its canonical path, look the URL up that way. If the hit's path differs from the requested one, answer with a permanent redirect to the canonical path. Keep history paths out of the sitemap.

A shortcut places an item in a second folder under a second path. Give the page a canonical to the item's own path so the two URLs don't compete.

The language switcher

The switcher links to the same item in the other languages, from the same aliased query. When the item is not published in a language, link to that language's front page instead. In Next.js, fetch the target page before navigating (router.prefetch, then router.push), so the new language's shell doesn't flash blank.

Content that isn't translated

Discovery returns an item in a language when it is published in that language, even if some fields were never translated. Test for the content you need (a name, a description) rather than for a hit, and fall back to your main language for that block, or leave the language out of hreflang.

See also: Multilingual and Paths in the PIM docs, and Markets for market-specific prices.