Skip to content

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:

ColumnTypeDescription
idUUIDPrimary key
codeVARCHAR(10)ISO language code (e.g., en, ar, fr)
nameVARCHAR(100)English name (e.g., "Arabic")
native_nameVARCHAR(100)Native name (e.g., "العربية")
directionVARCHAR(3)Text direction: ltr or rtl
is_defaultBOOLEANWhether this is the default language
is_activeBOOLEANWhether the language is enabled
created_atTIMESTAMPRecord creation timestamp
updated_atTIMESTAMPLast update timestamp

Translations Table

The translations table stores static UI strings:

ColumnTypeDescription
idUUIDPrimary key
group_nameVARCHAR(100)Translation group (e.g., auth, validation)
keyVARCHAR(255)Translation key (e.g., login_button)
translationsJSONBValues for all languages
created_atTIMESTAMPRecord creation timestamp
updated_atTIMESTAMPLast update timestamp

Translations JSONB Structure

json
{
  "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:

json
{
  "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:

tsx
// 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:

tsx
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:

tsx
// 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:

html
<!-- 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:

css
/* 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:

css
/* 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:

bash
# 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 en

Command Reference

CommandDescription
forge lang:addAdd a new language with code, name, and direction
forge lang:removeRemove a language and its translations
forge lang:listDisplay all configured languages
forge lang:defaultSet 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:

sql
-- 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:

bash
# 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:

FieldTypeRequiredDescription
language_codestringYesISO language code (e.g., en, ar)

Response:

json
{
  "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 unavailable

Localized 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:

GroupKeyDescription
smsotp_verificationOTP code for mobile login
smsotp_2fa2FA verification code for password login

Example translations:

json
// 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:

PlaceholderDescriptionExample
The OTP or verification code764067
Validity period in minutes10

How It Works

When sending an OTP:

  1. Existing user: Fetch user's language_id from the database, join with languages to get the code
  2. New user: Use the application's default language (DEFAULT_LANGUAGE env var)
  3. Fetch translation: Query translations table for the sms.otp_verification key
  4. Replace placeholders: Substitute and with actual values
  5. 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:

bash
# .env
DEFAULT_LANGUAGE=ar   # New users will receive Arabic SMS by default

This 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:

bash
SMS_DRIVER=slack
SMS_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
SMS_SLACK_CHANNEL=#sms-test

Adding New SMS Templates

To add a new localized SMS message:

  1. Add the translation key:
sql
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()
);
  1. Use the SmsMessages service in your code:
rust
// 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?;

Released under the MIT License.