Skip to content

Translations (i18n)

Fullfinity supports multi-language translations using an approach where the English source text serves as the translation key.

  • Backend-driven: All translations are managed on the backend and served to the frontend
  • English-text-as-key format: Translation files use {"English Text": "Translated Text"} format
  • AI-powered: Translations are generated using OpenAI or Anthropic APIs
  • Per-module: Each module has its own i18n/ folder with translation files

Translation files are stored in each module’s i18n/ directory:

modules/crm/
├── models/
├── views/
└── i18n/
├── es.json # Spanish
├── fr.json # French
└── de.json # German

Each file uses English text as the key:

{
"Customer": "Cliente",
"Expected Revenue": "Ingresos esperados",
"Mark as Won": "Marcar como ganado",
"Leads in this stage are considered won": "Los leads en esta etapa se consideran ganados"
}

The fullfinity-translate CLI extracts translatable strings and generates translations.

Terminal window
# Extract strings from a module (shows counts)
./fullfinity-translate -c config.yaml --extract fullfinity/modules/crm
# Translate a single module to Spanish
./fullfinity-translate -c config.yaml fullfinity/modules/crm es
# Translate all modules to multiple languages
./fullfinity-translate -c config.yaml fullfinity/modules es,fr,de
# Translate to all supported languages
./fullfinity-translate -c config.yaml --all fullfinity/modules/invoicing
# List supported languages
./fullfinity-translate --list-languages

React t() strings are extracted from app/src and stored with the core module (modules/core/i18n/<lang>.json), because all translations are served from the backend. They are translated automatically whenever core is within the path you pass, e.g.:

Terminal window
./fullfinity-translate -c config.yaml fullfinity/modules es # all modules + frontend
./fullfinity-translate -c config.yaml fullfinity/modules/core es # core + frontend

