Back to 0fee
0fee

Internationalization: 15 Backend Languages, 5 Frontend

How 0fee.dev implements 15 backend languages and 5 frontend languages with SolidJS Context API, RTL Arabic, and 1,350+ keys.

Thales & Claude | March 25, 2026 10 min 0fee
i18ninternationalizationsolidjs-contextrtltranslations

A payment platform serving developers across 200+ countries cannot be English-only. The backend checkout pages support 15 languages including Arabic with RTL layout. The frontend dashboard supports 5 languages with 1,350+ translation keys each. The entire system was built without any third-party i18n library -- just SolidJS Context API, TypeScript interfaces, browser language detection, and localStorage persistence.

This article covers the dual-layer translation architecture, the SolidJS Context implementation, the RTL challenge, browser language detection, and the accent correction session that fixed dozens of missing diacritics.

Two Translation Layers

The system uses different translation counts for different surfaces:

LayerLanguagesKeys Per LanguageReason
Backend (checkout)15~40Short checkout-specific strings
Frontend (dashboard)5~1,350Full application interface

The backend needs more languages because checkout pages are customer-facing -- a customer in Japan paying via the hosted checkout should see Japanese text. The frontend needs fewer languages but more depth because it is the developer dashboard -- and most developers building payment integrations read English, French, Spanish, Portuguese, or Chinese.

Backend: 15 Languages

The backend translation system is a single Python module with nested dictionaries:

python# backend/locales/translations.py

SUPPORTED_LANGUAGES = [
    ("en", "English"),
    ("fr", "Francais"),
    ("es", "Espanol"),
    ("pt", "Portugues"),
    ("de", "Deutsch"),
    ("it", "Italiano"),
    ("nl", "Nederlands"),
    ("ar", "العربية"),
    ("zh", "中文"),
    ("ja", "日本語"),
    ("ko", "한국어"),
    ("tr", "Turkce"),
    ("ru", "Русский"),
    ("sw", "Kiswahili"),
    ("ha", "Hausa"),
]

TRANSLATIONS = {
    "en": {
        "checkout_title": "Checkout",
        "select_country": "Select your country",
        "select_method": "Choose payment method",
        "enter_phone": "Enter your phone number",
        "phone_placeholder": "Phone number",
        "enter_otp": "Enter the verification code sent to your phone",
        "otp_placeholder": "6-digit code",
        "pay_button": "Pay {amount} {currency}",
        "processing": "Processing your payment...",
        "check_phone": "Check your phone for the payment prompt",
        "success_title": "Payment Successful",
        "success_message": "Your payment of {amount} {currency} has been processed",
        "failed_title": "Payment Failed",
        "failed_message": "We could not process your payment",
        "retry": "Try Again",
        "cancel": "Cancel",
        "back": "Back",
        "powered_by": "Powered by 0fee.dev",
        "sandbox_notice": "Test mode - no real charges",
    },
    "fr": {
        "checkout_title": "Paiement",
        "select_country": "Choisissez votre pays",
        "select_method": "Choisissez un moyen de paiement",
        "enter_phone": "Entrez votre numero de telephone",
        "phone_placeholder": "Numero de telephone",
        "enter_otp": "Entrez le code de verification envoye sur votre telephone",
        "otp_placeholder": "Code a 6 chiffres",
        "pay_button": "Payer {amount} {currency}",
        "processing": "Traitement de votre paiement en cours...",
        "check_phone": "Consultez votre telephone pour valider le paiement",
        "success_title": "Paiement reussi",
        "success_message": "Votre paiement de {amount} {currency} a ete effectue",
        "failed_title": "Paiement echoue",
        "failed_message": "Nous n'avons pas pu traiter votre paiement",
        "retry": "Reessayer",
        "cancel": "Annuler",
        "back": "Retour",
        "powered_by": "Propulse par 0fee.dev",
        "sandbox_notice": "Mode test - aucun debit reel",
    },
    "ar": {
        "checkout_title": "الدفع",
        "select_country": "اختر بلدك",
        "select_method": "اختر طريقة الدفع",
        "enter_phone": "أدخل رقم هاتفك",
        "phone_placeholder": "رقم الهاتف",
        "processing": "جاري معالجة دفعتك...",
        "success_title": "تم الدفع بنجاح",
        "failed_title": "فشل الدفع",
        "retry": "حاول مرة أخرى",
        "cancel": "إلغاء",
        "back": "رجوع",
    },
    "sw": {
        "checkout_title": "Malipo",
        "select_country": "Chagua nchi yako",
        "select_method": "Chagua njia ya malipo",
        "enter_phone": "Weka nambari yako ya simu",
        "processing": "Tunashughulikia malipo yako...",
        "success_title": "Malipo Yamefanikiwa",
        "failed_title": "Malipo Yameshindwa",
    },
    "ha": {
        "checkout_title": "Biya",
        "select_country": "Zabi kasarku",
        "select_method": "Zabi hanyar biya",
        "enter_phone": "Shigar da lambar wayarka",
        "processing": "Ana aiwatar da biyarka...",
        "success_title": "Biya ta Yi Nasara",
        "failed_title": "Biya ta Kasa",
    },
    # ... 10 more languages
}

