This guide covers writing ICU MessageFormat 2 (MF2) messages in Phoenix templates. Three tools are available, in increasing order of HEEx-specificity:
~tsigil fromLocalize.Message.Sigils— compile-time MF2 translation that returns aString.t(). Best when you only need plain text (no markup). Usable anywhere in Elixir code, not just templates.Localize.HTML.t/1macro — the recommended form for HEEx templates. Combines Gettext extraction, MF2 interpolation, and markup rendering in one call:{t("...")}. Supports inline markup such as{#bold}…{/bold}or{#link navigate=…}…{/link}.Localize.HTML.message/1function component — for cases where the MF2 source is dynamic (loaded from a database, etc.). Accepts a runtime:msgidattribute and:bindingsmap.
All three rely on a Gettext backend configured with Localize.Gettext.Interpolation.
Setup
1. Configure a Gettext backend with MF2 interpolation
defmodule MyApp.Gettext do
use Gettext.Backend,
otp_app: :my_app,
interpolation: Localize.Gettext.Interpolation
endWithout Localize.Gettext.Interpolation, MF2 placeholders like {$name} are returned literally because Gettext's default interpolation only recognises the %{name} form.
2. Opt the calling module into ~t and t/1
In a Phoenix app, the most common spot is the HTML helpers macro in MyAppWeb:
defmodule MyAppWeb do
def html_helpers do
quote do
use Phoenix.Component
import Localize.HTML
use Localize.Message.Sigils,
backend: MyApp.Gettext,
sigils: [domain: "messages"]
end
end
endEvery LiveView, component, and HTML module that does use MyAppWeb, :html now has ~t, the t/1 macro, and <.message> available.
use Localize.Message.Sigils accepts:
:backend— the Gettext backend module. Required.:sigils— a keyword list of sigil-level defaults::domain— default Gettext domain. The default is:default.:context— default Gettext message context. The default isnil.
The ~t sigil — plain-text translation
~t"…" rewrites Elixir #{expr} interpolations as MF2 {$name} placeholders, canonicalises the resulting message, registers it with Gettext for translation lookup, and evaluates the MF2 message at runtime.
def render(assigns) do
~H"""
<h1>{~t"Hello, #{@user.name}!"}</h1>
<p>{~t"You have #{count = length(@items)} item(s)"}</p>
"""
endAt compile time, ~t"Hello, #{@user.name}!" expands to roughly:
Gettext.Macros.dpgettext_with_backend(
MyApp.Gettext,
"messages",
nil,
"Hello, {$user_name}!",
%{user_name: @user.name}
)The .po msgid is the MF2-canonical form with {$name} placeholders, so translators can use MF2 features (selectors, formatters, plural categories) per-locale without changing the source code.
Binding key derivation
Binding names are derived from the interpolated expression:
| Source | Derived key |
|---|---|
#{name} | name |
#{@count} | count |
#{user.name} | user_name |
#{String.upcase(x)} | string_upcase |
#{total = a + b} | total |
The explicit key = expr form always wins and is the way to disambiguate when two expressions would derive the same key (the macro raises a compile error if you don't):
~t"#{a = String.upcase(first)} vs #{b = String.upcase(second)}"Identical expressions interpolated twice share a single binding:
~t"#{name} is #{name}"
# => msgid "{$name} is {$name}"Translator workflow
Run the standard Gettext extraction tasks:
mix gettext.extract
mix gettext.merge priv/gettext
The extracted msgids are valid MF2. A translator localising "Hello, {$user_name}!" into French may simply translate the text:
msgid "Hello, {$user_name}!"
msgstr "Bonjour, {$user_name} !"…or use MF2 features when the target language needs them, for example pluralisation:
msgid "You have {$count} item(s)"
msgstr ".input {$count :number}\n.match $count\n0 {{Aucun élément}}\n1 {{Un élément}}\n* {{{$count} éléments}}"The MF2 evaluator runs the translated string with the bindings supplied at the call site, so pluralisation rules can change per locale without code changes.
The t/1 macro — translations with markup (recommended)
The Localize.HTML.t/1 macro is the most ergonomic way to write translations in HEEx. It combines compile-time Gettext extraction, MF2 interpolation, and markup rendering in one call:
<h1>{t("Hello, #{@user.name}!")}</h1>
<p>{t("By signing up you accept our {#link navigate=|/terms|}terms{/link}.")}</p>
<p>{t("Read {#bold}#{@count} item(s){/bold}")}</p>At compile time, the macro:
- Walks Elixir
#{expr}interpolations and derives flat MF2 binding names (same rules as~t). - Rewrites the message as canonical MF2 source with
{$name}placeholders. - Emits a
Gettext.Macros.dpgettext_with_backend/5call somix gettext.extractpicks up the msgid.
At runtime:
- The Gettext lookup returns the translated MF2 source without stripping markup (via an internal sentinel that bypasses the markup-stripping interpolation path).
- The translated source is walked via
Localize.Message.format_to_safe_list/3. - Each markup node is dispatched to a registered component (defaults documented under
<.message>below).
Differences from ~t
| Feature | ~t | t/1 |
|---|---|---|
| Returns | String.t() | Phoenix.HTML.safe() |
| Use site | Anywhere | HEEx {...} interpolation |
| MF2 markup | Stripped | Rendered as HEEx via the registry |
Bindings from @assign | Yes (compile-time AST walk) | Yes (HEEx rewrites @x to assigns.x before the macro sees it, and the assigns prefix is stripped from the derived binding name) |
Use ~t when you need a plain string outside of HEEx, e.g. for error messages, page titles assigned to other variables, etc. Use t/1 everywhere else inside templates.
Options
t/2 accepts a keyword-list second argument:
{t("Hello, #{@name}!", locale: :fr)}
{t("Click {#link navigate=|/x|}here{/link}", components: %{"link" => &my_link/1})}:localeoverridesLocalize.get_locale/0for this render only.:componentsis a per-call markup component override (same shape as<.message>'s).
The <.message> component — for dynamic msgids
MF2 supports inline markup tags. Examples:
Please {#bold}read{/bold} the {#link href=|/terms|}terms{/terms}.
Line one{#br/}line two.Localize.Message.format/3 strips these tags. <Localize.HTML.message /> preserves them by walking Localize.Message.format_to_safe_list/3 and dispatching each markup node to a renderer.
<Localize.HTML.message
msgid={~t"Read the #{document = "terms"} before clicking {#bold}Accept{/bold}"}
/>Or with attributes directly:
<Localize.HTML.message
msgid="Visit {#link href=|/home|}home{/link}"
/>Attributes
:msgid— the MF2 message string. Required.:bindings— a map of variable bindings for MF2 placeholders. The default is%{}. When:msgidcomes from~t, leave this empty —~tinjects the bindings into the gettext call.:locale— a locale name or language tag. The default isLocalize.get_locale/0.:components— a map of%{markup_name => renderer_fun}overriding defaults and app config for this render only. The default is%{}.
Default markup renderers
| MF2 tag | HEEx output |
|---|---|
bold, strong | <strong>…</strong> |
italic, emphasis, em | <em>…</em> |
code | <code>…</code> |
link | Phoenix <.link> — accepts href, navigate, or patch MF2 attributes |
br (standalone) | <br> |
All literal text and binding values are HTML-escaped automatically.
The link renderer uses Phoenix.Component's <.link>, so translators can
emit same-app navigation by writing navigate or patch in the MF2 source:
{#link navigate=|/dashboard|}dashboard{/link}
{#link patch=|/list?tag=urgent|}urgent items{/link}
{#link href=|https://example.com|}external{/link}The renderer falls back to href="#" when none of the three attributes
is present. <.link> also rejects unsafe javascript: and data:
destinations at runtime.
Adding custom markup tags
Markup-name lookups consult three sources in order:
The component's
:componentsattribute (per-call override).config :localize_web, :mf2_markup, components: %{…}(app-wide).Built-in defaults from
Localize.HTML.Message.default_components/0.
A renderer is a function of one argument %{attrs: map, children: safe} that returns either a Phoenix.LiveView.Rendered.t() (the recommended form, produced by ~H) or a Phoenix.HTML.safe() value ({:safe, iodata}). The children value is already rendered and HTML-escaped — pass it straight through, wrap it, or ignore it, but do not escape it again.
Recommended form, using ~H:
defmodule MyAppWeb.MF2 do
use Phoenix.Component
def user(%{attrs: %{"id" => id}, children: children}) do
assigns = %{id: id, children: children}
~H"""
<span class={"user-#{@id}"}>{@children}</span>
"""
end
end
config :localize_web, :mf2_markup,
components: %{"user" => &MyAppWeb.MF2.user/1}Raw form, using {:safe, iodata}:
fn %{attrs: %{"id" => id}, children: {:safe, child_iodata}} ->
{:safe,
[~s(<span class="user-), to_string(id), ~s(">), child_iodata, "</span>"]}
endTo start from the defaults and add your own:
config :localize_web, :mf2_markup,
components: Map.merge(
Localize.HTML.Message.default_components(),
%{"user" => &MyAppWeb.MF2.user/1}
)Unknown tags raise
If a translator writes {#weird}…{/weird} and weird isn't registered, the component raises Localize.HTML.Message.UnknownMarkupError listing the tag and the registered tag names. This is intentional — silent fallbacks hide translator mistakes.
Choosing between ~t, t/1, and <.message>
Inside HEEx templates → use
t/1. It handles both plain text and inline markup, extracts to.po, and renders markup correctly.Outside HEEx (error messages, dynamic strings, anywhere that needs a
String.t()) → use~t. Note that markup is stripped —~tis text-only.MF2 source loaded at runtime (from a database, user content, etc.) → use
<.message msgid={...} bindings={...} />. Bypasses Gettext extraction (the msgid isn't a literal) but supports the same markup-rendering pipeline.
A complete example
defmodule MyAppWeb.TermsLive do
use MyAppWeb, :live_view
def render(assigns) do
~H"""
<header>
<h1>{t("Welcome, #{@user.name}")}</h1>
</header>
<section>
<p>{t("By signing up you accept our {#link navigate=|/terms|}terms{/link} and {#link navigate=|/privacy|}privacy policy{/link}.")}</p>
</section>
<p>{t("You have #{count = @notification_count} new notification(s)")}</p>
"""
end
endAfter mix gettext.extract, the .pot file contains:
msgid "Welcome, {$user_name}"
msgstr ""
msgid "By signing up you accept our {#link href=|/terms|}terms{/link} and {#link href=|/privacy|}privacy policy{/link}."
msgstr ""
msgid "You have {$count} new notification(s)"
msgstr ""The third msgid is a natural fit for MF2 selectors in the translation — number-aware pluralisation is the translator's job, not the developer's.