Internationalization
Nine languages ship. One file each, loaded when chosen, with direction, plurals and number formatting following from the choice.
The languages
| Code | Label | Intl tag | Flag |
|---|---|---|---|
en | English | en-US | /flags/us.svg |
ar | العربية | ar | /flags/sa.svg |
de | Deutsch | de-DE | /flags/de.svg |
es | Español | es-ES | /flags/es.svg |
fr | Français | fr-FR | /flags/fr.svg |
id | Bahasa Indonesia | id-ID | /flags/id.svg |
it | Italiano | it-IT | /flags/it.svg |
pt | Português | pt-PT | /flags/pt.svg |
tr | Türkçe | tr-TR | /flags/tr.svg |
Each row also names the date-fns locale to load for that language. Arabic's is assembled from two: ar for the words, with ar-SA's time format.
One file per language
src/locales/en.json holds every namespace for English, and the other eight are its mirror. There is no per-module translation folder, which is what lets one check see the whole app at once.
English is BASE_LOCALE, declared once in src/core/i18n/index.ts, bundled with the app and used as the fallback. The other eight are dynamic imports, fetched the first time that language is chosen. Their date-fns tables are dynamic too, and deliberately kept out of the eager vendor chunk — naming them there made a reader of one language download all nine.
Switching
import { useLocale } from '@core/i18n/useLocale'
const { setLocale, currentLocale, availableLocales, localeLabel, weekStartsOn } = useLocale()As with everything else, set the config value rather than calling the engine:
useConfig().mergeConfig({ locale: 'fr' })The wiring calls setLocale, waits for the bundle, then re-applies the direction — in that order, because setLocale resets direction to auto after awaiting the chunk, and a pinned direction applied before that would be overwritten a few microtasks later.
Direction
Four codes are right-to-left: ar, he, fa and ur. Direction normally follows the language, and ui.direction overrides it:
ui.direction | Behavior |
|---|---|
'auto' | Follows the language. The default. |
'ltr' | Pinned left-to-right. |
'rtl' | Pinned right-to-left. |
Pinning is what lets the settings drawer preview a right-to-left layout without translating the interface, and it is the fastest way to check your own screens.
import { useDirection } from '@core/i18n/useDirection'
const { setDirection, currentDirection } = useDirection()currentDirection is 'ltr' or 'rtl', and the attribute is written on <html>. Reka UI's primitives never read <html dir> themselves, so App.vue wraps everything in a ConfigProvider carrying the direction and the locale.
Write layout with logical properties — ps-4 and border-s rather than pl-4 and border-l — and a screen mirrors on its own.
Labels that may or may not be keys
Half the engine takes labels from the caller: a schema field's label, a column's label, a nav item's labelKey. uiText is the one rule for all of them.
import { uiText } from '@core/i18n/uiText'
const translated = uiText('products.fields.name')
const literal = uiText('Full name')
const filename = uiText('avatar.png')The first is translated. The other two come back exactly as they went in.
A value is translated only when it looks like a key — dotted, unspaced, starting lower-case — and the base locale defines it. A dotted string the app does not define, such as a filename, is data rather than a key.
Call it inside a render function or a computed. That is what registers the dependency on the active language, so the text re-renders on a switch.
The cost of this design is that a typo renders as itself. warehouses.fields.utilzation with a letter missing paints that dotted string across a table header with nothing in the console:
npm run check:i18nThat is the check that finds it. It reads the direction the coverage report cannot — a key the code names that no file defines — and it also verifies that the base locale's four coupled declarations still agree. In every locale file it rejects a key name that contains a dot, which vue-i18n can never reach, and it compiles every message, so a stray @ or { fails here instead of on the page.
A second script finds the opposite mistake: English written straight into a template or a toast, which no key check can see.
node scripts/check-i18n-literals.mjsIt is not part of check:i18n, because a literal label is a supported case. Run it when you review translations. Brand names and other deliberate English go in its ALLOWED list, with the reason.
Plurals
vue-i18n's default plural handling is English-shaped. Arabic has six CLDR categories — zero, one, two, few for 3 to 10, many for 11 to 99, and other — and its messages carry six branches in that order. A custom rule maps the count onto them, and degrades gracefully: a two-branch string in an Arabic bundle falls back to one and other rather than indexing past the end and printing the raw source.
{
"products": {
"entity": "product | products"
}
}useCrud reads entityLabel for its toasts, which is why "1 product" and "3 products" both inflect in a language that cares.
Formatting
Numbers, dates and currency go through Intl with the app's tag, never the browser's. A reader on an English operating system who has switched the app to Arabic should see Arabic dates.
import { formatCompact, formatCurrency, formatDate, formatNumber } from '@shared/utils/format'
formatDate(order.placedAt)
formatNumber(1284)
formatCurrency(249, 'USD')
formatCompact(1284000)formatCurrencyCompact and kpiCurrencyFormatter cover the dashboard cards. activeLocale(override?) resolves which language a call should use.
@shared/utils/intl adds the pieces Intl exposes but JavaScript's string methods get wrong: graphemes and graphemeCount count user-perceived characters rather than code units, and collator sorts text the way the language does.
Week start comes from the locale's own table, with overrides where the table and the region disagree — Arabic starts on Saturday.
import { weekStartFor } from '@core/i18n/localeMeta'Relative time has its own compact formatter, formatRelativeCompact(date, tag), for the "2h ago" labels in feeds and notifications.
One honest limit
The interface is translated in all nine languages. The demo data — customer names, product titles, chat messages, the seeded rows in every demo module — is English in every language. Translating fixture data would not make the template more useful, and it would grow every locale file for no gain. Your own data comes from your API and is whatever you send.
Adding a language
One command wires all five sites. See Add a language.
Where to go next
- Add a language — the task view.
- Global config —
locale,ui.directionand thei18nandrtlflags.