Locale
View SourceIntroduction
This guide walks through setting up locale (language) support in a Phoenix app using Corex.
Locale is handled by a Plug that reads the locale from the URL segment, cookie, referer, or Accept-Language header.
For RTL (Right-to-Left) support (e.g. Arabic), see the RTL guide as a follow-up once locale is in place.
The Problem
You want to:
- Serve the app in multiple locales (e.g. English and Arabic) with the locale in the URL (
/en/...,/ar/...). - Persist the user’s choice (e.g. cookie) and respect browser language.
- When switching locale, keep the same logical path (e.g.
/en/accordion→/ar/accordion).
The Solution
Use a Locale plug that:
- Redirects requests without a valid locale (e.g.
/or/invalid/...) to a localized path. - Sets
conn.assigns.localeandconn.assigns.current_path(path without locale, for building switcher URLs). - Sets Gettext locale and a preferred-locale cookie.
Use shared LiveView hooks to assign locale and current_path from the URL on mount, and to handle the locale_change event so the locale switcher can redirect to the same path in another locale.
Set lang on the root <html> so the initial HTML and LiveView have the correct language.
Implementation
1. Gettext Setup
Configure your Gettext backend with a default locale and the list of locales you support. In lib/my_app_web/gettext.ex:
defmodule MyAppWeb.Gettext do
use Gettext.Backend,
otp_app: :my_app,
default_locale: "en",
locales: ~w(en ar)
endExtract translatable strings and create/update PO files for each locale:
mix gettext.extract
mix gettext.merge priv/gettext
mix gettext.extract updates the default POT file from your source. mix gettext.merge priv/gettext creates or updates .po files for each locale in the locales list. Add more locale codes to locales as needed (e.g. ~w(en ar fr)).
2. Create the Locale Plug
Create a plug that:
- Accepts requests where the first path segment is a valid locale (e.g.
/en/...,/ar/...). - Redirects all other requests to a localized URL using a determined locale (cookie, referer,
Accept-Language, or default). - Sets locale and path-without-locale in assigns and sets the Gettext locale.
Example (conceptually based on SetLocale); adapt module and backend to your app:
defmodule MyAppWeb.Plugs.Locale do
@moduledoc """
Sets the locale for the current request from the path, cookie, referer, or Accept-Language.
Assigns :locale and :current_path (path without locale segment).
"""
import Plug.Conn
@backend MyAppWeb.Gettext
@locales Gettext.known_locales(@backend)
@default_locale @backend.__gettext__(:default_locale)
@cookie_key "preferred_locale"
def init(opts), do: opts
@doc """
Returns the path without the locale prefix, for building locale-switch URLs.
## Examples
path_without_locale("/en/accordion", "en") # => "/accordion"
path_without_locale("/ar", "ar") # => "/"
"""
def path_without_locale(path, locale) when is_binary(path) and is_binary(locale) do
case String.replace_prefix(path, "/#{locale}", "") do
"" -> "/"
rest -> rest
end
end
def call(%{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
conn
|> set_locale(locale)
|> put_resp_cookie(@cookie_key, locale, max_age: 365 * 24 * 60 * 60)
end
def call(%{params: %{"locale" => invalid_locale}} = conn, _opts) do
locale = determine_locale(conn)
redirect_with_locale(conn, locale, strip_invalid_locale(conn.request_path, invalid_locale))
end
def call(conn, _opts) do
locale = determine_locale(conn)
redirect_with_locale(conn, locale, conn.request_path)
end
defp determine_locale(conn) do
conn.cookies[@cookie_key] ||
get_locale_from_referer(conn) ||
get_locale_from_accept_language(conn) ||
@default_locale
end
defp get_locale_from_referer(conn) do
case get_req_header(conn, "referer") do
[referer] when is_binary(referer) ->
referer
|> URI.parse()
|> Map.get(:path)
|> extract_locale_from_path()
|> validate_locale()
_ ->
nil
end
end
defp get_locale_from_accept_language(conn) do
conn
|> MyAppWeb.Plugs.Locale.Headers.extract_accept_language()
|> Enum.find(&(&1 in @locales))
end
defp extract_locale_from_path(path) when is_binary(path) do
case String.split(path, "/", parts: 3) do
["", maybe_locale | _] -> maybe_locale
_ -> nil
end
end
defp extract_locale_from_path(_), do: nil
defp validate_locale(locale) when locale in @locales, do: locale
defp validate_locale(_), do: nil
defp strip_invalid_locale(path, invalid_locale) do
String.replace_prefix(path, "/#{invalid_locale}", "")
end
defp redirect_with_locale(conn, locale, path) do
path = localize_path(path, locale)
path = preserve_query_string(conn, path)
conn
|> put_resp_cookie(@cookie_key, locale, max_age: 365 * 24 * 60 * 60)
|> Phoenix.Controller.redirect(to: path)
|> halt()
end
defp localize_path("/", locale), do: "/#{locale}"
defp localize_path(path, locale), do: "/#{locale}#{path}"
defp preserve_query_string(%{query_string: ""}, path), do: path
defp preserve_query_string(%{query_string: qs}, path), do: "#{path}?#{qs}"
defp set_locale(conn, locale) do
Gettext.put_locale(@backend, locale)
current_path = path_without_locale(conn.request_path, locale)
conn
|> assign(:locale, locale)
|> assign(:current_path, current_path)
end
defmodule Headers do
def extract_accept_language(conn) do
case Plug.Conn.get_req_header(conn, "accept-language") do
[value | _] ->
value
|> String.split(",")
|> Enum.map(&parse_language_option/1)
|> Enum.sort(&(&1.quality > &2.quality))
|> Enum.map(& &1.tag)
|> Enum.reject(&is_nil/1)
|> ensure_language_fallbacks()
_ ->
[]
end
end
defp parse_language_option(string) do
captures = Regex.named_captures(~r/^\s?(?<tag>[\w\-]+)(?:;q=(?<quality>[\d\.]+))?$/i, string)
quality =
case Float.parse(captures["quality"] || "1.0") do
{val, _} -> val
_ -> 1.0
end
%{tag: captures["tag"], quality: quality}
end
defp ensure_language_fallbacks(tags) do
Enum.flat_map(tags, fn tag ->
[language | _] = String.split(tag, "-")
if Enum.member?(tags, language), do: [tag], else: [tag, language]
end)
end
end
end3. Router: Locale in the Path
Use two scopes:
scope "/"– e.g. a singleget "/", PageController, :home. The Locale plug runs and redirects to/{locale}(or/{locale}/...if you later change the root to redirect to a default path).scope "/:locale"– all other routes (LiveView and controller). The first path segment isparams["locale"], so the plug sets assigns and does not redirect.
Add the plug to the browser pipeline:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug MyAppWeb.Plugs.Mode
plug MyAppWeb.Plugs.Locale
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
end
scope "/:locale", MyAppWeb do
pipe_through :browser
live_session :default, on_mount: [MyAppWeb.ModeLive, MyAppWeb.SharedEvents] do
live "/live/accordion", AccordionLive
# ... other live routes
end
get "/", PageController, :home
get "/accordion", PageController, :accordion_page
# ... other controller routes
endVisiting / triggers a redirect to /{locale} (e.g. /en or /ar). Visiting /en/accordion keeps the request in the /:locale scope and the plug sets locale and current_path.
4. Root Layout: lang
Set lang on the root <html> from assigns so the initial render and LiveView have the correct language:
<!DOCTYPE html>
<html lang={assigns[:locale] || "en"} data-theme="neo" data-mode={assigns[:mode]}>
...
</html>For dir (RTL support), see the RTL guide.
5. Shared LiveView Hooks (locale + current_path + locale_change)
LiveViews under /:locale need locale and current_path on the socket, and must handle the locale_change event so the locale switcher can change language without losing the current path.
Create a shared hook module that:
- On mount: assigns
localefromparams["locale"], andcurrent_pathfrom the request URI (usingpath_without_locale). - On
handle_params: updatescurrent_pathwhen the LiveView URL changes (e.g. patch). - On
handle_event("locale_change", ...): redirects to the new locale URL (e.g. value["/ar/accordion"]or build/ar+current_path).
Example:
defmodule MyAppWeb.SharedEvents do
@moduledoc "Event handlers and assigns shared on LiveView modules (locale, current_path, locale_change)."
use Phoenix.LiveView
def on_mount(:default, params, _session, socket) do
socket =
socket
|> assign(:locale, params["locale"] || "en")
|> assign_current_path(params)
|> attach_hook(:locale_change, :handle_event, &handle_locale_change/3)
|> attach_hook(:current_path, :handle_params, &assign_current_path_from_uri/3)
{:cont, socket}
end
defp assign_current_path(socket, params) do
locale = params["locale"] || "en"
path = get_connect_info(socket, :uri) |> path_from_uri()
current_path = MyAppWeb.Plugs.Locale.path_without_locale(path || "/#{locale}", locale)
assign(socket, :current_path, current_path)
end
defp assign_current_path_from_uri(_params, uri, socket) do
path = path_from_uri(uri)
locale = socket.assigns.locale
current_path = MyAppWeb.Plugs.Locale.path_without_locale(path || "/#{locale}", locale)
{:cont, assign(socket, :current_path, current_path)}
end
defp path_from_uri(nil), do: nil
defp path_from_uri(uri) when is_binary(uri), do: URI.parse(uri).path
defp path_from_uri(%URI{path: path}), do: path
defp handle_locale_change("locale_change", params, socket) do
value = params["value"] || params[:value] || []
path = params["path"] || params[:path] || socket.assigns[:current_path] || "/"
first = List.first(value)
to =
if first && String.starts_with?(first, "/") do
first
else
"/#{first || "en"}#{path}"
end
{:halt, redirect(socket, to: to)}
end
defp handle_locale_change(_event, _params, socket) do
{:cont, socket}
end
endAttach this in your live_session via on_mount: [MyAppWeb.ModeLive, MyAppWeb.SharedEvents] so every LiveView under that session gets locale, current_path, and the locale_change handler.
6. Layout: App and Locale Switcher
Your app layout should receive locale and current_path and pass them to a locale switcher. Controllers get these from conn.assigns; LiveViews get them from the shared hook.
App layout – add locale and current_path:
attr :locale, :string, default: nil, doc: "Current locale (from plug or LiveView assigns)."
attr :current_path, :string, default: "/", doc: "Path without locale segment, e.g. \"/accordion\", for the locale switcher."In the layout template, pass them to the switcher and use them for links:
<Layouts.app flash={@flash} mode={@mode} locale={@locale} current_path={@current_path}>
...
</Layouts.app>Locale switcher – use Corex <.select> with:
collection: options like[%{id: "/en" <> current_path, label: "English"}, %{id: "/ar" <> current_path, label: "العربية"}].value:["/#{@locale}#{@current_path}"].redirect: true so normal changes do a full navigation.on_value_change="locale_change"so the shared hook can run and redirect (e.g. to preserve path or handle server-driven redirect).
Example:
def locale_switcher(assigns) do
~H"""
<.select
id="locale-select"
class="select select--sm select--micro"
collection={[
%{id: "/en#{@current_path}", label: "English"},
%{id: "/ar#{@current_path}", label: "العربية"}
]}
value={["/#{@locale}#{@current_path}"]}
redirect
on_value_change="locale_change"
>
<:label class="sr-only">Language</:label>
<:item :let={item}>{item.label}</:item>
<:trigger>
<.icon name="hero-language" />
</:trigger>
<:item_indicator>
<.icon name="hero-check" />
</:item_indicator>
</.select>
"""
endWhen the user picks another locale, the select sends the new URL (e.g. /ar/accordion) as value; the shared locale_change handler redirects to it, the Locale plug runs, and the next page renders with the same logical path in the new locale.
7. Controllers and LiveViews: Passing locale and current_path
Controllers (under
scope "/:locale"): After the plug runs,conn.assignshas:localeand:current_path. Pass them into the layout when rendering:render(conn, :home, locale: conn.assigns.locale, current_path: conn.assigns.current_path)Your layout or page templates should use the same assign names (e.g.
@locale,@current_path) so the root layout and locale switcher work.LiveViews: With
SharedEventsinon_mount, each LiveView already has@localeand@current_path. Use them in the layout call:<Layouts.app flash={@flash} mode={@mode} locale={@locale} current_path={@current_path}>
Internal links should include the locale so they stay in the same language:
<.link navigate={~p"/#{@locale}/accordion"} class="link">Accordion</.link>Use path_without_locale/2 only when building switcher URLs (e.g. /en + current_path); for normal links, use @locale in the path.
For RTL (Right-to-Left) support (e.g. Arabic), see the RTL guide.
Summary
- Gettext: Configure backend with
default_localeandlocales; runmix gettext.extractandmix gettext.merge priv/gettextto create/update PO files. - Locale plug: Valid locale in path → set locale, current_path, Gettext, cookie; otherwise redirect to a localized URL using cookie/referer/Accept-Language/default.
- Router: Root
get "/"inscope "/"(plug redirects); all other routes underscope "/:locale"with the same browser pipeline. - Root layout:
<html lang={assigns[:locale] || "en"}>from assigns. - SharedEvents: On mount assign
localeandcurrent_path; on params updatecurrent_path; handle locale_change by redirecting to the chosen locale URL. - Layout: Pass
localeandcurrent_pathinto the app layout; locale switcher uses<.select>withredirectandon_value_change="locale_change"and options built fromcurrent_path. - Controllers: Pass
conn.assigns.localeandconn.assigns.current_pathinto the layout. - LiveViews: Rely on SharedEvents for
@localeand@current_path, and use them in the layout and in links (~p"/#{@locale}/...").
This gives URL-based locales, persistent preference, and path-preserving locale switch. For RTL support, see the RTL guide.