Skip to content

Internationalization

FORGE generates full multi-language support across both frontend applications. Translations are loaded from static JSON files (published from the admin panel), the useTranslation hook provides a t() function with interpolation, and RTL layouts are handled automatically based on the active language's direction.

Architecture

┌────────────────────────────────────────────────────────────┐
│                      Admin Panel                           │
│  ┌──────────────────────────────────────────────────────┐  │
│  │           Translation Management Page                │  │
│  │  key: "auth.login_title"                             │  │
│  │  en: "Login to your account"                         │  │
│  │  ar: "تسجيل الدخول إلى حسابك"                        │  │
│  │                                                      │  │
│  │  [Publish] → exports /messages/en.json               │  │
│  │                       /messages/ar.json               │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────┘


┌────────────────────────────────────────────────────────────┐
│                    I18n Provider                           │
│                                                           │
│  1. Read locale from cookie/localStorage                  │
│  2. Fetch /messages/{locale}.json                         │
│  3. Fetch /api/v1/languages (for direction info)          │
│  4. Provide t() function, locale, direction               │
│  5. Set <html lang="..." dir="...">                       │
└────────────────────────────────────────────────────────────┘


┌────────────────────────────────────────────────────────────┐
│                    Components                              │
│                                                           │
│  const { t } = useTranslation();                          │
│  <h1>{t("auth.login_title")}</h1>                         │
│  // → "Login to your account" or "تسجيل الدخول إلى حسابك"  │
└────────────────────────────────────────────────────────────┘

I18n Provider

Both applications wrap their component tree with an I18nProvider that manages locale state, loads translations, and tracks text direction:

tsx
// app/layout.tsx
import { I18nProvider } from "@/components/providers/i18n-provider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <I18nProvider defaultLocale="en" fallbackLocale="en">
          {children}
        </I18nProvider>
      </body>
    </html>
  );
}

Props

PropTypeDefaultDescription
defaultLocalestring"en"Initial locale if no preference is stored
fallbackLocalestring"en"Locale to use if translation file fails to load

How It Works

On mount, the provider:

  1. Reads the locale from the locale cookie, falls back to localStorage, then to defaultLocale
  2. Reads the direction from the direction cookie (set during language switch)
  3. Fetches the language list from /api/v1/languages to get direction metadata
  4. Loads translations from /messages/{locale}.json (a static JSON file)
  5. Sets <html> attributes -- lang and dir -- to match the active locale

Translation Hook

The useTranslation hook is the primary interface for accessing translations in components:

typescript
import { useTranslation } from "@/components/providers/i18n-provider";

function LoginPage() {
  const { t, locale, direction, isLoading } = useTranslation();

  return (
    <div>
      <h1>{t("auth.login_title")}</h1>
      <p>{t("auth.login_subtitle")}</p>
    </div>
  );
}

Return Values

PropertyTypeDescription
t(key: string, params?: Record<string, string | number>) => stringTranslation function
localestringCurrent locale code (e.g., "en", "ar")
direction"ltr" | "rtl"Current text direction
isLoadingbooleanWhether translations are still loading
currentLanguagestringAlias for locale

Interpolation

The t() function supports parameter interpolation using syntax:

typescript
// Translation: "Welcome, {{name}}! You have {{count}} messages."
t("dashboard.welcome", { name: "Ahmed", count: 5 });
// → "Welcome, Ahmed! You have 5 messages."

Key Convention

Translation keys follow a dot-separated namespace convention:

{module}.{section}.{key}

Examples:

KeyEnglishArabic
auth.login_titleLogin to your accountتسجيل الدخول إلى حسابك
admin.users.UsersUsersالمستخدمون
admin.users.Add_UserAdd Userإضافة مستخدم
admin.nav.DashboardDashboardلوحة التحكم
common.DeleteDeleteحذف
common.SaveSaveحفظ

TIP

If a translation key is not found, the t() function returns the key itself. This makes it easy to identify missing translations during development -- untranslated text appears as its key path.

Language Switcher

The LanguageSwitcher component displays a dropdown with available languages. When a language is selected:

  1. Sets locale and direction cookies (shared across subdomains)
  2. Updates localStorage as a fallback
  3. Sets document.documentElement.dir and document.documentElement.lang
  4. Updates the I18n context state (triggers re-render of all translated content)
  5. Calls router.refresh() to update server-rendered content
tsx
import { LanguageSwitcher } from "@/components/language-switcher";

// Renders a Globe icon button with dropdown
<LanguageSwitcher />

The component automatically hides itself when only one language is configured. Each language is displayed using its native_name property (e.g., "English", "العربية", "Francais").

Language preference is persisted in cookies so that server-rendered pages can access the locale before JavaScript loads:

typescript
// Set during language switch
document.cookie = `locale=${langCode};path=/;max-age=31536000`;
document.cookie = `direction=${direction};path=/;max-age=31536000`;

The middleware (or Server Component) reads these cookies to determine the correct locale for server-side rendering:

typescript
// In a Server Component
const headersList = await headers();
const locale = headersList.get("x-locale") || "en";

RTL Support

RTL (Right-to-Left) support is built into every layer of the frontend.

1. HTML Direction

The <html> element's dir attribute is set by the I18n provider:

html
<!-- English -->
<html lang="en" dir="ltr">

