Locale Routing: the Language Lives in the URL

Copy Markdown View Source

PhoenixKit serves each language of a page at its own URL — /et/products, /ru/products — and sets the page language from that URL on every navigation. The default language may be served without a prefix when the site is configured that way. A session-based language is not supported, on purpose. This guide explains why, and how to move a session-locale app onto the supported shape.

If you arrived here because the language switcher's links "don't work", this is almost certainly the cause: the page is showing a language its URL does not carry. In development the switcher logs a warning that points here.

Single-language sites

With multi-language support off (Admin → Settings → Languages, the first card), the site is single-language and no URL carries a locale segment — /cart, /users/log-in, /admin, never /en/cart. There is nothing to switch to, so a segment would mean nothing. The site's language is its primary language, set on the same page ("Primary language & URLs", shown whether the switch is on or off; Settings → General links to it). Unset, it is config :phoenix_kit, default_locale (default "en-US"), read at runtime.

Kit routes redirect (302) a request carrying a locale segment to the same path without it — old /en/... links keep working. An admin URL renders as-is under the primary language, since both admin shapes route.

What this means for a host:

  • The segment-less route must exist. The redirect's target is the same path without the segment (/products), so a host that only declares /:locale/products — and reaches it through Routes.path/2 — has nothing to land on. Declare the unprefixed route (see "Migrating a session-locale app" below for the two-scope shape) or turn multi-language support on.
  • Link builders stop emitting the segment. Routes.path/2, Routes.locale_aware_path/2, Routes.home_path/2, Routes.admin_path/2 and Routes.localize_served_path/2 return the path without it, for any locale — including an explicit locale: "et".
  • :default_locale_redirect covers this redirect too. A host that opted into 301/308 for the default-locale redirect gets it for the single-language one (GET and HEAD); other methods always get a 307.

That redirect is done by the kit's locale plug, so it covers the kit's own routes only. A host that declares its own scope "/:locale" routes must either turn multi-language support on (the supported shape for a site served in several languages) or drop the segment itself. On a single-language site it can reuse the kit's plug for that — put the scope behind the kit's locale pipeline:

scope "/:locale", MyAppWeb do
  pipe_through [:browser, :phoenix_kit_locale_validation]
  live "/products", ProductLive.Index
end

phoenix_kit_routes() defines that pipeline, so the scope goes after the call. The plug reads path_params["locale"] and redirects /<code>/products to /products; with multi-language support on it validates the code instead, as it does for the kit's own routes. Two limits:

  • It looks for the segment right after the kit's url_prefix. With url_prefix: "/" that is any host scope's first segment; under url_prefix: "/phoenix_kit" a host /:locale scope is not found, and the page renders under the primary language without a redirect.
  • It treats whatever binds :locale as a locale, so reuse it only on a scope whose first segment is always one.

A multilingual site is unaffected: the primary language is prefixless only when "Primary language without prefix" is on, and every other language keeps its prefix. The first time multi-language support is switched on, that setting is turned on (unless it was ever saved), so a site that was single-language keeps its clean URLs.

Why the URL, not the session

  • A link must mean one thing. Someone sends a link to a colleague; the colleague must see the same language. With a session language the same URL shows different content to different people.
  • Search engines index URLs. hreflang and canonical links point at URLs, and crawlers send no cookies. A page whose language comes from the session is indexed in one language for everyone. Google's own guidance is to use a distinct URL per language rather than cookies or browser settings.
  • Caches and CDNs key on the URL. A session-varying page is either uncacheable or served in the wrong language.
  • Link previews fetch without cookies. Chat apps and social cards would always show the default language.
  • PhoenixKit tried it. A session fallback once existed and was removed after it overrode the language of URLs people had been sent.

The invariant is a distinct, stable URL per language. A subdomain per language satisfies it just as well as a path segment.

Migrating a session-locale app

  1. Put the locale in your routes. Wrap the localized pages in a locale scope, next to the unprefixed one if your default language is prefixless:

    scope "/", MyAppWeb do
      pipe_through :browser
      live "/products", ProductLive.Index   # default language
    end
    
    scope "/:locale", MyAppWeb do
      pipe_through :browser
      live "/products", ProductLive.Index   # every other language
    end
  2. Build links with the kit's helpers, so they keep the current language:

    PhoenixKit.Utils.Routes.path("/products")
    PhoenixKit.Utils.Routes.path("/products", locale: "et")
  3. Stop reading the language from the session when rendering a page. Let the URL decide; PhoenixKit's on_mount hooks set Gettext from it.

  4. Keep the preference for the landing page only. A saved preference or a cookie may choose where a bare / goes:

    def home(conn, _params) do
      locale = get_session(conn, :preferred_locale) || "en"
      redirect(conn, to: PhoenixKit.Utils.Routes.path("/", locale: locale))
    end

    Once a URL carries a locale, the URL wins — never consult the preference on a URL that already has one. (That is exactly the bug the removed session fallback had.) If the redirect depends on the visitor, don't let a shared cache store it.

  5. Switching language is navigation. The switcher links to the same page under the other locale. If you need your own switcher markup, build the links with PhoenixKitWeb.Components.Core.LanguageSwitcher.locale_path/2, which returns exactly what the component renders.

What is deliberately not offered

  • A "session locale mode".
  • A switcher hook that can produce the same URL for every language.

Both would make PhoenixKit carry two routing models, and every URL it builds — email links, sitemaps, social cards — would have to support both.