Generated i18n/*.json files are not read at runtime — they’re loaded into the Translation table. After generating, reload them:

  • UI: Languages → pick the language → Generate translations (generates + loads in one step), or Reload translations to just load existing files.
  • These map to Language.action_generate_translations / action_load_translations. Loading only happens for installed modules.
CodeLanguage
esSpanish
frFrench
deGerman
pt-BRPortuguese (Brazil)
zh-CNChinese (Simplified)
jaJapanese
arArabic
itItalian
nlDutch
ruRussian
koKorean
hiHindi
trTurkish
plPolish
viVietnamese

Checking that everything is actually translated

Section titled “Checking that everything is actually translated”

A string only reaches a user in their language if three things hold, and each fails silently and independently:

  1. it is wrapped — t() in code, T() in a module-scope table, or a display key in YAML;
  2. it is extracted into i18n/<lang>.json;
  3. the value is looked up where it renders.

All three failures look identical on screen — English text — so finding them by reading the app means opening every page in a real language. Two things replace that.

Catalog completeness (checked when you release)

Section titled “Catalog completeness (checked when you release)”

Every catalog that exists must contain every string the extractor can see:

Terminal window
./fullfinity-server -c config.yaml check --only i18n-catalogs

It lists the exact shortfall per catalog, so a partial AI run, an expired key, or a merge that added copy without regenerating is caught with the missing strings named.

This gate is release-time, not commit-time — like check --only version-bump, it is not part of a bare check. The only way to satisfy it is to run the translator, which is an LLM call per language; blocking every commit that adds a line of copy on that just teaches people to ignore a permanently red build. Regenerate, then check:

Terminal window
./fullfinity-translate -c config.yaml fullfinity/modules/<module> es,fr,de

Adding a language stays opt-in per module: a module with no es.json is never asked for Spanish. But once the file is there, it has to be complete.

Extraction is checked by the release gate above; wrapping is checked on every commit by ./fullfinity-server check --only i18n. The third link — a value that IS translated but is served past its lookup — is checked per endpoint (TestViewEndpointsTranslate), because that is where it has actually gone wrong: the wizard endpoint composed its arch and returned it without translating, so every wizard in the product rendered English regardless of language while the catalog beside it was complete.

If you add a new endpoint that serves view text or field labels, translate it there and add it to that test. Nothing else will notice.

Add an AI API key to your config.yaml:

# Use either OpenAI or Anthropic
OPENAI_API_KEY: "sk-proj-..."
# or
ANTHROPIC_API_KEY: "sk-ant-..."

The CLI extracts:

  • Field descriptions: description="Customer Name"
  • Field hints: hint="Enter the customer's full name"
  • Error messages: raise ValidationError("Invalid email format") — including a fallback inside the raise, e.g. raise UserError(remote_error or "Refund failed.")
  • t("...") message templates (the first argument literal — see Dynamic messages below)
class Contact(Model):
name = Char(
description="Contact Name", # Extracted
hint="Full name of the contact", # Extracted
max_length=255
)

A field label must be a literal the extractor can read

Section titled “A field label must be a literal the extractor can read”

Extraction parses your source, so the label has to be in the source. A long hint split across lines is fine — Python joins the fragments into one string and the extractor reads that same joined string, so the key matches what renders:

hint="Extra days added to the promised delivery date, on top of "
"each product's own customer lead time." # ✅ one key, the full sentence

Sharing a hint between fields via a module-level constant is fine too — the extractor resolves it:

_BLANK_HINT = "Leave blank to use the mapped account."
expense_account = ManyToOne("Account", description="Expense Account", hint=_BLANK_HINT) # ✅

What does not work is a label assembled at runtime, because there is no literal to collect — no key reaches the catalogue and the label stays English in every language:

weight = Float(description=f"Weight ({unit})") # ❌ f-string
state = Char(description="Status: " + suffix) # ❌ concatenation
note = Char(description=labels["note"]) # ❌ lookup

./fullfinity-server check --only i18n fails on these (CI and pre-commit), naming the file and line. If a label genuinely has to vary, keep the field’s description static and put the dynamic part in the value or a calculated field instead.

Error messages: static needs nothing, dynamic needs t()

Section titled “Error messages: static needs nothing, dynamic needs t()”

A static error message needs no ceremony at all. Write it plainly and it is extracted by its English text and translated for you — the framework translates the message where it builds the error response, so every raise below ships in every language you generate:

raise UserError("Only documents in draft stage can be posted.") # ✅ translated for you
raise MissingError("The record has been deleted.") # ✅
raise UserError(result.get("error") or "Refund failed.") # ✅ the fallback ships too

The same applies to a message a helper returns — but there the literal has to be wrapped, because a return is not a raise and nothing marks it as user-facing:

async def is_valid(self):
if self.state == "Expired":
return False, t("This coupon has expired.") # ✅ wrapped at the source
...
is_valid, reason = await coupon.is_valid()
if not is_valid:
raise UserError(reason) # ✅ already translated

Dynamic messages — translate the template, don’t build a string

Section titled “Dynamic messages — translate the template, don’t build a string”

A message that contains a runtime value must not be built with an f-string:

# WRONG — not translatable. The extractor can't key an f-string, and at runtime the
# concrete string ("...at most 1 of Widget.") never matches a translation key.
raise UserError(f"You can return at most {qty} of {name}.")

Instead use the backend t() helper. It mirrors the frontend t('...{x}', {x}): the static template is the translation key, and the dynamic values are interpolated after the template is translated — so runtime values never have to match a key:

from fullfinity.engine.base import t # also available via `from ...base import *`
raise UserError(
t("You can return at most {qty} of {name}.", qty=qty, name=name)
)
  • The first argument is a static literal with {name} placeholders — never an f-string. It’s what gets extracted and translated.
  • t() is synchronous — no await, so it works in both sync and async code. It resolves the current user’s language and reads the in-process translation cache, then fills in the params. A static message with no placeholders works too (t("Only draft returns can be confirmed.")).
  • Because translation happens server-side at the call site, it works for anything — UserError/ValidationError messages, email bodies, notifications — not just React.
  • Format specs can’t live in the template (they’d break the translation key). Pre-format the value and pass the string: t("At most {qty}…", qty=f"{n:g}").

Naming records in a message — summarize_list

Section titled “Naming records in a message — summarize_list”

A message that names the rows something went wrong on must not grow with how many rows there are. Joining the whole list reads fine while you’re testing with three lines and is unreadable at thirty — and the list or form behind the toast is already showing that same per-record detail, where the user can scroll, sort and filter it:

# WRONG — the toast is as long as the document.
raise UserError(t("Still short: {items}.", items=", ".join(names)))

summarize_list names a few and counts the rest, so the message has a fixed ceiling whatever the data does:

from fullfinity.engine.base import summarize_list # also via `from ...base import *`
raise UserError(
t("Still short: {items}. Reserve the missing stock.", items=summarize_list(names))
)
# -> "Still short: Widget, Gadget, Bolt and 27 more. Reserve the missing stock."
  • The total survives the cap — the overflow count is what the user needs; the identities of rows 4..30 belong on the rows.
  • limit is how many are named before overflow: the default 3 suits an inline list in a toast, 5 or so suits a bulleted separator="\n" list, which has more room. A list exactly one item over the limit is shown in full, since naming the last item beats “and 1 more”.
  • separator joins the shown items. The overflow phrase goes on its own line for line-based separators and after a space otherwise.
  • Returns "" for an empty list, so summarize_list(x) or t("…") works as a fallback.
  • If a full list genuinely has to be preserved, put it on the record — a field, a log line, a chatter note — and let the message point at it. Only the transient message is capped.

A list that’s bounded by code rather than data — the valid choices for a Selection, a fixed set of column names — needs none of this; naming all of them is the useful answer.

./fullfinity-server check --only i18n (CI and pre-commit) fails on a user-facing exception — UserError, ValidationError, AccessError, MissingError — whose message is assembled with an f-string, %, .format() or concatenation, naming the file and line. The other exception types render a developer modal with a traceback, so they are not checked.

If a message really is output for whoever wrote the module rather than for an end user — a bad view definition, an upgrade punch-list, a CLI hint — mark the raise and it is skipped by both the gate and extraction:

raise UserError(f"UiView {path}: 'confirm' needs a message.") # i18n: diagnostic

Build the message at raise time, not at import time. A module-level constant is evaluated once, before any request exists, so t() there would freeze whatever language the worker started in — wrap it in a small function instead:

_CONFLICT = t("Someone else changed this report…") # ❌ resolved once, at import
def _conflict() -> str:
return t("Someone else changed this report…") # ✅ resolved per request

The CLI extracts these keys from view definitions:

  • title - Section/tab titles
  • label - Field labels
  • description - Descriptions
  • placeholder - Input placeholders
  • hint - Help text
  • message - Messages
  • name - Only from UiMenu and WindowAction (display names)
{
"type": "field",
"name": "contact",
"properties": {
"label": "Customer",
"placeholder": "Select a customer",
"hint": "The customer for this order"
}
}

Strings wrapped in the t() function are extracted. The English text is the key — pass it as the first argument:

t("Save Changes") // key + fallback in one
t("Saved {count} records", { count }) // with interpolation params

Do not invent a separate identifier key, e.g. t("save_changes", "Save Changes"). The whole pipeline (extraction, i18n/*.json, the Translation.key/source columns, and the served lookup dict) is keyed by the English source text, so a synthetic key like "save_changes" is never present at runtime and the string silently falls back to English. Keying by the English text is also collision-free — the source string is already unique, whereas a lowercased/underscored identifier can collapse distinct strings together ("Sign up" and "Sign-up").

Portal, website and email templates translate explicitly, through the same English-text-is-the-key rule:

<h1>{{ t('Your Orders') }}</h1>
<p>{{ t('Dear {name},', name=contact.name) }}</p>

Report and email bodies get t() for free — the framework builds those environments. A module that builds its own Environment(...) for its page templates must install the global itself, or every {{ t('…') }} in the templates it renders raises 't' is undefined at render time:

from jinja2 import Environment, FileSystemLoader
from fullfinity.engine.jinja_filters import install_translation_global
my_env = install_translation_global(
Environment(loader=FileSystemLoader(_template_dir))
)

Two things to know about it:

  • A global, not a context variable. An {% import %}ed macro never sees the caller’s render context, so a macro that translates its own labels can only read a global. Passing t in the render context alone leaves every macro broken.
  • A context translator still wins. A page that resolves its own language (from the visitor’s language cookie, or a recipient’s language on an email) puts that translator in the render context, which shadows the global. Installing the global doesn’t override per-visitor language — it’s the floor for renders that never set one.
  • It also installs the display filters|money, |number and |date — so a module page never has to format an amount or a date by hand. {{ total|money }} takes the symbol side, decimals and grouping characters from the currency in the render context. Anything you bound yourself before calling it is left alone.

./fullfinity-server check --only i18n fails on a module environment with no translator bound, alongside the unwrapped-string rules.

Month and weekday names in dates render in the viewer’s language — you don’t translate them by hand (they aren’t t() strings), the formatter does it.

  • Server-rendered templates — the |date Jinja filter localizes automatically. {{ order.date|date }} uses the page language’s date_format for layout and renders month/day names in that language. Pass an explicit pattern ({{ d|date("MMMM D, YYYY") }}) and the word tokens still localize. The page language comes from the visitor’s choice (a ?lang= param or the on-site language switcher’s cookie), falling back to their browser’s Accept-Language.
  • Backend Python — format a date with format_localized(value, fmt) (from fullfinity.engine.jinja_filters) instead of value.strftime(fmt) where the result is shown to a user. fmt is an ordinary strftime pattern; names localize to the current render’s language, then the logged-in user’s language, else English — and it never raises (any failure falls back to English strftime). Keep strftime only for machine formats that must stay fixed (RSS/RFC-822 dates, filenames, ISO keys).
  • React — dates render through dayjs, whose locale is set from the user’s language, so .format('MMM D') and the first-day-of-week both follow that language. No per-call work.

The language carries only the names; numeric layout/order (DD/MM vs MM/DD) still comes from the language’s date_format.

A language also carries how a figure is punctuated: decimal_separator, thousands_separator, and grouping — the group sizes, counted from the right, with the last size repeating. "3" is the Western 12,345,678; "3;2" is the South Asian lakh/crore 1,23,45,678. Amounts, floats and integers rendered by the framework’s widgets already use these, so a Monetary or Number field needs no per-field work.

Grouping is a property of the reader, not of the money. A Currency may carry its own separators, so an amount is punctuated the same way for everyone who sees it — but the group sizes always come from the viewer’s language, which is what lets an en-IN user read a USD total as $1,23,45,678.00.

In server-rendered templates — reports, portal pages, website pages, emails — the same split applies, and both filters resolve it for you from the render language:

  • {{ qty | number }} takes all three properties from the page language. There is no currency to speak for a quantity, so a German render prints 1.234,56 and an Indian one 1,23,456.
  • {{ total | money(currency) }} takes the separators from the currency and the group sizes from the page language.

The render language is the reader’s: a customer-facing document resolves it from the recipient’s contact, falling back to the sending user’s. Nothing per-template is needed — a body that prints {{ line.quantity | number(rounding=line.uom.rounding) }} already follows whichever language the document is being rendered in.

Views, menus and field labels are translated as the request is served. Error messages are translated where the error response is built, so a static message is looked up by its English text with nothing needed at the raise; a message that carries a runtime value is already translated by the time it is raised, because t() did it. The framework’s own messages (a duplicate-key or required-field error, a permission refusal) go through the same lookup — they live in the core catalog, which every database has.

Outside a request — a scheduled job, a CLI install — there is no user and therefore no language, so t() returns the English template rather than failing.

The translate_arch() function translates view architectures based on user language:

from fullfinity.engine.translation import translate_arch, get_translations_dict
# Get translations for Spanish
translations = await get_translations_dict(env, "es")
# Translate a view
translated_arch = translate_arch(view.arch, translations)

Use the useTranslation hook:

import { useTranslation } from '../contexts/TranslationContext';
function MyComponent() {
const { t } = useTranslation();
return (
<Button>{t("Save Changes")}</Button>
);
}
  1. In Python models, use description= and hint=:
name = Char(description="Product Name", hint="Enter product name")
  1. In JSON views, use translatable keys:
{
"type": "fieldset",
"title": "Customer Information",
"content": [...]
}
  1. In React components, wrap with t():
<Button>{t("Submit Order")}</Button>
  1. Run the translation CLI to generate translations:
Terminal window
./fullfinity-translate -c config.yaml fullfinity/modules/mymodule es

Website content: contributing your records to the translator

Section titled “Website content: contributing your records to the translator”

The strings above are yours as a developer — field labels, view titles, messages you wrote. A website also displays records the user created: pages, menu labels, and whatever your own module puts on the site. Those are translated by the same source-keyed mechanism, but they have to be collected first, because nothing can scan a database for “text a visitor will read”.

Two halves, and they must agree:

  1. Render the value through t() in your template, exactly as you would a literal. The English text is the key, so {{ t(product.name) }} looks up the product’s own name and falls back to it when no translation exists.

  2. Contribute it to the harvest by extending Website._collect_translatable_strings(). Call super(), add your own, return the dict. The website module never has to know your model exists — the same shape as collect_content_sitemap_urls for the sitemap.

class WebsiteBlog(Model):
__inherit__ = "Website"
async def _collect_translatable_strings(self) -> dict:
strings = await super()._collect_translatable_strings()
from fullfinity.modules.website.models.website import collect_text_values
Post = get_model("BlogPost")
for post in await Post.filter(published=True).all():
collect_text_values(strings, post.title)
collect_text_values(strings, post.summary)
return strings

collect_text_values(target, value) walks strings, dicts and lists — so a whole settings blob can be handed over without knowing its shape — and skips anything that is plainly not prose (URLs, colours, CSS values, text with no letters in it). Use it rather than writing into the dict yourself: a translator handed #f5f5f5 will cheerfully invent something.

Collect only what the site actually publishes. Translating a draft is paying for text nobody will read.

A string rendered through t() but never harvested renders in English forever, silently — which is the failure this pairing exists to prevent, so add both halves in the same change.

The CLI only translates new strings. Existing translations are preserved:

Terminal window
# First run: translates all 50 strings
./fullfinity-translate -c config.yaml fullfinity/modules/crm es
# Output: Translating 50 new strings...
# Add new fields, run again: only new strings translated
./fullfinity-translate -c config.yaml fullfinity/modules/crm es
# Output: Translating 3 new strings...
  1. Use descriptive English text - The English text becomes the key, so make it clear and unique

  2. Never bake a value into a message - t("Only {n} left.", n=count), never f"Only {count} left.". The template is the key; a finished sentence with a value in it can never match one

  3. Avoid programmatic strings - Don’t translate identifiers, field names, or code

  4. Review AI translations - AI translations are good but may need manual review for domain-specific terms

  5. Translate early and often - Run translations as part of your development workflow

  6. Keep translations in version control - The i18n/*.json files should be committed