diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml
index 65f3826c84c..92dee7e0319 100644
--- a/.github/workflows/dispatch_release.yml
+++ b/.github/workflows/dispatch_release.yml
@@ -147,6 +147,10 @@ on:
description: "reflex-hosting-cli"
type: boolean
default: false
+ reflex_i18n:
+ description: "reflex-i18n"
+ type: boolean
+ default: false
reflex_otel:
description: "reflex-otel"
type: boolean
@@ -209,6 +213,7 @@ jobs:
${{ inputs.reflex_components_sonner && 'reflex-components-sonner' || '' }}
${{ inputs.reflex_docgen && 'reflex-docgen' || '' }}
${{ inputs.reflex_hosting_cli && 'reflex-hosting-cli' || '' }}
+ ${{ inputs.reflex_i18n && 'reflex-i18n' || '' }}
${{ inputs.reflex_otel && 'reflex-otel' || '' }}
${{ inputs.reflex_release && 'reflex-release' || '' }}
${{ inputs.reflex_workflow && 'reflex-workflow' || '' }}
diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py
index 14f407d03a4..05abfa6a601 100644
--- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py
+++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py
@@ -127,6 +127,7 @@ def get_sidebar_items_backend():
database,
enterprise,
events,
+ i18n,
state,
state_structure,
utility_methods,
@@ -183,6 +184,12 @@ def get_sidebar_items_backend():
client_storage.overview,
],
),
+ create_item(
+ "Internationalization",
+ children=[
+ i18n.overview,
+ ],
+ ),
create_item(
"Database",
children=[
diff --git a/docs/i18n/overview.md b/docs/i18n/overview.md
new file mode 100644
index 00000000000..3b54e067b92
--- /dev/null
+++ b/docs/i18n/overview.md
@@ -0,0 +1,360 @@
+```python exec
+import reflex as rx
+```
+
+# Internationalization (i18n)
+
+Reflex supports translating your app into multiple languages through the
+[`reflex-i18n`](https://pypi.org/project/reflex-i18n/) package. It covers the
+two kinds of text in an app:
+
+- **Static content** — literal strings in your components (labels, buttons,
+ headings), translated on the client with `rx.t(...)`.
+- **Dynamic content** — strings produced by your state (messages, formatted
+ values), translated on the server with `gettext`.
+
+Only the visitor's active language is ever sent to the browser, so adding more
+locales does not bloat what each user downloads.
+
+## Installation
+
+```bash
+pip install reflex-i18n
+```
+
+Then enable it by adding the `I18nPlugin` to your `rxconfig.py`, listing the
+locales you support:
+
+```python
+import reflex as rx
+from reflex_i18n import I18nPlugin
+
+config = rx.Config(
+ app_name="myapp",
+ plugins=[
+ I18nPlugin(locales=["en", "de", "fr"], default_locale="en"),
+ ],
+)
+```
+
+`default_locale` is the language your source strings are written in. Translations
+live in `.po` files under `locales/` (configurable with `catalog_dir=`).
+
+## Static content with `rx.t`
+
+Wrap literal component strings in `rx.t`. The text you pass is the message in
+your default locale; at runtime it is looked up in the active locale's catalog,
+falling back to the original text when a translation is missing.
+
+```python
+def index():
+ return rx.vstack(
+ rx.heading(rx.t("Welcome")),
+ rx.button(rx.t("Sign in")),
+ )
+```
+
+### Interpolating values
+
+Use `{name}` placeholders and pass the values as keyword arguments. Values may
+be plain data **or state vars** — they interpolate on the client, so they stay
+reactive:
+
+```python
+class State(rx.State):
+ name: str = "Ada"
+
+
+def greeting():
+ return rx.text(rx.t("Hello, {name}!", name=State.name))
+```
+
+### Plurals
+
+Pass a `plural` form and a `count`. The correct form is chosen using the active
+locale's plural rules, and `count` is also available as the `{count}`
+placeholder:
+
+```python
+class CartState(rx.State):
+ items: int = 1
+
+
+def cart_label():
+ return rx.text(rx.t("{count} item", plural="{count} items", count=CartState.items))
+```
+
+A message is either singular or plural: gettext keys catalog entries by their
+msgid, so using the same text both with and without `plural` raises an error.
+Give one of them a `context` (below) if you need both.
+
+### Disambiguating with context
+
+When the same source text needs different translations in different places,
+give it a `context`:
+
+```python
+rx.t("Open", context="verb") # "to open something"
+rx.t("Open", context="status") # "currently open"
+```
+
+## Dynamic content with `gettext`
+
+For text generated in your state, import `gettext` (conventionally aliased `_`)
+and call it wherever you build the string. It translates into the current
+visitor's locale.
+
+The best place is a **computed var**: it re-runs and re-sends the translated
+string automatically when the locale changes.
+
+```python
+from reflex_i18n import gettext as _
+
+
+class DashboardState(rx.State):
+ unread: int = 0
+
+ @rx.var
+ def status(self) -> str:
+ return _("You have {n} new messages").format(n=self.unread)
+```
+
+You can also translate at the moment you produce a one-off message, such as in
+an event handler:
+
+```python
+from reflex_i18n import gettext as _, ngettext
+
+
+class OrderState(rx.State):
+ message: str = ""
+
+ @rx.event
+ def checkout(self):
+ self.message = _("Order confirmed")
+
+ @rx.event
+ def summarize(self, n: int):
+ self.message = ngettext("{n} order", "{n} orders", n).format(n=n)
+```
+
+`ngettext(singular, plural, n)` handles plurals and `pgettext(context, message)`
+handles context, mirroring `rx.t`.
+
+Note: translate at render time (in a computed var) or at the moment you emit a
+message. A translated string stored in a plain var earlier will not
+re-translate on its own when the locale changes.
+
+## Formatting numbers and dates
+
+Numbers, currencies, percentages and dates are formatted differently per locale
+(`1,234.5` vs `1.234,5`, `7/18/2026` vs `18.07.2026`). There are two ways to
+format, mirroring static vs dynamic content.
+
+**In components (client-side).** `rx.i18n.number` / `currency` / `percent` /
+`date` / `time` / `datetime` format a value in the browser using `Intl`, and
+reformat instantly when the locale changes:
+
+```python
+import datetime
+
+
+class PriceState(rx.State):
+ quantity: float = 3.5
+ total: float = 41.9
+ tax_rate: float = 0.19
+ created: datetime.datetime = datetime.datetime.now(datetime.timezone.utc)
+
+
+def price_row():
+ return rx.hstack(
+ rx.text(rx.i18n.number(PriceState.quantity, max_fraction_digits=2)),
+ rx.text(rx.i18n.currency(PriceState.total, "EUR")),
+ rx.text(rx.i18n.percent(PriceState.tax_rate, max_fraction_digits=1)),
+ rx.text(rx.i18n.date(PriceState.created, length="long")),
+ )
+```
+
+Curated options — `min_fraction_digits`, `max_fraction_digits`, `grouping`,
+`compact`, and `length` (`"short"`/`"medium"`/`"long"`/`"full"`) — cover the
+common cases; pass `options={...}` for raw
+[`Intl`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat)
+options.
+
+**In state (server-side).** Use the `format_*` helpers, e.g. when building a
+translated string. Like `gettext`, a computed var using one reformats when the
+locale changes:
+
+```python
+from reflex_i18n import gettext as _, format_currency
+
+
+class CartState(rx.State):
+ total: float = 0.0
+
+ @rx.var
+ def summary(self) -> str:
+ return _("Total: {amount}").format(amount=format_currency(self.total, "EUR"))
+```
+
+`rx.i18n.locale` exposes the active locale as a var, e.g. to drive
+`rx.moment(PriceState.created, locale=rx.i18n.locale)`.
+
+Two caveats:
+
+- **Naive datetimes client-side** are shown in the visitor's local time zone (a
+ datetime with no offset has no absolute meaning). For a fixed zone, use a
+ timezone-aware datetime, pass `options={"timeZone": "..."}`, or format
+ server-side with `format_datetime`. (Plain dates and times are unambiguous and
+ render correctly.)
+- Python's `f"{value:,}"` formatting on a numeric var stays `en-US` by design;
+ use `rx.i18n.number` for locale-aware output.
+
+## Detecting and switching the locale
+
+On a visitor's first load, the locale is negotiated from their browser's
+`Accept-Language`, falling back to `default_locale`. Once they pick a language
+it is remembered in a cookie.
+
+Switch languages with `rx.i18n.set_locale`, which updates both static and
+dynamic content:
+
+```python
+def language_switcher():
+ return rx.hstack(
+ rx.button("English", on_click=rx.i18n.set_locale("en")),
+ rx.button("Deutsch", on_click=rx.i18n.set_locale("de")),
+ )
+```
+
+Static (`rx.t`) content updates instantly; dynamic (state) content updates on
+the next server round-trip.
+
+`rx.i18n.language_switcher()` is a prebuilt switcher with one `` per locale.
+Here it switches static and dynamic content just like `set_locale`, and becomes
+a set of real crawlable links once URL routing is on (below).
+
+Locales are BCP 47 language tags written with hyphens (`en`, `pt-BR`,
+`zh-Hant`) — they double as URL segments and `Intl` tags, so the underscore
+spelling is rejected.
+
+## URL-based locales & SEO
+
+The cookie-based default keeps every language on the same URL. That's ideal for
+apps (dashboards, authenticated tools), but bad for **SEO**: search engines see
+only the default language, because the locale lives in a cookie/state rather
+than the URL.
+
+For public sites, opt into **URL-based locales** by giving the plugin a routing
+strategy:
+
+```python
+from reflex_i18n import I18nPlugin, PathPrefixRouting
+
+config = rx.Config(
+ app_name="mysite",
+ deploy_url="https://mysite.com", # used for absolute hreflang hrefs
+ plugins=[
+ I18nPlugin(
+ locales=["en", "de", "fr"],
+ default_locale="en",
+ routing=PathPrefixRouting(), # /de/..., /fr/...
+ )
+ ],
+)
+```
+
+You write each page **once**; the plugin fans it out into a concrete route per
+locale. With the default `PathPrefixRouting(default_at_root=True)`:
+
+- `/pricing` serves the default locale, `/de/pricing` and `/fr/pricing` the
+ others (use `default_at_root=False` to prefix every locale).
+- Each route is prerendered and carries reciprocal
+ `` (plus `x-default` and `canonical`) in
+ its ``, so all languages are discoverable and indexable.
+- The locale comes from the **URL**, not a cookie — so each language URL renders
+ its own content for crawlers.
+
+Here the switcher's links become real per-locale URLs (crawlers follow them
+instead of a cookie swap); you can also build your own with
+`rx.i18n.locale_url`:
+
+```python
+rx.i18n.language_switcher() # prebuilt links
+rx.link("Deutsch", href=rx.i18n.locale_url("de", "/pricing"))
+```
+
+Notes:
+
+- Page `title`/`meta` translated with `rx.t` still resolve in the default
+ locale (the document head is outside the per-route locale provider); prefer
+ per-route titles if that matters.
+- `default_at_root=True` (the default) is recommended for SEO: the default
+ locale keeps clean canonical URLs (`/pricing`). With `default_at_root=False`
+ every locale is prefixed; visiting an unprefixed path redirects to its
+ default-locale address (`/pricing` → `/en/pricing`), and its canonical link
+ points there.
+- App routes must not start with a configured locale (e.g. a `/de/...` page of
+ your own while `de` is configured): those URLs are reserved for the generated
+ locale routes, and compilation fails with a clear error.
+- Each locale route prerenders that language's content, `hreflang` and
+ `canonical`. The `` and `dir` attributes are applied on hydration:
+ the document root is app-wide (`rx.App(html_lang=...)`) and cannot yet vary
+ per route.
+
+## Translation catalogs
+
+Translations are standard gettext `.po` files, one per locale, under `locales/`:
+
+```text
+locales/
+ en.po
+ de.po
+ fr.po
+```
+
+A catalog entry pairs the source text (`msgid`) with its translation
+(`msgstr`):
+
+```po
+msgid "Welcome"
+msgstr "Willkommen"
+
+msgid "{count} item"
+msgid_plural "{count} items"
+msgstr[0] "{count} Artikel"
+msgstr[1] "{count} Artikel"
+```
+
+You don't write these by hand — the CLI generates and maintains them.
+
+## The `reflex i18n` CLI
+
+Once the plugin is configured, three commands manage your catalogs:
+
+```bash
+# Scan the app for rx.t and gettext calls and update every locale's .po file
+# (new messages added, translations preserved, removed ones marked obsolete).
+reflex i18n extract
+
+# Create a fresh catalog for a new locale.
+reflex i18n init es
+
+# Fail (non-zero exit) if any non-default locale has untranslated or fuzzy
+# messages (the default locale holds the source msgids). Useful as a CI check.
+reflex i18n check
+```
+
+A typical workflow: run `reflex i18n extract` after adding or changing strings,
+fill in the `msgstr` values in each locale's `.po` file, and add
+`reflex i18n check` to CI to catch missing translations.
+
+## How it works
+
+- `rx.t` compiles to a client-side lookup. At build time Reflex generates one
+ small JavaScript catalog per locale, and the browser loads **only the active
+ language** on demand.
+- `gettext` runs on the server against the locale resolved for the current
+ client, so no translation data for other languages is sent to the browser.
+- Missing translations always fall back to the source text, so an incomplete
+ catalog degrades gracefully rather than showing blank strings.
diff --git a/examples/ruff.toml b/examples/ruff.toml
index 41393b91a35..8c33feb588b 100644
--- a/examples/ruff.toml
+++ b/examples/ruff.toml
@@ -32,6 +32,7 @@ reflex_components_react_player.msg = "Examples import Reflex only as `import ref
reflex_components_recharts.msg = "Examples import Reflex only as `import reflex as rx`."
reflex_components_sonner.msg = "Examples import Reflex only as `import reflex as rx`."
reflex_docgen.msg = "Examples import Reflex only as `import reflex as rx`."
+reflex_i18n.msg = "Examples import Reflex only as `import reflex as rx`."
reflex_otel.msg = "Examples import Reflex only as `import reflex as rx`."
reflex_release.msg = "Examples import Reflex only as `import reflex as rx`."
reflex_site_shared.msg = "Examples import Reflex only as `import reflex as rx`."
diff --git a/news/6796.bugfix.md b/news/6796.bugfix.md
new file mode 100644
index 00000000000..826d6463462
--- /dev/null
+++ b/news/6796.bugfix.md
@@ -0,0 +1 @@
+Hot reload no longer crashes in `_mark_dirty_computed_vars` when a computed var on a surviving state depends on a state defined in the reloaded module: dependency edges pointing at reloaded states are now purged from every remaining state instead of being left to dangle.
diff --git a/news/6796.feature.md b/news/6796.feature.md
new file mode 100644
index 00000000000..6d38d9a5a2f
--- /dev/null
+++ b/news/6796.feature.md
@@ -0,0 +1 @@
+Added internationalization support through the new `reflex-i18n` package, exposed under `rx.i18n`: translate static component content with `rx.t` and dynamic state content server-side with `rx.i18n.gettext`/`ngettext`/`pgettext`, and configure locales with `rx.i18n.I18nPlugin` in `rx.Config(plugins=[...])`. With `reflex-i18n` installed, `reflex i18n` manages the translation catalogs.
diff --git a/packages/reflex-base/news/6796.feature.md b/packages/reflex-base/news/6796.feature.md
new file mode 100644
index 00000000000..53c5b675b98
--- /dev/null
+++ b/packages/reflex-base/news/6796.feature.md
@@ -0,0 +1 @@
+Added three extension points (used by `reflex-i18n`, available to any package): `register_event_scope_provider` wraps each event's handler execution and delta resolution in an ambient context that is re-derived after the handler runs (e.g. the active locale), which plain middleware cannot do; `register_implicit_dependency` marks functions whose use inside a computed var getter implies a dependency on a var the getter never reads directly (e.g. a gettext helper reading the active locale from a contextvar); and a plugin's `expand_routes` hook (`reflex_base.plugins.ExpandRoutesContext`) can register extra pages derived from the app's own, e.g. one route per locale. The first two are no-ops with negligible cost when nothing is registered.
diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py
index 7d13b83e437..012b50af185 100644
--- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py
+++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py
@@ -22,6 +22,7 @@
from reflex_base.event.context import EventContext
from reflex_base.event.processor.event_processor import EventProcessor, EventQueueEntry
from reflex_base.event.processor.future import EventFuture
+from reflex_base.event.processor.scope import event_scope
from reflex_base.registry import RegisteredEventHandler
from reflex_base.utils.form import form_data_as_dict, transform_form_data
from reflex_base.utils.format import format_event_handler
@@ -260,13 +261,17 @@ async def chain_updates(
if root_state is not None:
# Emit deltas first, so any frontend events are processed with the
- # latest state. The clean deliberately runs after resolution: the
+ # latest state. Enter the event scope again here (after the handler
+ # ran): a provider may derive its context from state the handler just
+ # changed (e.g. the locale), and computed vars are recomputed during
+ # delta resolution. The clean deliberately runs after resolution: the
# SharedState fan-out captures its dirty vars at clean time, and
# resolving the delta is what re-marks linked vars through the patch
# machinery, so cleaning earlier would fan out a stale set (see
# tests/integration/test_linked_state.py).
try:
- delta = await root_state._get_resolved_delta()
+ async with event_scope(root_state):
+ delta = await root_state._get_resolved_delta()
if delta:
await ctx.emit_delta(delta)
finally:
@@ -592,18 +597,22 @@ async def _execute_event(
substate = await state.get_state(registered_handler.states[0])
root_state = state._get_root_state()
- if needs_to_rehydrate:
- await self._rehydrate(root_state)
-
- # Process non-background events while holding the lock.
- if not registered_handler.handler.is_background:
- await process_event(
- handler=registered_handler.handler,
- payload=event.payload,
- state=substate,
- root_state=root_state,
- )
- return
+ # Enter any per-event ambient context (e.g. i18n locale) around
+ # handler execution. A no-op unless a feature registered a scope
+ # provider, so apps without one pay effectively nothing.
+ async with event_scope(root_state):
+ if needs_to_rehydrate:
+ await self._rehydrate(root_state)
+
+ # Process non-background events while holding the lock.
+ if not registered_handler.handler.is_background:
+ await process_event(
+ handler=registered_handler.handler,
+ payload=event.payload,
+ state=substate,
+ root_state=root_state,
+ )
+ return
# Otherwise drop the state lock and start processing the background task
# with a proxy state. No root_state: the lock is no longer held, and
# under a shared state tree (opportunistic locking, in-memory manager)
@@ -613,18 +622,21 @@ async def _execute_event(
# background task's own state changes are emitted (and cleaned) by its
# `async with self` context exits, which re-acquire the lock.
proxy = StateProxy(substate)
+ # The scope releases the root state once entered, so it pins no tree.
+ scope = event_scope(root_state)
# Leave the tree loaded under the lock to the proxy, which replaces it on
# `async with self`. A manager like redis loads a fresh tree for every
# lock, so these locals would pin an extra copy for the whole task.
del state, substate, root_state
handler_error: BaseException | None = None
try:
- await process_event(
- handler=registered_handler.handler,
- state=proxy,
- payload=event.payload,
- root_state=None,
- )
+ async with scope:
+ await process_event(
+ handler=registered_handler.handler,
+ state=proxy,
+ payload=event.payload,
+ root_state=None,
+ )
except BaseException as ex:
handler_error = ex
raise
diff --git a/packages/reflex-base/src/reflex_base/event/processor/scope.py b/packages/reflex-base/src/reflex_base/event/processor/scope.py
new file mode 100644
index 00000000000..ced810772d0
--- /dev/null
+++ b/packages/reflex-base/src/reflex_base/event/processor/scope.py
@@ -0,0 +1,95 @@
+"""Per-event ambient context providers (e.g. i18n locale).
+
+A provider yields a context manager entered around both handler execution and
+delta resolution; it is called fresh at each phase, so state a handler changed
+is re-read. Middleware can't do this (separate pre/post calls). No registered
+providers means a ``nullcontext`` fast path.
+"""
+
+from __future__ import annotations
+
+import contextlib
+from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from types import TracebackType
+
+ from reflex.state import BaseState
+
+# A provider takes the root state and returns (async) a synchronous context
+# manager to enter for the current event phase.
+EventScopeProvider = Callable[
+ ["BaseState"], Awaitable[contextlib.AbstractContextManager[None]]
+]
+
+_providers: list[EventScopeProvider] = []
+
+
+def register_event_scope_provider(provider: EventScopeProvider) -> None:
+ """Register a per-event ambient-context provider.
+
+ Args:
+ provider: Async callable from root state to a context manager.
+ """
+ _providers.append(provider)
+
+
+def has_event_scope_providers() -> bool:
+ """Whether any event-scope providers are registered.
+
+ Returns:
+ True if at least one provider is registered.
+ """
+ return bool(_providers)
+
+
+class _EventScope:
+ """Async context manager entering every registered provider's scope."""
+
+ __slots__ = ("_root_state", "_stack")
+
+ def __init__(self, root_state: BaseState):
+ self._root_state = root_state
+ self._stack = contextlib.ExitStack()
+
+ async def __aenter__(self) -> None:
+ # Drop the reference so a background task's scope does not pin the tree
+ # its proxy replaces on `async with self`.
+ root_state = self._root_state
+ del self._root_state
+ # ``async with`` only calls ``__aexit__`` if ``__aenter__`` returns, so
+ # a provider raising mid-loop would otherwise leak the contexts already
+ # entered (e.g. an earlier provider's contextvar token).
+ try:
+ for provider in _providers:
+ self._stack.enter_context(await provider(root_state))
+ except BaseException:
+ self._stack.close()
+ raise
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> bool | None:
+ # Forwarded so a provider's context manager can observe (and suppress)
+ # an exception raised by the handler or by delta resolution.
+ return self._stack.__exit__(exc_type, exc_value, traceback)
+
+
+def event_scope(
+ root_state: BaseState,
+) -> contextlib.AbstractAsyncContextManager[None]:
+ """Ambient-context scope for processing an event.
+
+ Args:
+ root_state: The client's root state instance.
+
+ Returns:
+ A scope entering every provider, or a no-op when none are registered.
+ """
+ if not _providers:
+ return contextlib.nullcontext()
+ return _EventScope(root_state)
diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py
index c82a36d559f..cdde8beea42 100644
--- a/packages/reflex-base/src/reflex_base/plugins/__init__.py
+++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py
@@ -18,6 +18,7 @@
from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin
from .base import (
CommonContext,
+ ExpandRoutesContext,
Plugin,
PostBuildContext,
PostCompileContext,
@@ -45,6 +46,7 @@
"CompilerHooks",
"ComponentAndChildren",
"EmbedPlugin",
+ "ExpandRoutesContext",
"PageContext",
"PageDefinition",
"Plugin",
@@ -78,6 +80,7 @@
"_screenshot": [("ScreenshotPlugin", "_ScreenshotPlugin")],
"base": [
"CommonContext",
+ "ExpandRoutesContext",
"Plugin",
"PostBuildContext",
"PostCompileContext",
diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py
index 3f52afe48ee..dbb542e3f83 100644
--- a/packages/reflex-base/src/reflex_base/plugins/base.py
+++ b/packages/reflex-base/src/reflex_base/plugins/base.py
@@ -106,6 +106,12 @@ class RegisterRouteContext(CommonContext):
has_app_page: Callable[[str], bool]
+class ExpandRoutesContext(RegisterRouteContext):
+ """Context for ``expand_routes``: adds the app's registered ``pages``."""
+
+ pages: Sequence["UnevaluatedPage"]
+
+
class PostCompileContext(CommonContext):
"""Context for post-compile hooks."""
@@ -194,6 +200,16 @@ def register_route(self, **context: Unpack[RegisterRouteContext]) -> None:
context: The route registration context.
"""
+ def expand_routes(self, **context: Unpack[ExpandRoutesContext]) -> None:
+ """Contribute pages derived from the app's already-registered pages.
+
+ Runs after ``register_route`` with the same staged ``add_page``; the
+ context adds ``pages`` (the app's registered pages) to fan out from.
+
+ Args:
+ context: The route-expansion context.
+ """
+
def pre_compile(self, **context: Unpack[PreCompileContext]) -> None:
"""Called before the compilation of the plugin.
diff --git a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py
index 733b5274820..ac24220de70 100644
--- a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py
+++ b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py
@@ -9,6 +9,7 @@
import importlib
import inspect
import sys
+from collections.abc import Callable, Iterable
from types import CellType, CodeType, FunctionType, MemberDescriptorType, ModuleType
from typing import TYPE_CHECKING, Any, ClassVar, cast
@@ -22,6 +23,26 @@
CellEmpty = object()
+# Functions whose mere use inside a computed var getter implies a dependency on
+# a var, even though the getter never reads that var directly (e.g. a gettext
+# helper that reads the active locale from a contextvar). Maps the function to a
+# provider returning the implied dependency Var, or None when inactive.
+_implicit_dependency_providers: dict[object, Callable[[], Var | None]] = {}
+
+
+def register_implicit_dependency(
+ funcs: Iterable[object], provider: Callable[[], Var | None]
+) -> None:
+ """Register functions that imply a computed-var dependency when referenced.
+
+ Args:
+ funcs: The functions to detect (matched by object identity).
+ provider: Returns the implied dependency Var, or None when the
+ dependency is not currently applicable.
+ """
+ for func in funcs:
+ _implicit_dependency_providers[func] = provider
+
def get_cell_value(cell: CellType) -> Any:
"""Get the value of a cell object.
@@ -118,6 +139,14 @@ class DependencyTracker:
_get_var_value_positions: dis.Positions | None = dataclasses.field(default=None)
_last_import_name: str | None = dataclasses.field(default=None)
+ # A module loaded by the previous instruction, so `pkg.mod.helper` can be
+ # resolved attribute by attribute (a code object has no globals of its own).
+ _attr_base: ModuleType | None = dataclasses.field(default=None)
+ # Globals and closure of the enclosing function when ``func`` is a nested
+ # code object (comprehension, lambda), which carries neither itself.
+ _globals: dict[str, Any] | None = dataclasses.field(default=None)
+ _closure: dict[str, Any] | None = dataclasses.field(default=None)
+
INVALID_NAMES: ClassVar[list[str]] = ["parent_state", "substates", "get_substate"]
def __post_init__(self):
@@ -198,6 +227,10 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None:
if not self.top_of_stack:
return
target_obj = self.get_tracked_local(self.top_of_stack)
+ if isinstance(target_obj, ModuleType):
+ # `mod.helper` on an inline-imported module.
+ self._load_module_attr(target_obj, instruction.argval)
+ return
try:
target_state = assert_base_state(target_obj, local_name=self.top_of_stack)
except VarValueError:
@@ -230,6 +263,39 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None:
instruction.argval
)
+ def _load_module_attr(self, module: ModuleType, name: str) -> None:
+ """Resolve an attribute of a module; detect helpers, follow submodules.
+
+ Args:
+ module: The module on top of the stack.
+ name: The attribute being loaded.
+ """
+ obj = getattr(module, name, None)
+ self._add_implicit_dependency(obj)
+ self._attr_base = obj if isinstance(obj, ModuleType) else None
+
+ def _add_implicit_dependency(self, obj: object) -> None:
+ """Record an implied dependency if ``obj`` is a registered function.
+
+ Args:
+ obj: The object a load instruction resolved to (may be anything).
+ """
+ if not _implicit_dependency_providers:
+ return
+ try:
+ provider = _implicit_dependency_providers.get(obj)
+ except TypeError:
+ return # unhashable objects can't be registered functions
+ if provider is None:
+ return
+ dep_var = provider()
+ if dep_var is None:
+ return
+ var_data = dep_var._get_all_var_data()
+ if var_data is None or not var_data.state:
+ return
+ self.dependencies.setdefault(var_data.state, set()).add(var_data.field_name)
+
def _get_globals(self) -> dict[str, Any]:
"""Get the globals of the function.
@@ -237,7 +303,7 @@ def _get_globals(self) -> dict[str, Any]:
The var names and values in the globals of the function.
"""
if isinstance(self.func, CodeType):
- return {}
+ return self._globals or {}
return self.func.__globals__ # pyright: ignore[reportAttributeAccessIssue]
def _get_closure(self) -> dict[str, Any]:
@@ -247,7 +313,7 @@ def _get_closure(self) -> dict[str, Any]:
The var names and values in the closure of the function.
"""
if isinstance(self.func, CodeType):
- return {}
+ return self._closure or {}
return {
var_name: get_cell_value(cell)
for var_name, cell in zip(
@@ -415,6 +481,8 @@ def _populate_dependencies(self) -> None:
define comprehensions or nested functions that may reference "self".
"""
for instruction in dis.get_instructions(self.func):
+ # Only the instruction right after a module load continues its chain.
+ attr_base, self._attr_base = self._attr_base, None
if self.scan_status == ScanStatus.GETTING_STATE:
self.handle_getting_state(instruction)
elif self.scan_status == ScanStatus.GETTING_STATE_POST_AWAIT:
@@ -432,29 +500,43 @@ def _populate_dependencies(self) -> None:
)
and instruction.argval in self.tracked_locals
):
+ # A tracked local may be an inline-imported function that
+ # implies a dependency (e.g. a gettext helper).
+ self._add_implicit_dependency(self.tracked_locals[instruction.argval])
# bytecode loaded the class instance to the top of stack, next load instruction
# is referencing an attribute on self
self.top_of_stack = instruction.argval
self.scan_status = ScanStatus.GETTING_ATTR
- elif (
- instruction.opname
- in (
- "LOAD_FAST_LOAD_FAST",
- "LOAD_FAST_BORROW_LOAD_FAST_BORROW",
- "STORE_FAST_LOAD_FAST",
- )
- and instruction.argval[-1] in self.tracked_locals
+ elif instruction.opname in (
+ "LOAD_FAST_LOAD_FAST",
+ "LOAD_FAST_BORROW_LOAD_FAST_BORROW",
+ "STORE_FAST_LOAD_FAST",
):
- # Double LOAD_FAST family instructions load multiple values onto the stack,
- # the last value in the argval list is the top of the stack.
- self.top_of_stack = instruction.argval[-1]
- self.scan_status = ScanStatus.GETTING_ATTR
+ # Every loaded name (the first one is a store, not a load, in
+ # STORE_FAST_LOAD_FAST) may be an inline-imported function
+ # that implies a dependency, same as the single-load case.
+ loaded_from = 1 if instruction.opname == "STORE_FAST_LOAD_FAST" else 0
+ for name in instruction.argval[loaded_from:]:
+ if name in self.tracked_locals:
+ self._add_implicit_dependency(self.tracked_locals[name])
+ if instruction.argval[-1] in self.tracked_locals:
+ # Double LOAD_FAST family instructions load multiple values onto the stack,
+ # the last value in the argval list is the top of the stack.
+ self.top_of_stack = instruction.argval[-1]
+ self.scan_status = ScanStatus.GETTING_ATTR
elif self.scan_status == ScanStatus.GETTING_ATTR and instruction.opname in (
"LOAD_ATTR",
"LOAD_METHOD",
):
self.load_attr_or_method(instruction)
self.top_of_stack = None
+ elif attr_base is not None and instruction.opname in (
+ "LOAD_ATTR",
+ "LOAD_METHOD",
+ ):
+ # `pkg.mod.helper(...)`: walk the attribute chain off a module
+ # so a module-qualified helper reference is detected too.
+ self._load_module_attr(attr_base, instruction.argval)
elif instruction.opname == "LOAD_CONST" and isinstance(
instruction.argval, CodeType
):
@@ -465,8 +547,28 @@ def _populate_dependencies(self) -> None:
func=instruction.argval,
state_cls=self.state_cls,
tracked_locals=self.tracked_locals,
+ _globals=self._get_globals(),
+ _closure=self._get_closure(),
)
)
+ elif (
+ instruction.opname == "LOAD_GLOBAL"
+ and self.scan_status == ScanStatus.SCANNING
+ ):
+ # A referenced global may be a function that implies a
+ # dependency (e.g. a gettext helper reading the active locale),
+ # or a module such a helper is then loaded from.
+ obj = self._get_globals().get(instruction.argval)
+ self._add_implicit_dependency(obj)
+ self._attr_base = obj if isinstance(obj, ModuleType) else None
+ elif (
+ instruction.opname == "LOAD_DEREF"
+ and self.scan_status == ScanStatus.SCANNING
+ ):
+ # Same as above for a closure-captured reference.
+ obj = self._get_closure().get(instruction.argval)
+ self._add_implicit_dependency(obj)
+ self._attr_base = obj if isinstance(obj, ModuleType) else None
elif instruction.opname == "IMPORT_NAME":
if instruction.argval is None:
continue
diff --git a/packages/reflex-i18n/README.md b/packages/reflex-i18n/README.md
new file mode 100644
index 00000000000..e48a4fbf561
--- /dev/null
+++ b/packages/reflex-i18n/README.md
@@ -0,0 +1,39 @@
+# reflex-i18n
+
+Internationalization (i18n) for [Reflex](https://reflex.dev) apps.
+
+- `rx.t(...)` — translate static component strings, resolved client-side from
+ per-locale catalogs (only the active locale is shipped to the client).
+- `gettext` / `ngettext` / `pgettext` — translate dynamic (state) content
+ server-side; translated computed vars retranslate automatically on a locale
+ switch.
+- `I18nPlugin` — configure locales and wire compilation.
+
+```python
+import reflex as rx
+from reflex_i18n import I18nPlugin, t, gettext as _
+
+
+class State(rx.State):
+ @rx.var
+ def greeting(self) -> str:
+ return _("Hello")
+
+
+def index():
+ return rx.text(t("Welcome"))
+
+
+app = rx.App()
+app.add_page(index)
+```
+
+```python
+# rxconfig.py
+config = rx.Config(
+ app_name="myapp",
+ plugins=[I18nPlugin(locales=["en", "de"], default_locale="en")],
+)
+```
+
+Translations live in `locales/{locale}.po`.
diff --git a/packages/reflex-i18n/news/6796.feature.md b/packages/reflex-i18n/news/6796.feature.md
new file mode 100644
index 00000000000..d64d82d3adc
--- /dev/null
+++ b/packages/reflex-i18n/news/6796.feature.md
@@ -0,0 +1 @@
+New `reflex-i18n` package providing internationalization for Reflex apps: `rx.t` for translating static component text, server-side `gettext`/`ngettext`/`pgettext` for dynamic state content, an `I18nPlugin`/`I18nConfig` to configure available locales, an `I18nState` whose active locale is backed by a cookie, and a `reflex i18n` CLI for extracting message catalogs and compiling `.po`/`.mo` files via Babel.
diff --git a/packages/reflex-i18n/pyproject.toml b/packages/reflex-i18n/pyproject.toml
new file mode 100644
index 00000000000..2b9a2c7959d
--- /dev/null
+++ b/packages/reflex-i18n/pyproject.toml
@@ -0,0 +1,38 @@
+[project]
+name = "reflex-i18n"
+dynamic = ["version"]
+description = "Internationalization (i18n) for Reflex apps."
+license.text = "Apache-2.0"
+readme = "README.md"
+authors = [{ name = "Reflex", email = "maintainers@reflex.dev" }]
+maintainers = [{ name = "Reflex", email = "maintainers@reflex.dev" }]
+requires-python = ">=3.11"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Programming Language :: Python :: 3.15",
+]
+dependencies = [
+ # TODO: Bump to the released versions before publishing.
+ "reflex-base >= 0.10.1.dev0",
+ "reflex >= 0.10.1.dev0",
+ "babel >= 2.14.0,<3.0",
+]
+
+[tool.hatch.version]
+source = "uv-dynamic-versioning"
+
+[tool.uv-dynamic-versioning]
+pattern-prefix = "reflex-i18n-"
+fallback-version = "0.0.0dev0"
+bump = true
+
+[tool.hatch.build]
+artifacts = ["_web/**"]
+
+[build-system]
+requires = ["hatchling", "uv-dynamic-versioning"]
+build-backend = "hatchling.build"
diff --git a/packages/reflex-i18n/src/reflex_i18n/__init__.py b/packages/reflex-i18n/src/reflex_i18n/__init__.py
new file mode 100644
index 00000000000..bc571644a4c
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/__init__.py
@@ -0,0 +1,107 @@
+"""Internationalization (i18n) for Reflex apps.
+
+Static (component) content is translated with :func:`t`; dynamic (state)
+content is translated server-side with :func:`gettext` / :func:`ngettext` /
+:func:`pgettext`. Configure locales with :class:`I18nPlugin` in
+``rx.Config(plugins=[...])``.
+"""
+
+from typing import TYPE_CHECKING, Any
+
+from .config import LOCALE_COOKIE_NAME, I18nConfig
+
+# date/datetime/time use the alias form and are omitted from __all__ so
+# `from reflex_i18n import *` cannot shadow the stdlib names; they remain
+# available as rx.i18n.date / .time / .datetime (prefer attribute access).
+from .format import currency, number, percent
+from .format import date as date
+from .format import datetime as datetime
+from .format import time as time
+from .nav import language_switcher, locale_url
+from .plugin import I18nPlugin
+from .routing import PathPrefixRouting
+from .runtime import (
+ format_currency,
+ format_date,
+ format_datetime,
+ format_decimal,
+ format_number,
+ format_percent,
+ format_time,
+ gettext,
+ ngettext,
+ pgettext,
+)
+from .vars import t
+
+if TYPE_CHECKING:
+ from reflex_base.vars.sequence import StringVar
+
+ from .state import I18nState, set_locale
+
+ # The active locale as a client-side var; resolved lazily (see below).
+ locale: StringVar
+
+# gettext alias, the conventional shorthand for marking translatable strings.
+_ = gettext
+
+# "date", "datetime" and "time" are intentionally omitted (see the import
+# above): reachable as rx.i18n.date/.time/.datetime but excluded from
+# `import *` so they cannot shadow the stdlib names. "locale" is omitted for a
+# different reason: `import *` resolves every exported name, and that one only
+# builds once an I18nPlugin is configured.
+__all__ = [
+ "LOCALE_COOKIE_NAME",
+ "I18nConfig",
+ "I18nPlugin",
+ "I18nState",
+ "PathPrefixRouting",
+ "currency",
+ "format_currency",
+ "format_date",
+ "format_datetime",
+ "format_decimal",
+ "format_number",
+ "format_percent",
+ "format_time",
+ "gettext",
+ "language_switcher",
+ "locale_url",
+ "ngettext",
+ "number",
+ "percent",
+ "pgettext",
+ "set_locale",
+ "t",
+]
+
+# Importing ``.state`` registers ``I18nState`` as a substate (a global side
+# effect). Defer it so merely importing this package (e.g. the reflex CLI
+# loading the ``reflex i18n`` command) does not attach i18n state to apps
+# that never use i18n; opting in via ``I18nPlugin`` or accessing these names
+# does.
+_LAZY_STATE_ATTRS = frozenset({"I18nState", "set_locale"})
+
+
+def __getattr__(name: str) -> Any:
+ """Lazily resolve state-registering attributes.
+
+ Args:
+ name: The attribute name.
+
+ Returns:
+ The resolved attribute.
+
+ Raises:
+ AttributeError: If the attribute is not part of the public API.
+ """
+ if name in _LAZY_STATE_ATTRS:
+ from . import state
+
+ return getattr(state, name)
+ if name == "locale":
+ from .format import _locale_var
+
+ return _locale_var()
+ msg = f"module {__name__!r} has no attribute {name!r}"
+ raise AttributeError(msg)
diff --git a/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js
new file mode 100644
index 00000000000..78a5f697132
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js
@@ -0,0 +1,478 @@
+import {
+ createContext,
+ createElement,
+ Fragment,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { useHref, useLocation, useNavigate } from "react-router";
+
+import {
+ cookieName,
+ defaultAtRoot,
+ defaultLocale,
+ defaultPlural,
+ deployUrl,
+ loaders,
+ locales,
+ urlRouting,
+} from "$/i18n/index.js";
+
+// gettext msgctxt separator; catalog keys are "context\u0004msgid".
+const CONTEXT_SEPARATOR = "\u0004";
+
+// Language subtags written right-to-left.
+const RTL_LANGUAGES = new Set([
+ "ar",
+ "arc",
+ "ckb",
+ "dv",
+ "fa",
+ "he",
+ "ks",
+ "ps",
+ "sd",
+ "ug",
+ "ur",
+ "yi",
+]);
+
+const stripContext = (key) => {
+ const index = key.indexOf(CONTEXT_SEPARATOR);
+ return index === -1 ? key : key.slice(index + 1);
+};
+
+const interpolate = (message, params) =>
+ message.replace(/\{(\w+)\}/g, (match, name) =>
+ params && name in params ? String(params[name]) : match,
+ );
+
+const readCookie = (name) => {
+ const match = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith(name + "="));
+ return match ? decodeURIComponent(match.split("=")[1]) : undefined;
+};
+
+const writeCookie = (name, value) => {
+ const secure = location.protocol === "https:" ? "; secure" : "";
+ document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax${secure}`;
+};
+
+// Match a requested locale list against the supported locales: exact tag
+// first, then primary-language prefix (e.g. "de-AT" -> "de"). Like the
+// server's negotiate_locale, tags match case-insensitively (RFC 5646) and
+// resolve to the configured spelling.
+const negotiate = (requested) => {
+ const byTag = new Map();
+ const byLanguage = new Map();
+ for (const supported of locales) {
+ const tag = supported.toLowerCase();
+ byTag.set(tag, supported);
+ const language = tag.split("-")[0];
+ if (!byLanguage.has(language)) {
+ byLanguage.set(language, supported);
+ }
+ }
+ for (const tag of requested) {
+ const lower = tag.toLowerCase();
+ const match = byTag.get(lower) ?? byLanguage.get(lower.split("-")[0]);
+ if (match !== undefined) {
+ return match;
+ }
+ }
+ return undefined;
+};
+
+const initialLocale = () => {
+ const fromCookie = readCookie(cookieName);
+ if (fromCookie && locales.includes(fromCookie)) {
+ return fromCookie;
+ }
+ return (
+ negotiate(navigator.languages ?? [navigator.language]) ?? defaultLocale
+ );
+};
+
+export const I18nContext = createContext({
+ locale: defaultLocale,
+ catalog: undefined,
+ setLocale: () => {},
+});
+
+// The mounted provider's setter, so a Reflex event (run_script) can switch
+// the locale without threading context through the calling component.
+let _switchLocale = null;
+
+export function switchLocale(locale, options) {
+ if (_switchLocale) {
+ _switchLocale(locale, options);
+ }
+}
+
+// Publish a setter as the one `switchLocale` (a Reflex event) drives, without
+// clobbering another provider's on unmount.
+const usePublishedSetter = (setLocale) => {
+ useEffect(() => {
+ _switchLocale = setLocale;
+ return () => {
+ if (_switchLocale === setLocale) {
+ _switchLocale = null;
+ }
+ };
+ }, [setLocale]);
+};
+
+// Switch locale by navigating to the current page's address in the target
+// locale, keeping the query string and fragment.
+const useLocaleNavigate = () => {
+ const { pathname, search, hash } = useLocation();
+ const navigate = useNavigate();
+ return useCallback(
+ (nextLocale, options) =>
+ navigate(
+ localizePath(delocalizePath(pathname), nextLocale) + search + hash,
+ options,
+ ),
+ [pathname, search, hash, navigate],
+ );
+};
+
+export function I18nProvider({ children, onLocaleChange }) {
+ const { pathname } = useLocation();
+ const navigateLocale = useLocaleNavigate();
+ // The compiled event binding (cookie mode only) may change identity between
+ // renders; reading it through a ref keeps setLocale stable.
+ const onLocaleChangeRef = useRef(onLocaleChange);
+ onLocaleChangeRef.current = onLocaleChange;
+ // Cookie mode starts from the default locale so the server/first render is
+ // deterministic and never touches document/navigator; the cookie- and
+ // browser-based locale is resolved client-side in the effect below.
+ const [cookieLocale, setCookieLocale] = useState(defaultLocale);
+ // With URL routing the path is authoritative (mirroring the server), so
+ // the cookie and browser preferences never override what the URL names.
+ const locale = urlRouting
+ ? (pathLocale(pathname) ?? defaultLocale)
+ : cookieLocale;
+ const [catalog, setCatalog] = useState(undefined);
+
+ useEffect(() => {
+ if (!urlRouting) {
+ setCookieLocale(initialLocale());
+ }
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ loaders[locale]()
+ .then((module_) => {
+ if (!cancelled) {
+ setCatalog(module_);
+ }
+ })
+ .catch((error) => {
+ // A failed chunk load (e.g. a stale hashed chunk after a redeploy)
+ // falls back to the source msgids rather than keeping the previous
+ // locale's catalog. Surface it instead of an unhandled rejection.
+ if (!cancelled) {
+ setCatalog(undefined);
+ }
+ console.error(
+ `Failed to load i18n catalog for locale "${locale}".`,
+ error,
+ );
+ });
+ // In URL mode this locale equals the route's, so setting /dir
+ // here agrees with LocaleRoute and also covers unwrapped (default-at-root)
+ // pages.
+ const root = document.documentElement;
+ root.lang = locale;
+ root.dir = RTL_LANGUAGES.has(locale.split("-")[0]) ? "rtl" : "ltr";
+ return () => {
+ cancelled = true;
+ };
+ }, [locale]);
+
+ const setLocale = useCallback(
+ (nextLocale, { notifyServer = true } = {}) => {
+ if (!locales.includes(nextLocale)) {
+ console.error(
+ `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`,
+ );
+ return;
+ }
+ if (urlRouting) {
+ // The URL owns the locale in this mode: switch by navigating to the
+ // localized address (the cookie is not consulted).
+ navigateLocale(nextLocale);
+ return;
+ }
+ // The cookie is the source of truth for a chosen locale; only an
+ // explicit choice writes it, so browser-preference changes keep
+ // applying for users who never picked a language.
+ writeCookie(cookieName, nextLocale);
+ setCookieLocale(nextLocale);
+ // Keep the server's locale (server-side translations) in step. A caller
+ // that already sends I18nState.set_locale itself opts out.
+ if (notifyServer) {
+ onLocaleChangeRef.current?.(nextLocale);
+ }
+ },
+ [navigateLocale],
+ );
+
+ usePublishedSetter(setLocale);
+
+ return createElement(
+ I18nContext.Provider,
+ { value: { locale, catalog, setLocale } },
+ children,
+ );
+}
+
+// Cached Intl formatter instances, keyed by locale + serialized options, so a
+// list of many formatted values reuses one formatter per (locale, options).
+const _numberFormatters = new Map();
+const _dateFormatters = new Map();
+
+// In normal use the key set is small (locales x compile-time option sets), but
+// runtime-varying options (a Var in `options=`) could grow it, so cap the cache
+// and evict the oldest entry (Map preserves insertion order).
+const FORMATTER_CACHE_LIMIT = 100;
+
+const getFormatter = (cache, Ctor, locale, options) => {
+ const key = locale + " " + JSON.stringify(options ?? {});
+ let formatter = cache.get(key);
+ if (formatter === undefined) {
+ formatter = new Ctor(locale, options);
+ if (cache.size >= FORMATTER_CACHE_LIMIT) {
+ cache.delete(cache.keys().next().value);
+ }
+ cache.set(key, formatter);
+ }
+ return formatter;
+};
+
+// Turn a value into a Date, normalizing Python's str(date/datetime/time):
+// trim microseconds to milliseconds; parse a date-only value as local midnight
+// (not UTC, which would shift the day in negative offsets); anchor a bare time
+// to the epoch date so Intl can format it (new Date("14:30:00") is invalid).
+const toDate = (value) => {
+ if (value instanceof Date) return value;
+ if (typeof value === "number") return new Date(value);
+ const s = String(value).replace(/(\.\d{3})\d+/, "$1");
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return new Date(`${s}T00:00:00`);
+ if (/^\d{2}:\d{2}/.test(s)) return new Date(`1970-01-01T${s}`);
+ return new Date(s.replace(" ", "T"));
+};
+
+export function useFormat() {
+ const { locale } = useContext(I18nContext);
+ const formatNumber = useCallback(
+ (value, options) =>
+ getFormatter(
+ _numberFormatters,
+ Intl.NumberFormat,
+ locale,
+ options,
+ ).format(value),
+ [locale],
+ );
+ const formatDate = useCallback(
+ (value, options) =>
+ getFormatter(
+ _dateFormatters,
+ Intl.DateTimeFormat,
+ locale,
+ options,
+ ).format(toDate(value)),
+ [locale],
+ );
+ return [formatNumber, formatDate];
+}
+
+export function useLocale() {
+ return useContext(I18nContext).locale;
+}
+
+export function useTranslation() {
+ const { catalog } = useContext(I18nContext);
+
+ const t_ = useCallback(
+ (key, params) => {
+ const message = catalog?.messages.get(key) ?? stripContext(key);
+ return interpolate(message, params);
+ },
+ [catalog],
+ );
+
+ const tp_ = useCallback(
+ (key, pluralMessage, count, params) => {
+ const entry = catalog?.messages.get(key);
+ // Uncataloged messages fall back to the two source strings, picked by
+ // the DEFAULT locale's plural rule (they are written in that language).
+ const message = Array.isArray(entry)
+ ? (entry[catalog.plural(count)] ?? entry[entry.length - 1])
+ : defaultPlural(count) === 0
+ ? stripContext(key)
+ : pluralMessage;
+ return interpolate(message, params);
+ },
+ [catalog],
+ );
+
+ return [t_, tp_];
+}
+
+// --- URL-based locale routing (opt-in via I18nPlugin(routing=...)) ---
+
+// Provide a fixed locale + statically-imported catalog to a route's subtree.
+// The catalog is a static import (bundled with the route chunk), so the right
+// language is present synchronously during prerender.
+export function LocaleRoute({ locale, catalog, children }) {
+ const navigateLocale = useLocaleNavigate();
+ // Its own navigate-backed setter rather than the I18nProvider's: a page that
+ // uses neither rx.t nor a formatting var never pulls the provider in.
+ const setLocale = useCallback(
+ (nextLocale) => {
+ if (!locales.includes(nextLocale)) {
+ console.error(
+ `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`,
+ );
+ return;
+ }
+ navigateLocale(nextLocale);
+ },
+ [navigateLocale],
+ );
+ usePublishedSetter(setLocale);
+ useEffect(() => {
+ const root = document.documentElement;
+ root.lang = locale;
+ root.dir = RTL_LANGUAGES.has(locale.split("-")[0]) ? "rtl" : "ltr";
+ }, [locale]);
+ return createElement(
+ I18nContext.Provider,
+ { value: { locale, catalog, setLocale } },
+ children,
+ );
+}
+
+// Path-prefix helpers (mirror reflex_i18n.routing.PathPrefixRouting).
+const pathLocale = (pathname) => {
+ const head = pathname.replace(/^\//, "").split("/")[0];
+ return locales.includes(head) ? head : undefined;
+};
+
+const delocalizePath = (pathname) => {
+ const [head, ...rest] = pathname.replace(/^\//, "").split("/");
+ if (locales.includes(head)) {
+ return "/" + rest.join("/");
+ }
+ return pathname.startsWith("/") ? pathname : "/" + pathname;
+};
+
+const localizePath = (base, locale) => {
+ if (locale === defaultLocale && defaultAtRoot) {
+ return base;
+ }
+ return base === "/" ? "/" + locale : "/" + locale + base;
+};
+
+const absoluteUrl = (path) => {
+ const base = (deployUrl || "").replace(/\/$/, "");
+ return base ? base + path : path;
+};
+
+// React Router paths (useLocation, navigate) are relative to the router
+// basename (Reflex's `frontend_path`); raw / hrefs are not, so they
+// need the basename prepended. useHref("/") is the basename with a trailing "/".
+const useBasename = () => useHref("/").replace(/\/$/, "");
+
+// Emit reciprocal + canonical for the current
+// route. Rendered as an app-wrap so it applies to every page; React hoists the
+// links into and they prerender into the static HTML.
+export function HreflangLinks({ children }) {
+ const { pathname } = useLocation();
+ const basename = useBasename();
+ const navigateLocale = useLocaleNavigate();
+ // When every locale is prefixed (defaultAtRoot off), an unprefixed URL has
+ // no locale of its own; send it to its default-locale address. This wrap is
+ // on every page with URL routing, unlike the provider.
+ useEffect(() => {
+ if (!defaultAtRoot && pathLocale(pathname) === undefined) {
+ navigateLocale(defaultLocale, { replace: true });
+ }
+ }, [pathname, navigateLocale]);
+ const base = delocalizePath(pathname);
+ const href = (locale) => absoluteUrl(basename + localizePath(base, locale));
+ const links = locales.map((locale) =>
+ createElement("link", {
+ key: locale,
+ rel: "alternate",
+ hrefLang: locale,
+ href: href(locale),
+ }),
+ );
+ links.push(
+ createElement("link", {
+ key: "x-default",
+ rel: "alternate",
+ hrefLang: "x-default",
+ href: href(defaultLocale),
+ }),
+ createElement("link", {
+ key: "canonical",
+ rel: "canonical",
+ // The localized URL for the page's own locale: identical to pathname
+ // except for an unprefixed page when every locale is prefixed, whose
+ // canonical is its default-locale address.
+ href: href(pathLocale(pathname) ?? defaultLocale),
+ }),
+ );
+ // This is an app-wrap: render the links AND pass the app content through.
+ return createElement(Fragment, null, ...links, children);
+}
+
+// A language switcher: one per locale. With URL routing these are real
+// links to the current page in each locale, so crawlers follow them and the
+// URL stays the source of truth; without it there is only one URL per page, so
+// they set the locale through the provider (and cookie) instead.
+export function LanguageSwitcher(props) {
+ const { pathname, search, hash } = useLocation();
+ const basename = useBasename();
+ const { locale: active, setLocale } = useContext(I18nContext);
+ const base = delocalizePath(pathname);
+ // Keep the query string and fragment: switching language must not drop the
+ // filters or the anchor the visitor is on. The clean canonical URL for each
+ // locale is emitted by HreflangLinks, so crawlers still see one per page.
+ const rest = search + hash;
+ return createElement(
+ "nav",
+ props,
+ ...locales.map((locale) =>
+ createElement(
+ "a",
+ {
+ key: locale,
+ href:
+ basename +
+ (urlRouting ? localizePath(base, locale) : pathname) +
+ rest,
+ hrefLang: locale,
+ "aria-current": locale === active ? "true" : undefined,
+ onClick: urlRouting
+ ? undefined
+ : (event) => {
+ event.preventDefault();
+ setLocale(locale);
+ },
+ },
+ locale,
+ ),
+ ),
+ );
+}
diff --git a/packages/reflex-i18n/src/reflex_i18n/catalog.py b/packages/reflex-i18n/src/reflex_i18n/catalog.py
new file mode 100644
index 00000000000..0ea6db53642
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/catalog.py
@@ -0,0 +1,208 @@
+"""Compile ``.po`` catalogs into the per-locale JS modules served to clients."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Sequence
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from reflex_base.utils import console
+
+from .config import LOCALE_COOKIE_NAME, I18nConfig
+from .registry import MessageKey
+
+if TYPE_CHECKING:
+ from babel.messages.catalog import Catalog
+
+# C plural expressions from the Plural-Forms header are (almost) valid JS;
+# whitelist their tokens before embedding one in generated code.
+_PLURAL_EXPR_ALLOWED = re.compile(r"^[ n0-9()%?:!<>=&|+*/-]+$")
+_FALLBACK_PLURAL_EXPR = "n != 1"
+
+_GENERATED_HEADER = "// Generated by Reflex. Do not edit.\n"
+
+
+def read_po_catalog(path: Path, locale: str | None = None) -> Catalog:
+ """Parse a ``.po`` catalog file.
+
+ Args:
+ path: The path of the ``.po`` file.
+ locale: The catalog's locale, supplying CLDR plural rules for a file
+ whose header declares none.
+
+ Returns:
+ The parsed catalog.
+ """
+ from babel.messages.pofile import read_po
+
+ with path.open("r", encoding="utf-8") as po_file:
+ try:
+ return read_po(po_file, locale=locale and locale.replace("-", "_"))
+ except ValueError:
+ # A tag Babel has no CLDR data for; the catalog's own header (or
+ # the generic rule) decides the plural forms. read_po builds the
+ # Catalog before reading, so nothing was consumed.
+ return read_po(po_file)
+
+
+def _plural_expr_js(catalog: Catalog | None, locale: str) -> str:
+ """Get the catalog's plural expression as a safe JS expression.
+
+ Args:
+ catalog: The parsed catalog, if one exists for the locale.
+ locale: The locale being compiled, for warnings.
+
+ Returns:
+ The validated plural expression over the variable ``n``.
+ """
+ if catalog is None:
+ return _FALLBACK_PLURAL_EXPR
+ expr = catalog.plural_expr
+ if not _PLURAL_EXPR_ALLOWED.match(expr):
+ console.warn(
+ f"Ignoring invalid Plural-Forms expression {expr!r} for locale "
+ f"{locale!r}; falling back to {_FALLBACK_PLURAL_EXPR!r}."
+ )
+ return _FALLBACK_PLURAL_EXPR
+ return expr
+
+
+def compile_catalog_module(
+ catalog: Catalog | None,
+ used_messages: Sequence[MessageKey],
+ locale: str,
+ *,
+ is_default_locale: bool,
+) -> str:
+ """Render the JS catalog module for one locale (used messages only).
+
+ Args:
+ catalog: The parsed ``.po`` catalog, or None if the locale has none.
+ used_messages: All messages collected from ``rx.t`` calls.
+ locale: The locale being compiled, for warnings.
+ is_default_locale: If True, missing translations are not warned about.
+
+ Returns:
+ The JS module source code.
+ """
+ entries: list[str] = []
+ missing: list[MessageKey] = []
+ for key in used_messages:
+ translation = _lookup_translation(catalog, key)
+ if translation is None:
+ missing.append(key)
+ continue
+ entries.append(f" [{json.dumps(key.catalog_key)}, {json.dumps(translation)}],")
+ if missing and not is_default_locale:
+ missing_list = "\n".join(f" {key.message!r}" for key in missing[:10])
+ more = f"\n ... and {len(missing) - 10} more" if len(missing) > 10 else ""
+ console.warn(
+ f"{len(missing)} translation(s) missing for locale {locale!r} "
+ f"(falling back to the default locale):\n{missing_list}{more}"
+ )
+ messages_body = "\n".join(entries)
+ # A Map, not an object literal: a message named like a prototype member
+ # ("constructor", "__proto__") must miss rather than resolve to one.
+ return (
+ f"{_GENERATED_HEADER}"
+ f"export const plural = (n) => Number({_plural_expr_js(catalog, locale)});\n"
+ f"export const messages = new Map([\n{messages_body}\n]);\n"
+ )
+
+
+def _lookup_translation(
+ catalog: Catalog | None, key: MessageKey
+) -> str | list[str] | None:
+ """Find the translation for a message in a catalog.
+
+ Args:
+ catalog: The parsed catalog, or None.
+ key: The message to look up.
+
+ Returns:
+ The translated string, a list of plural forms, or None if the message
+ is untranslated (including partially translated plurals).
+ """
+ if catalog is None:
+ return None
+ message = catalog.get(key.msgid, key.context)
+ # A fuzzy entry carries a stale translation; fall back to the source text
+ # (the server side already excludes fuzzy via write_mo).
+ if message is None or message.fuzzy:
+ return None
+ strings = message.string
+ if key.plural is None:
+ return strings or None if isinstance(strings, str) else None
+ if isinstance(strings, str):
+ strings = (strings,)
+ if not strings or not all(strings):
+ return None
+ return list(strings)
+
+
+def default_plural_expr_js(catalog: Catalog | None, locale: str) -> str:
+ """The default locale's plural expression for the client-side fallback.
+
+ Args:
+ catalog: The default locale's catalog, if one exists.
+ locale: The default locale.
+
+ Returns:
+ A validated JS plural expression over the variable ``n``.
+ """
+ if catalog is None:
+ from babel.messages.catalog import Catalog as BabelCatalog
+
+ try:
+ # Babel's built-in CLDR plural table for the locale.
+ catalog = BabelCatalog(locale=locale.replace("-", "_"))
+ except ValueError:
+ return _FALLBACK_PLURAL_EXPR
+ return _plural_expr_js(catalog, locale)
+
+
+def compile_index_module(
+ config: I18nConfig,
+ *,
+ url_routing: bool = False,
+ default_at_root: bool = True,
+ deploy_url: str = "",
+ default_plural_expr: str = _FALLBACK_PLURAL_EXPR,
+) -> str:
+ """Render the JS module describing the app's locales and catalog loaders.
+
+ The static ``import()`` map lets the bundler code-split one chunk per
+ locale, so clients only ever download the active language.
+
+ Args:
+ config: The app's i18n configuration.
+ url_routing: Whether URL-based locale routing is enabled (so the client
+ knows the locale comes from the URL, not the cookie).
+ default_at_root: Whether URL routing serves the default locale at the
+ unprefixed path (used by the hreflang helper).
+ deploy_url: Absolute site URL for building absolute hreflang hrefs, or
+ empty to emit relative hrefs.
+ default_plural_expr: The default locale's plural expression, used
+ client-side to pick between the two source strings of a plural
+ message that has no catalog entry.
+
+ Returns:
+ The JS module source code.
+ """
+ loaders = "\n".join(
+ f" {json.dumps(locale)}: () => import({json.dumps(f'$/i18n/{locale}.js')}),"
+ for locale in config.locales
+ )
+ return (
+ f"{_GENERATED_HEADER}"
+ f"export const locales = {json.dumps(list(config.locales))};\n"
+ f"export const defaultLocale = {json.dumps(config.default_locale)};\n"
+ f"export const cookieName = {json.dumps(LOCALE_COOKIE_NAME)};\n"
+ f"export const urlRouting = {json.dumps(url_routing)};\n"
+ f"export const defaultAtRoot = {json.dumps(default_at_root)};\n"
+ f"export const deployUrl = {json.dumps(deploy_url)};\n"
+ f"export const defaultPlural = (n) => Number({default_plural_expr});\n"
+ f"export const loaders = {{\n{loaders}\n}};\n"
+ )
diff --git a/packages/reflex-i18n/src/reflex_i18n/cli.py b/packages/reflex-i18n/src/reflex_i18n/cli.py
new file mode 100644
index 00000000000..9e75efcfe33
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/cli.py
@@ -0,0 +1,302 @@
+"""The ``reflex i18n`` command group: extract, init, and check catalogs.
+
+Registered lazily as ``reflex i18n`` by ``reflex.reflex`` when this package is
+installed.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import click
+from reflex_base.config import get_config
+from reflex_base.utils import console
+
+from .config import validate_locale
+from .plugin import I18nPlugin
+from .registry import MessageKey, collected_messages
+
+if TYPE_CHECKING:
+ from babel.messages.catalog import Catalog
+
+# Call names extracted for server-side (dynamic) translation. Matched
+# syntactically by Babel, so the conventional ``gettext as _`` alias works.
+_GETTEXT_KEYWORDS = {
+ "_": None,
+ "gettext": None,
+ "ngettext": (1, 2),
+ "pgettext": ((1, "c"), 2),
+}
+
+_POT_FILENAME = "messages.pot"
+
+
+@dataclasses.dataclass
+class LocaleStats:
+ """Per-locale translation completeness, for reporting and ``check``."""
+
+ locale: str
+ missing: int = 0
+ fuzzy: int = 0
+ obsolete: int = 0
+
+ @property
+ def incomplete(self) -> bool:
+ """Whether the locale has untranslated or fuzzy messages.
+
+ Returns:
+ True if any message is missing or fuzzy.
+ """
+ return bool(self.missing or self.fuzzy)
+
+
+def _resolve_plugin() -> I18nPlugin:
+ """Find the active I18nPlugin in the loaded config.
+
+ Returns:
+ The configured plugin.
+
+ Raises:
+ click.ClickException: If no I18nPlugin is configured.
+ """
+ plugin = next((p for p in get_config().plugins if isinstance(p, I18nPlugin)), None)
+ if plugin is None:
+ msg = (
+ "No I18nPlugin configured. Add I18nPlugin(locales=[...]) to "
+ "rx.Config(plugins=[...]) in rxconfig.py."
+ )
+ raise click.ClickException(msg)
+ return plugin
+
+
+def _extract_template() -> tuple[I18nPlugin, Catalog, Path]:
+ """Dry-compile the app and extract every message into a template catalog.
+
+ Returns:
+ The plugin, the extracted template, and the catalog directory.
+ """
+ from reflex.utils import prerequisites
+
+ prerequisites.get_compiled_app(dry_run=True, use_rich=False)
+ plugin = _resolve_plugin()
+ template = extract_catalog(_app_source_dir(), collected_messages())
+ return plugin, template, Path.cwd() / plugin.catalog_dir
+
+
+def extract_catalog(app_dir: Path, used_messages: tuple[MessageKey, ...]) -> Catalog:
+ """Build a message-template catalog from both translation sources.
+
+ Args:
+ app_dir: The app source directory to scan for gettext calls.
+ used_messages: Static ``rx.t`` messages from the compile registry.
+
+ Returns:
+ A catalog holding every extracted message (untranslated template).
+ """
+ from babel.messages.catalog import Catalog
+ from babel.messages.extract import extract_from_dir
+
+ catalog = Catalog()
+
+ # Dynamic content: gettext-family calls in the app source (with locations).
+ for filename, lineno, message, _comments, context in extract_from_dir(
+ app_dir, keywords=_GETTEXT_KEYWORDS
+ ):
+ catalog.add(
+ message,
+ locations=[(str(Path(app_dir.name) / filename), lineno)],
+ context=context,
+ )
+
+ # Static content: rx.t messages collected during compilation.
+ for key in used_messages:
+ catalog.add(key.msgid, context=key.context)
+
+ return catalog
+
+
+def _read_or_new_catalog(po_path: Path, locale: str) -> Catalog:
+ """Read an existing ``.po`` file or create an empty catalog for a locale.
+
+ Args:
+ po_path: The path of the ``.po`` file.
+ locale: The locale identifier.
+
+ Returns:
+ The loaded or newly created catalog.
+ """
+ from babel.messages.catalog import Catalog
+ from babel.messages.pofile import read_po
+
+ # Babel parses only the underscore form of a hyphenated tag (pt-BR).
+ babel_locale = locale.replace("-", "_")
+ if po_path.exists():
+ with po_path.open("rb") as f:
+ return read_po(f, locale=babel_locale)
+ return Catalog(locale=babel_locale)
+
+
+def _write_catalog(catalog: Catalog, path: Path) -> None:
+ """Write a catalog to a ``.po``/``.pot`` file.
+
+ Args:
+ catalog: The catalog to write.
+ path: The destination path.
+ """
+ from babel.messages.pofile import write_po
+
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("wb") as f:
+ write_po(f, catalog, omit_header=False)
+
+
+def merge_into_locale(template: Catalog, po_path: Path, locale: str) -> LocaleStats:
+ """Merge the template into a locale catalog, preserving translations.
+
+ Args:
+ template: The freshly extracted message template.
+ po_path: The locale's ``.po`` file path.
+ locale: The locale identifier.
+
+ Returns:
+ Stats describing the merged catalog.
+ """
+ catalog = _read_or_new_catalog(po_path, locale)
+ catalog.update(template)
+ _write_catalog(catalog, po_path)
+ return _catalog_stats(catalog, locale)
+
+
+def _catalog_stats(catalog: Catalog, locale: str) -> LocaleStats:
+ """Count untranslated, fuzzy, and obsolete messages in a catalog.
+
+ Args:
+ catalog: The catalog to inspect.
+ locale: The locale identifier.
+
+ Returns:
+ The computed stats.
+ """
+ stats = LocaleStats(locale=locale, obsolete=len(catalog.obsolete))
+ for message in catalog:
+ if not message.id: # the header entry
+ continue
+ if "fuzzy" in message.flags:
+ stats.fuzzy += 1
+ elif not message.string or (
+ isinstance(message.string, (list, tuple)) and not all(message.string)
+ ):
+ stats.missing += 1
+ return stats
+
+
+def _report(stats: LocaleStats, *, is_source: bool = False) -> None:
+ """Print a one-line summary for a locale.
+
+ Args:
+ stats: The locale stats to report.
+ is_source: Whether this is the default locale, whose catalog holds the
+ source text and is expected to have empty ``msgstr`` entries.
+ """
+ detail = f"{stats.missing} missing, {stats.fuzzy} fuzzy, {stats.obsolete} obsolete"
+ if is_source:
+ console.info(f" {stats.locale}: source catalog ({stats.obsolete} obsolete)")
+ elif stats.incomplete:
+ console.warn(f" {stats.locale}: {detail}")
+ else:
+ console.success(f" {stats.locale}: complete ({stats.obsolete} obsolete)")
+
+
+def _app_source_dir() -> Path:
+ """The app's Python source directory.
+
+ Resolved from the imported app module (the dry compile imported it), so
+ ``app_module_import`` and src layouts scan the real package; falls back to
+ ``/`` when the module cannot be found.
+
+ Returns:
+ The directory scanned for gettext calls.
+ """
+ config = get_config()
+ top_module = sys.modules.get(config.module.partition(".")[0])
+ pkg_paths = getattr(top_module, "__path__", None)
+ if pkg_paths:
+ return Path(next(iter(pkg_paths)))
+ module_file = getattr(top_module, "__file__", None)
+ if module_file:
+ return Path(module_file).parent
+ return Path.cwd() / config.app_name
+
+
+@click.group()
+def i18n_cli():
+ """Manage translation catalogs for the app."""
+
+
+@i18n_cli.command(name="extract")
+def extract_command():
+ """Extract messages and update every locale's ``.po`` catalog."""
+ plugin, template, catalog_dir = _extract_template()
+
+ _write_catalog(template, catalog_dir / _POT_FILENAME)
+ console.info(f"Extracted {len(template)} messages.")
+ for locale in plugin.locales:
+ _report(
+ merge_into_locale(template, catalog_dir / f"{locale}.po", locale),
+ is_source=locale == plugin.default_locale,
+ )
+ console.success("Catalogs updated.")
+
+
+@i18n_cli.command(name="init")
+@click.argument("locale")
+def init_command(locale: str):
+ """Create a new ``.po`` catalog for LOCALE.
+
+ Args:
+ locale: The locale to initialize (e.g. ``de``).
+
+ Raises:
+ ClickException: If the locale is not a language tag, or the catalog
+ already exists.
+ """
+ try:
+ validate_locale(locale)
+ except ValueError as exc:
+ raise click.ClickException(str(exc)) from exc
+ plugin, template, catalog_dir = _extract_template()
+ po_path = catalog_dir / f"{locale}.po"
+ if po_path.exists():
+ msg = f"Catalog already exists: {po_path}. Use `reflex i18n extract`."
+ raise click.ClickException(msg)
+
+ stats = merge_into_locale(template, po_path, locale)
+ console.success(f"Created {po_path} with {stats.missing} messages to translate.")
+ if locale not in plugin.locales:
+ console.info(
+ f"Add {locale!r} to I18nPlugin(locales=[...]) in rxconfig.py to ship it."
+ )
+
+
+@i18n_cli.command(name="check")
+def check_command():
+ """Fail if any non-default locale has untranslated or fuzzy messages."""
+ plugin, template, catalog_dir = _extract_template()
+
+ incomplete = False
+ for locale in plugin.locales:
+ if locale == plugin.default_locale:
+ continue
+ catalog = _read_or_new_catalog(catalog_dir / f"{locale}.po", locale)
+ catalog.update(template)
+ stats = _catalog_stats(catalog, locale)
+ _report(stats)
+ incomplete = incomplete or stats.incomplete
+
+ if incomplete:
+ msg = "Some locales have untranslated or fuzzy messages."
+ raise click.ClickException(msg)
+ console.success("All locales are complete.")
diff --git a/packages/reflex-i18n/src/reflex_i18n/component.py b/packages/reflex-i18n/src/reflex_i18n/component.py
new file mode 100644
index 00000000000..cfac69182a1
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/component.py
@@ -0,0 +1,103 @@
+"""The client components backing i18n (provider, per-route locale, hreflang)."""
+
+from __future__ import annotations
+
+import functools
+from typing import Any
+
+from reflex_base.components.component import Component
+from reflex_base.event import EventHandler, passthrough_event_spec
+from reflex_base.plugins.base import get_plugin
+from reflex_base.vars.base import Var
+
+# Outside the ErrorBoundary (55) so error fallback UI can translate too.
+_PROVIDER_PRIORITY = 58
+
+
+class I18nProvider(Component):
+ """Provides the active locale and message catalog via React context.
+
+ Implemented in the static web template ``utils/i18n.js``; pulled into the
+ app shell automatically (via ``VarData.app_wraps``) whenever ``rx.t`` is
+ used.
+ """
+
+ library = "$/utils/i18n"
+
+ tag = "I18nProvider"
+
+ # Fires with the locale a user picks through the provider (cookie mode only).
+ on_locale_change: EventHandler[passthrough_event_spec(str)]
+
+
+@functools.cache
+def _provider() -> I18nProvider:
+ """The provider app wrap shared by every feature that needs it.
+
+ In cookie mode it reports a client-side switch to ``I18nState`` so the
+ server's locale follows; the URL owns the locale otherwise.
+
+ Returns:
+ The provider for the configured plugin.
+ """
+ # Inline: the plugin module imports this one, and the state module
+ # registers I18nState on import.
+ from .plugin import I18nPlugin
+
+ plugin = get_plugin(I18nPlugin)
+ if plugin is None or plugin.routing is not None:
+ return I18nProvider.create()
+
+ from .state import I18nState
+
+ return I18nProvider.create(on_locale_change=I18nState.set_locale)
+
+
+class LocaleRoute(Component):
+ """Wraps a per-locale route with a fixed locale + static catalog.
+
+ The static catalog import makes the language available synchronously during
+ prerender (unlike the provider's default dynamic import).
+ """
+
+ library = "$/utils/i18n"
+
+ tag = "LocaleRoute"
+
+ # The locale this route renders in.
+ locale: Var[str]
+
+ # The statically-imported catalog module for ``locale``.
+ catalog: Var[Any]
+
+
+class HreflangLinks(Component):
+ """App-wrap emitting ``hreflang`` alternates + canonical for the route.
+
+ Reads the current path and its config from ``$/i18n/index.js`` (no props).
+ When every locale is prefixed, it also redirects an unprefixed URL to its
+ default-locale address.
+ """
+
+ library = "$/utils/i18n"
+
+ tag = "HreflangLinks"
+
+
+class LanguageSwitcher(Component):
+ """A language switcher: one ```` per locale (crawlable with URL routing)."""
+
+ library = "$/utils/i18n"
+
+ tag = "LanguageSwitcher"
+
+ @staticmethod
+ def _get_app_wrap_components() -> dict[tuple[int, str], Component]:
+ """Pull in the provider so the switcher can read and set the locale.
+
+ Returns:
+ The provider app wrap.
+ """
+ # A page may use the switcher without any rx.t or rx.i18n formatting
+ # var, neither of which would then drag the provider in.
+ return {(_PROVIDER_PRIORITY, "I18nProvider"): _provider()}
diff --git a/packages/reflex-i18n/src/reflex_i18n/config.py b/packages/reflex-i18n/src/reflex_i18n/config.py
new file mode 100644
index 00000000000..b17e255ffb4
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/config.py
@@ -0,0 +1,138 @@
+"""App-level i18n configuration."""
+
+from __future__ import annotations
+
+import dataclasses
+import re
+from collections.abc import Sequence
+from pathlib import Path
+
+# Cookie persisting the user's chosen locale; read by both the client
+# runtime and the server-side locale resolution.
+LOCALE_COOKIE_NAME = "reflex_locale"
+
+# A BCP 47-shaped tag: alphabetic language, optional alphanumeric subtags.
+# Locales double as URL path segments, JS catalog identifiers and `Intl` /
+# `Accept-Language` tags, all of which are hyphen-separated, so the underscore
+# spelling is rejected rather than normalized in each of those places. Babel's
+# underscore form is derived internally where it is needed.
+_LOCALE_RE = re.compile(r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$")
+
+
+def validate_locale(locale: str) -> None:
+ """Check that a locale is a usable language tag.
+
+ Args:
+ locale: The locale to check.
+
+ Raises:
+ ValueError: If the locale is not a hyphen-separated language tag.
+ """
+ if not _LOCALE_RE.fullmatch(locale):
+ msg = (
+ f"Invalid locale {locale!r}: expected a hyphen-separated language "
+ "tag such as 'en', 'pt-BR' or 'zh-Hant'."
+ )
+ raise ValueError(msg)
+
+
+@dataclasses.dataclass(frozen=True)
+class I18nConfig:
+ """Internationalization configuration held by :class:`I18nPlugin`."""
+
+ # Locales the app supports, e.g. ("en", "de"). Order is preserved for
+ # Accept-Language negotiation ties.
+ locales: tuple[str, ...]
+
+ # The locale the source-text msgids are written in.
+ default_locale: str = "en"
+
+ # Directory (relative to the app root) containing {locale}.po catalogs.
+ catalog_dir: str = "locales"
+
+ def __init__(
+ self,
+ locales: Sequence[str],
+ default_locale: str = "en",
+ catalog_dir: str = "locales",
+ ):
+ """Initialize and validate the i18n configuration.
+
+ Args:
+ locales: Locales the app supports, e.g. ``["en", "de"]``.
+ default_locale: The locale the source-text msgids are written in.
+ catalog_dir: Directory (relative to the app root) containing
+ ``{locale}.po`` catalogs.
+
+ Raises:
+ ValueError: If no locales are given, a locale is repeated, a locale
+ is not a language tag, or the default locale is not among them.
+ """
+ locales_tuple = tuple(locales)
+ if not locales_tuple:
+ msg = "I18nConfig.locales must contain at least one locale."
+ raise ValueError(msg)
+ if len(set(locales_tuple)) != len(locales_tuple):
+ # A repeated locale would emit its route and catalog module twice.
+ msg = f"I18nConfig.locales must not repeat a locale: {locales_tuple!r}."
+ raise ValueError(msg)
+ for locale in locales_tuple:
+ validate_locale(locale)
+ if default_locale not in locales_tuple:
+ msg = (
+ f"I18nConfig.default_locale {default_locale!r} must be one of "
+ f"the configured locales {locales_tuple!r}."
+ )
+ raise ValueError(msg)
+ object.__setattr__(self, "locales", locales_tuple)
+ object.__setattr__(self, "default_locale", default_locale)
+ object.__setattr__(self, "catalog_dir", catalog_dir)
+
+
+_active_config: I18nConfig | None = None
+
+# Absolute catalog directory, captured when the app is constructed (cwd is the
+# app root then). Used at compile and request time so catalog loading does not
+# depend on the process cwd, which is not guaranteed to be the app root later.
+_active_catalog_dir: Path | None = None
+
+
+def set_active_i18n_config(config: I18nConfig | None) -> None:
+ """Set the i18n configuration of the running app.
+
+ Called by ``rx.App`` so server-side translation helpers can resolve
+ locales without a reference to the app instance. Must be called while the
+ current working directory is the app root (it is during app construction).
+
+ Args:
+ config: The configuration to activate, or None to deactivate.
+ """
+ # Inline to avoid a circular import; runtime imports from this module.
+ from .runtime import clear_translations_cache
+
+ global _active_config, _active_catalog_dir
+ _active_config = config
+ _active_catalog_dir = (
+ (Path.cwd() / config.catalog_dir).resolve() if config is not None else None
+ )
+ # Drop translators built from the previous catalog dir (or from catalogs
+ # edited before a dev hot reload re-activated the config).
+ clear_translations_cache()
+
+
+def get_active_i18n_config() -> I18nConfig | None:
+ """Get the i18n configuration of the running app.
+
+ Returns:
+ The active configuration, or None if the app has no i18n config.
+ """
+ return _active_config
+
+
+def get_active_catalog_dir() -> Path | None:
+ """Get the absolute catalog directory of the running app.
+
+ Returns:
+ The absolute catalog directory, or None if i18n is not configured.
+ """
+ return _active_catalog_dir
diff --git a/packages/reflex-i18n/src/reflex_i18n/format.py b/packages/reflex-i18n/src/reflex_i18n/format.py
new file mode 100644
index 00000000000..a91a7937cc8
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/format.py
@@ -0,0 +1,364 @@
+"""Client-side, locale-aware number and date formatting vars.
+
+``rx.i18n.number`` / ``rx.i18n.currency`` / ``rx.i18n.date`` (and friends)
+format a value in the active locale using the browser's ``Intl`` API,
+reactively reformatting when the locale changes. To format inside state
+(server-side), use the ``format_*`` helpers in :mod:`reflex_i18n.runtime`.
+"""
+
+from __future__ import annotations
+
+import functools
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any
+
+from reflex_base.utils.imports import ImportVar
+from reflex_base.vars.base import LiteralVar, Var, VarData
+from reflex_base.vars.function import FunctionStringVar, FunctionVar, ReflexCallable
+from reflex_base.vars.sequence import StringVar
+
+from .component import _PROVIDER_PRIORITY, _provider
+from .config import get_active_i18n_config
+
+if TYPE_CHECKING:
+ # Aliased so the `date`/`time`/`datetime` functions below don't shadow it.
+ import datetime as _datetime
+
+
+def _require_config() -> None:
+ """Ensure the i18n plugin is configured.
+
+ Raises:
+ RuntimeError: If no I18nPlugin is configured.
+ """
+ if get_active_i18n_config() is None:
+ msg = (
+ "rx.i18n formatting requires the i18n plugin. Add "
+ "I18nPlugin(locales=[...]) to rx.Config(plugins=[...])."
+ )
+ raise RuntimeError(msg)
+
+
+@functools.cache
+def _format_var_data() -> VarData:
+ """VarData injecting the ``useFormat`` hook and the provider.
+
+ Returns:
+ The shared VarData for number/date formatting vars.
+ """
+ return VarData(
+ imports={"$/utils/i18n": [ImportVar(tag="useFormat")]},
+ hooks={"const [ fmtNumber, fmtDate ] = useFormat()": None},
+ app_wraps=((_PROVIDER_PRIORITY, _provider()),),
+ )
+
+
+@functools.cache
+def _locale_var_data() -> VarData:
+ """VarData injecting the ``useLocale`` hook and the provider.
+
+ Returns:
+ The shared VarData for the active-locale var.
+ """
+ return VarData(
+ imports={"$/utils/i18n": [ImportVar(tag="useLocale")]},
+ hooks={"const i18nLocale = useLocale()": None},
+ app_wraps=((_PROVIDER_PRIORITY, _provider()),),
+ )
+
+
+# ``useFormat`` destructures to ``[fmtNumber, fmtDate]``; each takes the value
+# to format and an ``Intl`` options object, and returns the formatted string.
+_Formatter = ReflexCallable[[Any, Mapping[str, Any]], str]
+
+
+@functools.cache
+def _fmt_number() -> FunctionVar[_Formatter]:
+ """The client-side number formatter for the active locale.
+
+ Returns:
+ The ``fmtNumber`` hook function as a callable Var.
+ """
+ return FunctionStringVar.create(
+ "fmtNumber", _var_type=_Formatter, _var_data=_format_var_data()
+ )
+
+
+@functools.cache
+def _fmt_date() -> FunctionVar[_Formatter]:
+ """The client-side date formatter for the active locale.
+
+ Returns:
+ The ``fmtDate`` hook function as a callable Var.
+ """
+ return FunctionStringVar.create(
+ "fmtDate", _var_type=_Formatter, _var_data=_format_var_data()
+ )
+
+
+def _number_options(
+ *,
+ style: str | None = None,
+ currency: str | None = None,
+ min_fraction_digits: int | None = None,
+ max_fraction_digits: int | None = None,
+ grouping: bool | None = None,
+ compact: bool = False,
+ options: dict[str, Any] | None = None,
+) -> Var[Mapping[str, Any]]:
+ """Build the ``Intl.NumberFormat`` options object from curated kwargs.
+
+ Args:
+ style: The Intl number style (``decimal``/``currency``/``percent``).
+ currency: The ISO 4217 currency code (for ``style="currency"``).
+ min_fraction_digits: Minimum fraction digits.
+ max_fraction_digits: Maximum fraction digits.
+ grouping: Whether to show the grouping (thousands) separator.
+ compact: Whether to use compact notation (e.g. ``1.2M``).
+ options: Raw ``Intl.NumberFormat`` options, merged last.
+
+ Returns:
+ The Intl options object as a Var.
+ """
+ opts: dict[str, Any] = {}
+ if style is not None:
+ opts["style"] = style
+ if currency is not None:
+ opts["currency"] = currency
+ if min_fraction_digits is not None:
+ opts["minimumFractionDigits"] = min_fraction_digits
+ if max_fraction_digits is not None:
+ opts["maximumFractionDigits"] = max_fraction_digits
+ if grouping is not None:
+ opts["useGrouping"] = grouping
+ if compact:
+ opts["notation"] = "compact"
+ if options:
+ opts.update(options)
+ return LiteralVar.create(opts)
+
+
+def number(
+ value: Var[Any] | int | float,
+ *,
+ min_fraction_digits: int | None = None,
+ max_fraction_digits: int | None = None,
+ grouping: bool | None = None,
+ compact: bool = False,
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a number in the active locale.
+
+ Args:
+ value: The number to format.
+ min_fraction_digits: Minimum fraction digits.
+ max_fraction_digits: Maximum fraction digits.
+ grouping: Whether to show the grouping separator.
+ compact: Whether to use compact notation (e.g. ``1.2M``).
+ options: Raw ``Intl.NumberFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized number.
+ """
+ _require_config()
+ opts = _number_options(
+ min_fraction_digits=min_fraction_digits,
+ max_fraction_digits=max_fraction_digits,
+ grouping=grouping,
+ compact=compact,
+ options=options,
+ )
+ return _fmt_number().call(value, opts).to(str)
+
+
+def currency(
+ value: Var[Any] | int | float,
+ currency: str,
+ *,
+ min_fraction_digits: int | None = None,
+ max_fraction_digits: int | None = None,
+ grouping: bool | None = None,
+ compact: bool = False,
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a currency amount in the active locale.
+
+ Args:
+ value: The amount to format.
+ currency: The ISO 4217 currency code (e.g. ``"EUR"``).
+ min_fraction_digits: Minimum fraction digits.
+ max_fraction_digits: Maximum fraction digits.
+ grouping: Whether to show the grouping separator.
+ compact: Whether to use compact notation.
+ options: Raw ``Intl.NumberFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized currency amount.
+ """
+ _require_config()
+ opts = _number_options(
+ style="currency",
+ currency=currency,
+ min_fraction_digits=min_fraction_digits,
+ max_fraction_digits=max_fraction_digits,
+ grouping=grouping,
+ compact=compact,
+ options=options,
+ )
+ return _fmt_number().call(value, opts).to(str)
+
+
+def percent(
+ value: Var[Any] | int | float,
+ *,
+ min_fraction_digits: int | None = None,
+ max_fraction_digits: int | None = None,
+ grouping: bool | None = None,
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a ratio as a percentage in the active locale (``0.15`` -> ``15%``).
+
+ Args:
+ value: The ratio to format (``1`` == 100%).
+ min_fraction_digits: Minimum fraction digits.
+ max_fraction_digits: Maximum fraction digits.
+ grouping: Whether to show the grouping separator.
+ options: Raw ``Intl.NumberFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized percentage.
+ """
+ _require_config()
+ opts = _number_options(
+ style="percent",
+ min_fraction_digits=min_fraction_digits,
+ max_fraction_digits=max_fraction_digits,
+ grouping=grouping,
+ options=options,
+ )
+ return _fmt_number().call(value, opts).to(str)
+
+
+# ``Intl.DateTimeFormat`` throws when ``dateStyle``/``timeStyle`` are combined
+# with any of these per-component options, so the curated ``length`` gives way
+# when the escape hatch spells the format out field by field.
+_DATE_COMPONENT_OPTIONS = frozenset({
+ "weekday",
+ "era",
+ "year",
+ "month",
+ "day",
+ "dayPeriod",
+ "hour",
+ "minute",
+ "second",
+ "fractionalSecondDigits",
+ "timeZoneName",
+})
+
+
+def _date_options(
+ *,
+ date_style: str | None = None,
+ time_style: str | None = None,
+ options: dict[str, Any] | None = None,
+) -> Var[Mapping[str, Any]]:
+ """Build the ``Intl.DateTimeFormat`` options object from curated kwargs.
+
+ Args:
+ date_style: The Intl ``dateStyle`` (``short``/``medium``/``long``/``full``).
+ time_style: The Intl ``timeStyle``.
+ options: Raw ``Intl.DateTimeFormat`` options, merged last.
+
+ Returns:
+ The Intl options object as a Var.
+ """
+ opts: dict[str, Any] = {}
+ if not (options and _DATE_COMPONENT_OPTIONS.intersection(options)):
+ if date_style is not None:
+ opts["dateStyle"] = date_style
+ if time_style is not None:
+ opts["timeStyle"] = time_style
+ if options:
+ opts.update(options)
+ return LiteralVar.create(opts)
+
+
+def date(
+ value: Var[Any] | _datetime.date | str,
+ *,
+ length: str = "medium",
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a date in the active locale.
+
+ Args:
+ value: The date to format (a Var or ISO string).
+ length: The date length (``short``/``medium``/``long``/``full``).
+ options: Raw ``Intl.DateTimeFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized date.
+ """
+ _require_config()
+ return (
+ _fmt_date()
+ .call(value, _date_options(date_style=length, options=options))
+ .to(str)
+ )
+
+
+def time(
+ value: Var[Any] | _datetime.time | str,
+ *,
+ length: str = "medium",
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a time in the active locale.
+
+ Args:
+ value: The time to format (a Var or ISO string).
+ length: The time length (``short``/``medium``/``long``/``full``).
+ options: Raw ``Intl.DateTimeFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized time.
+ """
+ _require_config()
+ return (
+ _fmt_date()
+ .call(value, _date_options(time_style=length, options=options))
+ .to(str)
+ )
+
+
+def datetime(
+ value: Var[Any] | _datetime.datetime | str,
+ *,
+ length: str = "medium",
+ options: dict[str, Any] | None = None,
+) -> StringVar:
+ """Format a date and time in the active locale.
+
+ Args:
+ value: The datetime to format (a Var or ISO string).
+ length: The length (``short``/``medium``/``long``/``full``).
+ options: Raw ``Intl.DateTimeFormat`` options, merged last.
+
+ Returns:
+ A StringVar resolving to the localized date and time.
+ """
+ _require_config()
+ opts = _date_options(date_style=length, time_style=length, options=options)
+ return _fmt_date().call(value, opts).to(str)
+
+
+@functools.cache
+def _locale_var() -> StringVar:
+ """Build the active-locale var (cached singleton).
+
+ Returns:
+ A StringVar resolving to the active locale code.
+ """
+ _require_config()
+ return Var(_js_expr="i18nLocale", _var_data=_locale_var_data()).to(str)
diff --git a/packages/reflex-i18n/src/reflex_i18n/nav.py b/packages/reflex-i18n/src/reflex_i18n/nav.py
new file mode 100644
index 00000000000..b2f75a10e15
--- /dev/null
+++ b/packages/reflex-i18n/src/reflex_i18n/nav.py
@@ -0,0 +1,57 @@
+"""Navigation helpers for URL-based locale routing (``rx.i18n.*``)."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from .component import LanguageSwitcher
+
+if TYPE_CHECKING:
+ from reflex_base.components.component import Component
+
+
+def _base_path(route: str) -> str:
+ """Normalize a route to a base URL path.
+
+ Args:
+ route: A route key (``"index"``, ``"pricing"``) or path (``"/pricing"``).
+
+ Returns:
+ The URL path (``"/"`` for the index, else ``"/"``).
+ """
+ if route in ("", "/", "index"):
+ return "/"
+ return route if route.startswith("/") else f"/{route}"
+
+
+def locale_url(locale: str, route: str) -> str:
+ """The URL path for a route in a given locale (for custom links).
+
+ Args:
+ locale: The target locale.
+ route: The base route (``"/pricing"`` or ``"pricing"``).
+
+ Returns:
+ The localized URL path (unchanged if URL routing is off).
+ """
+ from reflex_base.plugins.base import get_plugin
+
+ from .plugin import I18nPlugin
+
+ path = _base_path(route)
+ plugin = get_plugin(I18nPlugin)
+ if plugin is None or plugin.routing is None:
+ return path
+ return plugin.routing.localize(path, locale, plugin.default_locale)
+
+
+def language_switcher(**props: Any) -> Component:
+ """A crawlable language switcher: one ```` link per locale.
+
+ Args:
+ props: Props forwarded to the switcher's ``