Swahili and Hausa are included because they are widely spoken in East and West Africa respectively -- key markets for 0fee.

RTL Support for Arabic

Arabic requires Right-to-Left layout. The checkout template detects Arabic and applies RTL:

html<html lang="{{ lang }}" {% if lang == 'ar' %}dir="rtl"{% endif %}>
css/* RTL-specific overrides */
[dir="rtl"] .checkout-form {
    direction: rtl;
    text-align: right;
}

[dir="rtl"] .phone-input {
    flex-direction: row-reverse;
}

[dir="rtl"] .phone-input .dial-code {
    border-left: 1px solid var(--border);
    border-right: none;
    border-radius: 0 8px 8px 0;
}

[dir="rtl"] .phone-input input {
    border-radius: 8px 0 0 8px;
    text-align: right;
}

[dir="rtl"] .back-button {
    flex-direction: row-reverse;
}

[dir="rtl"] .back-button svg {
    transform: rotate(180deg);
}

The back arrow, phone input layout, and text alignment all reverse. Every icon that implies direction (arrows, chevrons) must be mirrored.

Frontend: 5 Languages with 1,350+ Keys

The frontend uses a more structured approach with TypeScript interfaces for type safety:

Type Definitions

typescript// frontend/src/locales/types.ts
export interface Translations {
  common: {
    loading: string;
    error: string;
    success: string;
    cancel: string;
    save: string;
    delete: string;
    edit: string;
    create: string;
    search: string;
    filter: string;
    export: string;
    refresh: string;
    back: string;
    next: string;
    previous: string;
    confirm: string;
    // ... 50+ common strings
  };
  nav: {
    dashboard: string;
    apps: string;
    transactions: string;
    customers: string;
    wallet: string;
    settings: string;
    // ... navigation labels
    dropdown: {
      profile: string;
      logout: string;
      darkMode: string;
      lightMode: string;
    };
  };
  dashboard: {
    title: string;
    totalTransactions: string;
    totalVolume: string;
    successRate: string;
    recentTransactions: string;
    // ... dashboard-specific strings
  };
  transactions: {
    title: string;
    tableHeaders: {
      id: string;
      amount: string;
      status: string;
      provider: string;
      method: string;
      date: string;
    };
    filters: {
      status: string;
      provider: string;
      dateRange: string;
    };
    // ...
  };
  wallet: {
    balance: string;
    addFunds: string;
    withdraw: string;
    transactionHistory: string;
    // ...
  };
  // ... 15+ more sections
}

Language Files

Each language file implements the full Translations interface:

