Multi-Language
FORGE includes a comprehensive internationalization (i18n) system that supports multiple languages, right-to-left (RTL) layouts, translatable database content, and a full frontend translation workflow. The system covers both static UI strings and dynamic user-managed content.
Overview
The multi-language system has three layers:
┌─────────────────────────────────────────────────┐
│ Multi-Language System │
├─────────────────────────────────────────────────┤
│ │
│ 1. Languages Table │
│ Define available languages, directions, │
│ and defaults │
│ │
│ 2. Translations Table │
│ Static UI strings (labels, messages, │
│ validation errors) │
│ │
│ 3. JSONB Translations Column │
│ Dynamic content on translatable models │
│ (contents, menus, lookups) │
│ │
└─────────────────────────────────────────────────┘Database Schema
Languages Table
The languages table defines all available languages:
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
code | VARCHAR(10) | ISO language code (e.g., en, ar, fr) |
name | VARCHAR(100) | English name (e.g., "Arabic") |
native_name | VARCHAR(100) | Native name (e.g., "العربية") |
direction | VARCHAR(3) | Text direction: ltr or rtl |
is_default | BOOLEAN | Whether this is the default language |
is_active | BOOLEAN | Whether the language is enabled |
created_at | TIMESTAMP | Record creation timestamp |
updated_at | TIMESTAMP | Last update timestamp |
Translations Table
The translations table stores static UI strings:
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
group_name | VARCHAR(100) | Translation group (e.g., auth, validation) |
key | VARCHAR(255) | Translation key (e.g., login_button) |
translations | JSONB | Values for all languages |
created_at | TIMESTAMP | Record creation timestamp |
updated_at | TIMESTAMP | Last update timestamp |
Translations JSONB Structure
{
"en": "Login",
"ar": "تسجيل الدخول",
"fr": "Connexion"
}Translatable Models
Models that support translations (contents, menus, lookups) use a JSONB translations column to store per-language versions of their fields:
{
"en": {
"title": "About Us",
"summary": "Learn about our company"
},
"ar": {
"title": "من نحن",
"summary": "تعرف على شركتنا"
}
}TIP
The JSONB approach keeps translations co-located with their parent records, avoiding JOIN-heavy queries. When fetching a record, the backend resolves the correct language based on the request context and returns only the relevant translation.
Admin Interface
Translation Editor
The admin panel provides a side-by-side translation editor for static UI strings:
- Group Filter -- Filter translations by group (auth, validation, common, etc.)
- Search -- Find translations by key or value
- Side-by-Side View -- Edit translations for all languages simultaneously
- Missing Indicator -- Untranslated strings are highlighted
Model Translation Tabs
For translatable models (contents, menus, lookups), the create/edit forms include language tabs. Each tab shows the translatable fields for that language.
┌──────────────────────────────────────────────┐
│ [English] [العربية] [Français] │
├──────────────────────────────────────────────┤
│ │
│ Title: [ About Us ] │
│ │
│ Summary: [ Learn about our company ] │
│ │
│ Details: ┌──────────────────────────────┐ │
│ │ Rich text editor content... │ │
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘Frontend Integration
i18n Provider
The frontend loads translations via an i18n provider that wraps the application:
// components/providers/i18n-provider.tsx
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = useState("en");
const [translations, setTranslations] = useState({});
useEffect(() => {
fetchTranslations(locale).then(setTranslations);
}, [locale]);
return (
<I18nContext.Provider value={{ locale, setLocale, translations }}>
{children}
</I18nContext.Provider>
);
}useTranslation Hook
Access translations anywhere in your components:
function LoginPage() {
const { t } = useTranslation();
return (
<div>
<h1>{t("auth.login_title")}</h1>
<button>{t("auth.login_button")}</button>
</div>
);
}The t() function resolves the key from the current locale's translations. If a key is not found in the active language, it falls back to the default language.
LanguageSwitcher Component
A pre-built component for switching between active languages:
// components/language-switcher.tsx
export function LanguageSwitcher() {
const { locale, setLocale, languages } = useTranslation();
return (
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.native_name}
</option>
))}
</select>
);
}RTL Support
FORGE fully supports right-to-left languages such as Arabic and Hebrew:
HTML Direction
The dir attribute on the <html> element is set based on the active language:
<!-- LTR language (English) -->
<html lang="en" dir="ltr">
<!-- RTL language (Arabic) -->
<html lang="ar" dir="rtl">CSS Direction
CSS automatically adapts when the dir attribute changes. Use logical properties for consistent layouts:
/* Use logical properties instead of physical ones */
.sidebar {
margin-inline-start: 1rem; /* margin-left in LTR, margin-right in RTL */
padding-inline-end: 0.5rem; /* padding-right in LTR, padding-left in RTL */
}Code Blocks
Code snippets and technical content always render left-to-right regardless of the page direction:
/* Code blocks remain LTR in RTL layouts */
pre, code {
direction: ltr;
text-align: left;
}TIP
Tailwind CSS supports RTL via the rtl: variant. For example, rtl:space-x-reverse reverses horizontal spacing in RTL mode.
CLI Commands
FORGE provides CLI commands for managing languages:
# Add a new language
forge lang:add ar --name "Arabic" --native "العربية" --direction rtl
# Remove a language
forge lang:remove fr
# List all languages
forge lang:list
# Set the default language
forge lang:default enCommand Reference
| Command | Description |
|---|---|
forge lang:add | Add a new language with code, name, and direction |
forge lang:remove | Remove a language and its translations |
forge lang:list | Display all configured languages |
forge lang:default | Set the default language |
WARNING
Removing a language does not delete existing JSONB translation data from model records. The translations remain in the database but are no longer accessible through the UI or API. This is by design to prevent accidental data loss.
User Language Preferences
Each user can have a preferred language stored in their profile. This preference is used for personalized communications like SMS messages, emails, and push notifications.
Database Schema
The users table includes a language_id column that references the languages table:
-- Users table (simplified)
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255),
mobile VARCHAR(20),
name VARCHAR(255),
language_id UUID REFERENCES languages(id) ON DELETE SET NULL,
-- ... other fields
);When a new user registers, they are automatically assigned the application's default language. Users can change their preference at any time via the profile API.
Profile API Endpoint
Update a user's language preference:
# Change language to Arabic
curl -X PUT https://api.myapp.test/api/v1/profile/language \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"language_code": "ar"}'Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
language_code | string | Yes | ISO language code (e.g., en, ar) |
Response:
{
"success": true,
"data": {
"message": "Language updated successfully",
"language_code": "ar"
}
}Fallback Chain
When resolving a user's language, the system follows this fallback chain:
User's language_id → App default language → English (hardcoded)
↓ ↓ ↓
Primary choice If user has no Ultimate fallback
preference set if DB unavailableLocalized SMS Messages
SMS messages (OTP codes, 2FA verification, notifications) are sent in the user's preferred language.
SMS Translation Keys
SMS messages are stored in the translations table with the sms group:
| Group | Key | Description |
|---|---|---|
sms | otp_verification | OTP code for mobile login |
sms | otp_2fa | 2FA verification code for password login |
Example translations:
// sms.otp_verification
{
"en": "Your verification code is: {{code}}. Valid for {{minutes}} minutes.",
"ar": "رمز التحقق الخاص بك هو: {{code}}. صالح لمدة {{minutes}} دقائق."
}
// sms.otp_2fa
{
"en": "Your 2FA code is: {{code}}. Valid for {{minutes}} minutes.",
"ar": "رمز التحقق الثنائي: {{code}}. صالح لمدة {{minutes}} دقائق."
}Message Placeholders
SMS templates support the following placeholders:
| Placeholder | Description | Example |
|---|---|---|
| The OTP or verification code | 764067 |
| Validity period in minutes | 10 |
How It Works
When sending an OTP:
- Existing user: Fetch user's
language_idfrom the database, join withlanguagesto get the code - New user: Use the application's default language (
DEFAULT_LANGUAGEenv var) - Fetch translation: Query
translationstable for thesms.otp_verificationkey - Replace placeholders: Substitute
andwith actual values - Send: Deliver the localized message via the configured SMS provider
┌─────────────────────────────────────────────────────────────┐
│ OTP Send Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User requests OTP │
│ ↓ │
│ 2. Look up user's language preference │
│ (or use default for new users) │
│ ↓ │
│ 3. Fetch localized message template from DB │
│ ↓ │
│ 4. Replace {{code}} and {{minutes}} placeholders │
│ ↓ │
│ 5. Send via SMS provider (Twilio, Unifonic, Slack, etc.) │
│ │
└─────────────────────────────────────────────────────────────┘Environment Configuration
Set the default language for your application:
# .env
DEFAULT_LANGUAGE=ar # New users will receive Arabic SMS by defaultThis value should match one of the language codes defined in your languages table.
Testing with Slack
During development, use the Slack SMS provider to see localized messages in a Slack channel:
SMS_DRIVER=slack
SMS_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
SMS_SLACK_CHANNEL=#sms-testAdding New SMS Templates
To add a new localized SMS message:
- Add the translation key:
INSERT INTO translations (id, group_name, key, translations, created_at, updated_at)
VALUES (
gen_random_uuid(),
'sms',
'password_reset',
'{"en": "Your password reset code is: {{code}}", "ar": "رمز إعادة تعيين كلمة المرور: {{code}}"}',
NOW(),
NOW()
);- Use the SmsMessages service in your code:
// In your service
let message = sms_messages
.get_translation("sms", "password_reset", &user_lang)
.await
.unwrap_or("Your password reset code is: {{code}}".to_string())
.replace("{{code}}", &code);
sms_provider.send(&mobile, &message).await?;