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:
| Layer | Languages | Keys Per Language | Reason |
|---|---|---|---|
| Backend (checkout) | 15 | ~40 | Short checkout-specific strings |
| Frontend (dashboard) | 5 | ~1,350 | Full 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:
| Before | After | Category |
|---|---|---|
| Creer | Creer | Common |
| Gerer | Gerer | Navigation |
| Reference | Reference | Transactions |
| Telephone | Telephone | Customer |
| Periode | Periode | Filters |
| Developpeur | Developpeur | Settings |
| Deconnexion | Deconnexion | Auth |
| Integrez une fois | Integrez une fois, acceptez partout | Hero |
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: Englishtypescriptconst 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:
- 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.
- 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.
- 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:
- 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.
- 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.
- Browser detection provides a good default. Most developers' browsers are configured for their preferred language. Using
navigator.languageas 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.