typescript// frontend/src/locales/en.ts
import type { Translations } from "./types";

export const en: Translations = {
  common: {
    loading: "Loading...",
    error: "An error occurred",
    success: "Success",
    cancel: "Cancel",
    save: "Save",
    delete: "Delete",
    edit: "Edit",
    create: "Create",
    search: "Search...",
    filter: "Filter",
    export: "Export",
    refresh: "Refresh",
    back: "Back",
    next: "Next",
    previous: "Previous",
    confirm: "Confirm",
    // ...
  },
  // ... 1,350 total entries
};
typescript// frontend/src/locales/fr.ts
import type { Translations } from "./types";

export const fr: Translations = {
  common: {
    loading: "Chargement...",
    error: "Une erreur est survenue",
    success: "Succes",
    cancel: "Annuler",
    save: "Enregistrer",
    delete: "Supprimer",
    edit: "Modifier",
    create: "Creer",
    search: "Rechercher...",
    filter: "Filtrer",
    export: "Exporter",
    refresh: "Actualiser",
    back: "Retour",
    next: "Suivant",
    previous: "Precedent",
    confirm: "Confirmer",
    // ...
  },
  // ... 1,350 total entries
};

SolidJS Context API

The translations are provided through a SolidJS Context:

typescript// frontend/src/contexts/LanguageContext.tsx
import { createContext, useContext, createSignal, ParentComponent } from "solid-js";
import { en } from "../locales/en";
import { fr } from "../locales/fr";
import { es } from "../locales/es";
import { pt } from "../locales/pt";
import { zh } from "../locales/zh";
import type { Translations } from "../locales/types";

const LANGUAGES: Record<string, Translations> = { en, fr, es, pt, zh };

interface LanguageContextValue {
  t: () => Translations;
  lang: () => string;
  setLang: (lang: string) => void;
  availableLanguages: { code: string; name: string }[];
}

const LanguageContext = createContext<LanguageContextValue>();

export const LanguageProvider: ParentComponent = (props) => {
  const detectLanguage = (): string => {
    // Check localStorage first
    const saved = localStorage.getItem("lang");
    if (saved && LANGUAGES[saved]) return saved;

    // Check browser language
    const browserLang = navigator.language.split("-")[0];
    if (LANGUAGES[browserLang]) return browserLang;

    // Default to English
    return "en";
  };

  const [lang, setLang] = createSignal(detectLanguage());

  const t = () => LANGUAGES[lang()] || en;

  const updateLang = (newLang: string) => {
    setLang(newLang);
    localStorage.setItem("lang", newLang);
  };

  return (
    <LanguageContext.Provider
      value={{
        t,
        lang,
        setLang: updateLang,
        availableLanguages: [
          { code: "en", name: "English" },
          { code: "fr", name: "Francais" },
          { code: "es", name: "Espanol" },
          { code: "pt", name: "Portugues" },
          { code: "zh", name: "中文" },
        ],
      }}
    >
      {props.children}
    </LanguageContext.Provider>
  );
};

export function useLanguage() {
  const ctx = useContext(LanguageContext);
  if (!ctx) throw new Error("useLanguage must be used within LanguageProvider");
  return ctx;
}

Usage in Components

tsximport { useLanguage } from "../contexts/LanguageContext";

function TransactionsPage() {
  const { t } = useLanguage();

  return (
    <div>
      <h1>{t().transactions.title}</h1>
      <table>
        <thead>
          <tr>
            <th>{t().transactions.tableHeaders.id}</th>
            <th>{t().transactions.tableHeaders.amount}</th>
            <th>{t().transactions.tableHeaders.status}</th>
            <th>{t().transactions.tableHeaders.provider}</th>
            <th>{t().transactions.tableHeaders.date}</th>
          </tr>
        </thead>
      </table>
    </div>
  );
}

Language Switcher Component