<!-- Arabic -->
<html lang="ar" dir="rtl">

2. Logical CSS Properties

FORGE templates use CSS logical properties (ms-, me-, ps-, pe-, start, end) instead of physical directional properties (ml-, mr-, pl-, pr-, left, right). This ensures layouts mirror correctly in RTL mode:

tsx
// Instead of ml-2 (always left margin)
<Icon className="me-2 h-4 w-4" />   // margin-end: adapts to direction

// Instead of pl-8 (always left padding)
<div className="ps-8">              // padding-start: adapts to direction

// Instead of text-left
<span className="text-start">       // start: adapts to direction

WARNING

When adding custom CSS, always use logical properties (ms, me, ps, pe, start, end) instead of physical directional properties (ml, mr, pl, pr, left, right). This ensures your layout works correctly in both LTR and RTL modes.

3. Direction-Aware Components

Several components adapt their behavior based on direction:

  • Sidebar -- Positioned on the left in LTR, right in RTL
  • DirectionAwareToaster -- Toasts appear in the correct corner
  • RichTextEditor -- TinyMCE content direction matches the active locale
  • DataTable pagination -- Navigation arrows flip in RTL

Translatable Content

Resources like roles, content pages, menus, and lookups support per-field translations stored as JSONB in the database. The admin panel provides tabbed editing interfaces for these.

TranslationsInput

For simple resources (roles, permissions) with display_name and description:

tsx
<TranslationsInput
  value={role.translations || {}}
  onChange={(translations) => setRole({ ...role, translations })}
  fields={["display_name", "description"]}
/>

The data shape:

json
{
  "en": { "display_name": "Admin", "description": "Full access" },
  "ar": { "display_name": "مشرف", "description": "وصول كامل" }
}

ContentTranslationsInput

For content pages with rich fields (title, summary, details, button text, SEO):

tsx
<ContentTranslationsInput
  value={content.translations || {}}
  onChange={(translations) =>
    setContent({ ...content, translations })
  }
/>

The data shape:

json
{
  "en": {
    "title": "About Us",
    "summary": "Learn about our company",
    "details": "<p>We are a...</p>",
    "button_text": "Contact Us",
    "seo": {
      "title": "About - MyApp",
      "description": "Learn about our company"
    }
  },
  "ar": {
    "title": "من نحن",
    "summary": "تعرف على شركتنا",
    "details": "<p>نحن شركة...</p>",
    "button_text": "تواصل معنا",
    "seo": {
      "title": "من نحن - تطبيقي",
      "description": "تعرف على شركتنا"
    }
  }
}

Translation Management

The admin panel includes a dedicated Translations page (/translations) where administrators can:

  • View all translation keys grouped by module
  • Edit translation values for each active language
  • Search for specific translation keys
  • Publish translations to static JSON files served by the frontend

The publish action exports all translations for each language into a /messages/{locale}.json file that the I18n provider loads at runtime.

Date and Number Formatting

For locale-aware date and number formatting, use the browser's built-in Intl APIs with the current locale:

typescript
import { useTranslation } from "@/components/providers/i18n-provider";

function FormattedDate({ date }: { date: string }) {
  const { locale } = useTranslation();

  const formatted = new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(new Date(date));

  return <time dateTime={date}>{formatted}</time>;
}

// English: "January 15, 2025"
// Arabic: "١٥ يناير ٢٠٢٥"

Number formatting:

typescript
function FormattedNumber({ value }: { value: number }) {
  const { locale } = useTranslation();

  const formatted = new Intl.NumberFormat(locale).format(value);

  return <span>{formatted}</span>;
}

// English: "1,234,567"
// Arabic: "١٬٢٣٤٬٥٦٧"

Currency formatting:

typescript
function FormattedCurrency({
  value,
  currency = "USD",
}: {
  value: number;
  currency?: string;
}) {
  const { locale } = useTranslation();

  const formatted = new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(value);

  return <span>{formatted}</span>;
}

// English: "$1,234.56"
// Arabic: "١٬٢٣٤٫٥٦ US$"

Adding a New Language

To add a new language to your FORGE application:

  1. Add the language in admin -- Go to Settings > Languages and create a new language entry with the language code, name, native name, and direction:
Code:        fr
Name:        French
Native Name: Francais
Direction:   ltr
Is Active:   true
  1. Add translations -- Go to the Translations page and add translations for all keys in the new language. Use the bulk-create or auto-translate features to speed this up.

  2. Publish translations -- Click the "Publish" button to generate the /messages/fr.json file that the frontend will load.

  3. Add content translations -- For each content page, menu item, and role, add translations under the new language tab in the edit form.

TIP

The language switcher automatically picks up new languages from the /api/v1/languages endpoint. No code changes are needed -- just add the language through the admin panel and publish translations.

Additional Hooks

The I18n provider exports two additional hooks for specific use cases:

useI18n

Full access to the I18n context, including setLocale:

typescript
import { useI18n } from "@/components/providers/i18n-provider";

const { locale, direction, languages, isLoading, t, setLocale } = useI18n();

useLanguages

Focused hook for language management:

typescript
import { useLanguages } from "@/components/providers/i18n-provider";

const { languages, locale, setLocale, isLoading } = useLanguages();

What's Next

Released under the MIT License.