Most technology products are built in English first. The founders think in English, the codebase is in English, the first locale file is en.json, and French or Spanish or Arabic support arrives later -- if it arrives at all. The result is predictable: translations feel like afterthoughts. The UI stretches in odd places where a three-word English label becomes a seven-word French sentence. Right-to-left support is patched in as a CSS hack. And the product never feels native to anyone who does not speak English.
We built the opposite. French is our default language. The first locale file we wrote was fr.json. The fallback is French. The system prompts are in French. The error messages, the credit descriptions, the onboarding flow -- all French first. English is our second language, and Arabic, Swahili, Portuguese, and Spanish follow.
This article explains why, and how we built internationalization across web (SvelteKit + svelte-i18n) and mobile (React Native + i18next) for a continent where language is inseparable from identity.
Why These Six Languages
Africa has over 2,000 languages. Picking six feels absurd. But we are not trying to cover every mother tongue -- we are covering the languages of instruction in African school systems. The languages that appear on the CEPE, the BEPC, the Baccalaureat, the KCSE, the WASSCE. The languages students must write their exams in.
French covers West Africa (Cote d'Ivoire, Senegal, Mali, Cameroon, Democratic Republic of Congo, Burkina Faso, Guinea, Niger, Chad, Togo, Benin) and Central Africa (Gabon, Republic of Congo, Central African Republic). This is our primary market. Over 140 million French speakers in Africa, growing faster than anywhere else on earth.
English covers East Africa (Kenya, Uganda, Tanzania partly), Southern Africa (South Africa, Zimbabwe, Zambia, Malawi, Botswana), and West Africa (Nigeria, Ghana, Sierra Leone, Liberia, The Gambia). English is the second-largest language of instruction on the continent.
Arabic covers North Africa (Morocco, Algeria, Tunisia, Libya, Egypt, Sudan, Mauritania) and parts of East Africa. Arabic-medium education is standard across the Maghreb and the Sahel. It is also the only language in our set that requires right-to-left rendering.
Swahili covers East Africa (Kenya, Tanzania, Uganda, Rwanda, Burundi, eastern DRC). It is the most widely spoken African language by total number of speakers and serves as a lingua franca across the region.
Portuguese covers Mozambique, Angola, Cape Verde, Guinea-Bissau, and Sao Tome and Principe. Portuguese-speaking Africa has over 40 million people, and these countries are among the fastest-growing economies on the continent.
Spanish covers Equatorial Guinea, the only Spanish-speaking country in Africa. Small, but real. We refuse to ignore an entire language community because they are small.
Together, these six languages cover the official language of instruction in 54 out of 55 African Union member states. That is not a coincidence. It is the minimum viable set.
Web: svelte-i18n With Lazy-Loaded Locale Files
On the web, we use svelte-i18n (v4.0.1) with JSON locale files stored in src/lib/i18n/locales/. Each language gets a single JSON file: fr.json, en.json, ar.json, sw.json, pt.json, es.json.
The setup function initializes the library with lazy loading -- locale files are only fetched when needed, not bundled into the main JavaScript:
// src/lib/i18n/index.ts
import { register, init, getLocaleFromNavigator, locale } from 'svelte-i18n';
import { get } from 'svelte/store';const SUPPORTED_LANGS = ['fr', 'en', 'ar', 'sw', 'pt', 'es'] as const; export type SupportedLang = (typeof SUPPORTED_LANGS)[number];
export const LANG_LABELS: Record
let _initialized = false;
export function setupI18n(userLang?: string) { if (_initialized) return; _initialized = true;
SUPPORTED_LANGS.forEach((lang) => {
register(lang, () => import(./locales/${lang}.json));
});
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('deblo_lang') : null; const browserLang = getLocaleFromNavigator()?.split('-')[0] || 'fr';
const initial = (userLang && SUPPORTED_LANGS.includes(userLang as SupportedLang) ? userLang : null) ?? (stored && SUPPORTED_LANGS.includes(stored as SupportedLang) ? stored : null) ?? (SUPPORTED_LANGS.includes(browserLang as SupportedLang) ? browserLang : 'fr');
init({ fallbackLocale: 'fr', initialLocale: initial }); } ```
The initialization priority is deliberate. We check three sources in order:
1. User profile language -- if the user is authenticated and has a saved preference, we use that. This handles the case where a student logs in on a school computer that is configured for English but the student works in French.
2. localStorage -- the last language the user chose in this browser. This survives page reloads and returns.
3. Browser language -- the Accept-Language header, narrowed to the two-letter code. A browser set to fr-CI (French, Cote d'Ivoire) resolves to fr.
4. Default -- if all three are unavailable or unsupported, we fall back to French.
The fallback locale is also French. If a key exists in en.json but is missing from sw.json, the Swahili user sees the French translation -- never a raw key like chat.send_button. This is a conscious choice. A French fallback is more useful than an English fallback for our primary audience.
Changing Language: The Full Synchronization
When a user changes their language from the UI, four things happen simultaneously:
export function setLanguage(lang: SupportedLang | string) {
if (!SUPPORTED_LANGS.includes(lang as SupportedLang)) return;// 1. Update the svelte-i18n locale (triggers reactivity across all components) locale.set(lang);
// 2. Persist to localStorage (survives page reload) if (typeof localStorage !== 'undefined') { localStorage.setItem('deblo_lang', lang); }
// 3. Handle RTL for Arabic if (typeof document !== 'undefined') { document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr'; document.documentElement.lang = lang; }
// 4. Sync with backend (fire-and-forget) fetch('/api/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preferred_language: lang }), }).catch(() => {}); } ```
Step 4 is a fire-and-forget PATCH request. We do not await it. We do not show a loading spinner. We do not retry on failure. The backend update is important -- it ensures the AI responds in the correct language on the next conversation -- but it is not critical to the immediate user experience. If the network call fails, the language change still works locally, and the backend will be updated on the next successful request.
The document.documentElement.dir assignment on step 3 is what triggers right-to-left rendering for Arabic. One line of JavaScript flips the entire application layout. Every margin-left becomes margin-right. Every text-align: left becomes text-align: right. Every flex container reverses its direction. This works because we use Tailwind's logical properties and the browser's built-in RTL handling.
Using Translations in Svelte Components
In Svelte templates, translations are accessed through the $_ reactive function provided by svelte-i18n:
<script>
import { _ } from 'svelte-i18n';
</script>{$_('dashboard.title')}
{$_('dashboard.credit_balance')}: {credits} {$_('common.credits')}
```The $_ function is reactive. When locale.set('en') is called, every component that uses $_ re-renders with the new translations. There is no page reload, no flash of untranslated content. The language switch is instant.
Mobile: i18next With the Shared Package
The React Native mobile app uses i18next with react-i18next, initialized through a shared package @deblo/i18n:
// deblo-mobile/packages/i18n/src/setup.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';import fr from './locales/fr.json'; import en from './locales/en.json';
export const i18n = i18next;
export async function setupI18n(): Promise
const deviceLocales = Localization.getLocales(); const deviceLang = deviceLocales?.[0]?.languageCode ?? 'fr'; const lng = ['fr', 'en'].includes(deviceLang) ? deviceLang : 'fr';
await i18next.use(initReactI18next).init({ resources: { fr: { translation: fr }, en: { translation: en }, }, lng, fallbackLng: 'fr', interpolation: { escapeValue: false }, compatibilityJSON: 'v4', }); } ```
The mobile app currently ships with French and English. Arabic, Swahili, Portuguese, and Spanish will be added as we expand to those markets. The @deblo/i18n package is a monorepo package shared between the main app and any future companion apps. The locale JSON files are imported statically (not lazy-loaded) because React Native bundles all assets at build time.
The compatibilityJSON: 'v4' flag is required by i18next v23+ and ensures consistent plural handling. The escapeValue: false disables HTML escaping since React Native does not render HTML.
Locale File Structure
Our locale files use a flat-nested structure organized by feature area. Here is a representative excerpt from fr.json:
{
"common": {
"loading": "Chargement...",
"error": "Erreur",
"cancel": "Annuler",
"confirm": "Confirmer",
"save": "Enregistrer",
"credits": "credits",
"retry": "Reessayer",
"search": "Rechercher",
"send": "Envoyer",
"delete": "Supprimer",
"download": "Telecharger"
},
"nav": {
"home": "Accueil",
"pricing": "Tarifs",
"login": "Connexion",
"dashboard": "Tableau de bord",
"chat": "Salon Scolaire",
"work_session": "Salon Pro",
"logout": "Deconnexion",
"tasks": "Taches",
"notifications": "Notifications"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle": "Apercu de votre activite",
"greeting_morning": "Bonjour",
"greeting_afternoon": "Bon apres-midi",
"greeting_evening": "Bonsoir",
"credit_balance": "Solde credits",
"conversations_label": "Conversations"
},
"chat": {
"send_button": "Envoyer",
"placeholder": "Pose ta question...",
"new_conversation": "Nouvelle conversation",
"thinking": "Deblo reflechit..."
}
}The namespaces (common, nav, dashboard, chat, categories, subjects, homepage, etc.) keep the file organized as it grows. At the time of writing, fr.json has over 300 keys. English has the same 300+ keys. Arabic, Swahili, Portuguese, and Spanish have between 200 and 280 keys, with any gaps falling back to the French value.
We do not use ICU MessageFormat or complex pluralization rules yet. Our strings are simple label-value pairs. When we need pluralization (rare -- most African French uses simpler plural rules than European French), we handle it with two keys: key_one and key_other.
The AI Responds in the User's Language
Translating the interface is half the problem. The other half is making the AI itself respond in the correct language. When a Swahili-speaking student sends a message, the AI must respond in Swahili -- not in French, not in English.
This is handled in the system prompt assembly. The user's preferred_language is injected into the root system prompt:
# Excerpt from system prompt assembly
language_instruction = {
"fr": "Reponds toujours en francais.",
"en": "Always respond in English.",
"ar": "اجب دائما باللغة العربية.",
"sw": "Jibu kila wakati kwa Kiswahili.",
"pt": "Responda sempre em portugues.",
"es": "Responde siempre en espanol.",
}system_prompt += f"\n\nLANGUAGE: {language_instruction.get(user_lang, language_instruction['fr'])}" ```
This works remarkably well. DeepSeek V3, our primary text model, handles all six languages fluently. Its training data includes substantial French, English, Arabic, Portuguese, and Spanish corpora. Swahili performance is slightly weaker on specialized educational topics -- the model occasionally drops into English for mathematical terminology -- but it is adequate for CP-through-Terminale content.
The RTL Challenge
Arabic was our hardest language to support. Not because of the translation itself -- Arabic is well-supported by modern LLMs -- but because right-to-left rendering affects every pixel of the interface.
When document.documentElement.dir is set to 'rtl', the browser reverses the horizontal axis. Navigation that was on the left moves to the right. Text flows from right to left. Scroll bars swap sides. Flex containers with flex-direction: row effectively become row-reverse.
This sounds simple in theory. In practice, it surfaced dozens of edge cases:
- Icons with directional meaning -- a "back" arrow pointing left must point right in RTL. We use Tailwind's
rtl:variant for these:rtl:rotate-180. - Code blocks -- code snippets must remain LTR even in an RTL document. We add
dir="ltr"to allandelements. - Mixed-direction text -- a French mathematical formula inside an Arabic sentence. The Unicode Bidirectional Algorithm handles most cases, but occasionally we need explicit
elements. - Number formatting -- Arabic uses Eastern Arabic numerals in some contexts and Western Arabic numerals in others. We standardize on Western Arabic (0-9) for consistency, since that is what African school systems use.
- Chat bubbles -- the user's messages should appear on the right in LTR and on the left in RTL. The AI's messages swap accordingly.
The solution was not a separate RTL stylesheet. Tailwind's logical properties (ps-4 instead of pl-4, ms-2 instead of ml-2, start instead of left) handle most layout concerns automatically. We converted our Tailwind classes to use logical properties early in the development process, which made RTL support largely automatic rather than a manual retrofit.
The Terminology Problem
The hardest localization challenge is not technical. It is linguistic.
French as spoken in Cote d'Ivoire is not the same as French spoken in Senegal, Cameroon, or the Democratic Republic of Congo. The same word can mean different things. The school system uses different terminology. A "composition" in Ivorian schools is a "devoir surveille" in Senegalese schools. "Moyenne" means "passing grade" in some systems and "average score" in others.
We handle this by avoiding country-specific terminology wherever possible and using the most widely understood variant. When ambiguity is unavoidable, we add context: "Moyenne (note de passage)" rather than just "Moyenne."
For English, the same problem exists between Nigerian English, Kenyan English, and South African English. For Arabic, the gap between Maghreb Arabic and Egyptian Arabic is significant. We use Modern Standard Arabic (Fusha) for the interface, which is the shared formal register across all Arabic-speaking countries.
This is an ongoing challenge. As we expand to more countries, we may need country-specific locale variants (fr-CI, fr-SN, fr-CM) rather than a single fr.json. But for now, a single locale per language is sufficient.
Managing Translations at Scale
With 300+ keys across 6 languages, translation management is a real concern. Here is our current workflow:
1. French first -- all new features are built with French strings. The developer adds keys to fr.json as part of the feature PR.
2. English second -- English translations are added immediately, usually in the same PR. Both founders are fluent in English and French.
3. Other languages -- Arabic, Swahili, Portuguese, and Spanish translations are done in batches, using a combination of LLM-assisted translation (GPT-4o for initial drafts) and human review by native speakers.
4. Key auditing -- we periodically compare all locale files to find missing keys. Any key present in fr.json but absent from another locale file is flagged. The fallback to French prevents breakage, but we track completion percentage per language.
We do not use a translation management platform like Crowdin or Lokalise. At our current scale (6 languages, 300 keys), the overhead of a TMS exceeds the benefit. A simple JSON diff is sufficient.
What French-First Means for the Codebase
Building French-first has subtle implications throughout the codebase:
- Variable names are in English, strings are in French --
const greeting = $_('dashboard.greeting_morning')resolves to "Bonjour," not "Good morning." - System prompts are in French -- the root prompt, the anti-cheating rules, the Socratic method instructions are all written in French. This is not just a localization choice -- it improves the LLM's French output quality because the system prompt sets the linguistic context.
- Error messages default to French -- a 400 error returns
"Le titre est requis"by default. The API does not yet localize error messages (this is on the roadmap), but French is a reasonable default for our user base. - Database seed data is in French -- subject names, class names, category descriptions are all stored in French in the database.
This approach works because our primary market is Francophone Africa. If we were building for a global audience, English-first would be the pragmatic choice. But we are not building for a global audience. We are building for African students, and the majority of them study in French.
Looking Ahead
Six languages is a starting point. The roadmap includes:
- Wolof (Senegal) -- the most spoken national language in our second-largest market.
- Bambara (Mali) -- critical for reaching students in rural Mali where French proficiency is low.
- Hausa (Nigeria, Niger) -- the third most spoken language on the continent.
- Amharic (Ethiopia) -- Ethiopia's 120 million people use a unique script (Ge'ez) that will require its own rendering considerations.
- Country-specific locale variants --
fr-CI,fr-SN,fr-CMfor Ivorian, Senegalese, and Cameroonian French respectively.
Each of these additions will require not just UI translations but also LLM validation -- we need to confirm that DeepSeek V3 (or whatever model we use at that point) can respond accurately in that language for educational content.
Internationalization is never finished. But with six languages, lazy-loaded locale files, RTL support, and AI language awareness, we have a foundation that can scale to every classroom on the continent.
---
This is article 17 of 20 in the "How We Built Deblo.ai" series.
1. AI Tutoring for 250 Million African Students 2. 100 Sessions Later: The Architecture of an AI Education Platform 3. The Agentic Loop: 24 AI Tools in a Single Chat 4. System Prompts That Teach: Anti-Cheating, Socratic Method, and Grade-Level Adaptation 5. WhatsApp OTP and the African Authentication Problem 6. Credits, FCFA, and 6 African Payment Gateways 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. Building a React Native K12 App in 7 Days 10. 101 AI Advisors: Professional Intelligence for Africa 11. Background Jobs: When AI Takes 30 Minutes to Think 12. From Abidjan to 250 Million: The Deblo.ai Story 13. Generating PDFs, Spreadsheets, and Slide Decks From a Chat Message 14. Organizations: Families, Schools, and Companies on One Platform 15. Interactive Quizzes With LaTeX: Testing Students Inside a Chat 16. RAG Pipeline: Document Search With pgvector and Semantic Chunking 17. Six Languages, One Platform: i18n for Africa (you are here) 18. Tasks, Goals, and Recurring Reminders 19. AI Memory and Context Compression 20. Observability: Tracking Every LLM Call in Production