tsxfunction LanguageSwitcher() {
  const { lang, setLang, availableLanguages } = useLanguage();

  return (
    <select
      value={lang()}
      onChange={(e) => setLang(e.target.value)}
      class="px-2 py-1 text-sm rounded-lg border border-gray-300
             dark:border-gray-600 bg-white dark:bg-gray-800"
    >
      <For each={availableLanguages}>
        {(language) => (
          <option value={language.code}>{language.name}</option>
        )}
      </For>
    </select>
  );
}

The Accent Correction Session (083)

Session 082 created the Spanish, Portuguese, and Chinese translation files and completed all 1,350 keys per language. However, the French translations were missing accents -- a critical issue for 0fee's French-speaking African user base.

Session 083 was dedicated to fixing this:

BeforeAfterCategory
CreerCreerCommon
GererGererNavigation
ReferenceReferenceTransactions
TelephoneTelephoneCustomer
PeriodePeriodeFilters
DeveloppeurDeveloppeurSettings
DeconnexionDeconnexionAuth
Integrez une foisIntegrez une fois, acceptez partoutHero

Over 50 words received proper French diacritics. The TypeScript compiler caught none of these errors because they are string values, not syntax issues. This is why automated testing for translations is important -- and why we have a project-wide rule against reproducing input flaws in generated content.

Session 083 also fixed Spanish and Portuguese accents:

typescript// Spanish fixes
"Ultimo acceso" -> "Ultimo acceso"
"Cerrar sesion" -> "Cerrar sesion"

// Portuguese fixes
"Notificacoes" -> "Notificacoes"
"Ultimo acesso" -> "Ultimo acesso"

Browser Language Detection

The language detection follows a three-step priority:

1. localStorage (user's explicit choice)
2. navigator.language (browser's language setting)
3. Default: English
typescriptconst detectLanguage = (): string => {
  // Step 1: User's saved preference
  const saved = localStorage.getItem("lang");
  if (saved && LANGUAGES[saved]) return saved;

  // Step 2: Browser language
  const browserLang = navigator.language.split("-")[0];
  // navigator.language returns "fr-FR", "en-US", etc.
  // We split on "-" and take the first part
  if (LANGUAGES[browserLang]) return browserLang;

  // Step 3: Default
  return "en";
};

This means a French-speaking developer visiting 0fee.dev for the first time will see the dashboard in French without any configuration. They can switch to another language at any time, and their choice persists across sessions.

Architecture Decisions

Three deliberate choices shaped the i18n system:

  1. No external library. We considered i18next and typesafe-i18n but decided that SolidJS Context with TypeScript interfaces provides everything we need: type-safe keys, reactive language switching, and zero dependency overhead.
  1. Different language counts per layer. The backend needs broad coverage (15 languages) for customer-facing checkout. The frontend needs deep coverage (1,350+ keys) for the developer dashboard. Trying to maintain 1,350 keys in 15 languages would be impractical.
  1. Static imports, not dynamic loading. All five language files are imported at build time and included in the bundle. This adds ~30KB per language to the bundle but eliminates loading delays when switching languages. For five languages, the trade-off is acceptable.

What We Learned

Building the i18n system taught us three things:

  1. Type-safe translations catch structural errors but not content errors. TypeScript ensures every language file has every key, but it cannot detect missing accents, wrong translations, or grammatical errors. Human review (or in our case, a dedicated correction session) is still necessary.
  1. RTL support is not just text direction. Arabic required reversing form layouts, mirroring directional icons, adjusting phone input components, and testing every visual element. It is a layout system change, not just a CSS property.
  1. Browser detection provides a good default. Most developers' browsers are configured for their preferred language. Using navigator.language as the initial guess means the dashboard "just works" in the right language for most users on first visit.

The i18n system was built in Session 064, expanded in Session 082, and corrected in Session 083. It serves developers in 5 languages and customers in 15, covering the major markets where 0fee operates.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles