DEV Community

Cover image for Your Expo App Only Speaks English. Here's How to Fix That in 10 Minutes and make it Global
VINCENT PHIRI
VINCENT PHIRI

Posted on

Your Expo App Only Speaks English. Here's How to Fix That in 10 Minutes and make it Global

Most apps speak one language and silently lose everyone else.

If your app is English-only, you are telling a massive portion of the global market to meet you halfway. If you want them to open your app and instantly feel at home—in Spanish, French, Arabic, Hindi, or any of the 200+ global languages they call their own—you usually have to pay the standard "i18n tax."

Look at the traditional i18n package ecosystem on npm. It pulls in over 500,000 active downloads and 180,000 installs in last 7 days.

The demand is massive, but the workflow is stuck in the stone age. Standard i18n forces you into a brutal key-management chore: you invent arbitrary keys, maintain separate massive JSON dictionary files, and manually translate arrays or copy-paste phrases one by one. If you typo a key, your user gets an ugly, broken placeholder string.

We are developers. We are too busy for dictionary maintenance.
That is why I built expo-polyglot-ai—a zero-runtime overhead localization engine that eliminates manual translations entirely.

⚡ The Code IS the Key
With expo-polyglot-ai, you don’t abstract your screens into rigid backend tracking maps. You code naturally. You write plain, normal English directly into your UI layout, and let automated compilation hooks do the heavy lifting.

The Competitor vs. expo-polyglot-ai
The Traditional i18n Chore:

manually. // Breaks your code layout. Forces you to maintain 5 separate JSON files
<Text>{t('auth.dashboard.profile_alert_text_v2')}</Text>

Enter fullscreen mode Exit fullscreen mode

The expo-polyglot-ai Way:

// Write normal text. The build script automatically discovers, translates, and generates your local bundles.
<TranslateText>Welcome back to your security dashboard profile.</TranslateText>

Enter fullscreen mode Exit fullscreen mode

*Why Drop It In?
*

  • •No Dictionary Maintenance: Just your existing app strings, your LLM of choice (like the Gemini gateway proxy), and a terminal command. •0ms Native Performance (Persistent Local Caching): This isn't a slow runtime wrapper that makes users wait for an API call on every screen load. The translation happens at build time. Strings are cached locally, meaning each string is only ever translated once. Your user experiences lightning-fast, zero-network locale snapping. •Layout Defenses Built-In (autoShrink): Different languages occupy different lengths (a short English phrase can turn into a massive sentence in French). By attaching an autoShrink property directly to our custom typography component, the engine micro-scales font sizes down proportionally to preserve your Tailwind/NativeWind layout constraints on smaller devices.

Shift to Autopilot in 5 Quick Steps
1.

npm install expo-polyglot-ai
npx expo install expo-localization @react-native-async-storage/async-storage
npx expo install @react-native-picker/picker  'This provides an easy way to create drop 
Enter fullscreen mode Exit fullscreen mode

2. Replace Text with TranslateText

import { TranslateText } from 'expo-polyglot-ai';

// Before
<Text>Welcome back!</Text>

// After — now speaks French, Zulu, Arabic, Hindi... automatically
<TranslateText>Welcome back!</TranslateText>
Enter fullscreen mode Exit fullscreen mode

Write all your strings in the your app inside these You Strings

3.Wrap the Root Layout


import { Picker } from "@react-native-picker/picker";
import {
  FlatList,
  SafeAreaView,
  StatusBar,
  StyleSheet,
  View,
} from "react-native";
// Import your custom package components cleanly
import {
  TranslateText,
  TranslationProvider,
  useTranslation,
} from "expo-polyglot-ai";

// Mock list data with various string lengths to test layout text expansion
const SAMPLE_MOCK_DATA = [
  { id: "1", phrase: "Welcome back to your security dashboard profile." },
  {
    id: "2",
    phrase: "Please tap application settings to modify your configurations.",
  },
  {
    id: "3",
    phrase: "Are you sure you want to confirm your active logout request?",
  },
];

// The developer defines what they built in their specific project
const MY_APP_BUNDLES = {
  fr: require("../../locales/fr.json"),
  es: require("../../locales/es.json"),
  yo: require("../../locales/yo.json"),
};

Export default function MainAppScreen() {
  const { currentLanguage, changeLanguage } = useTranslation();

  return (
    <SafeAreaView style={styles.container}>
      <StatusBar barStyle="dark-content" />

      {/* Dynamic Translated Static Heading */}
      <TranslateText style={styles.heading}>Choose Language</TranslateText>

      {/* 1. Language Dropdown Selector */}
      <View style={styles.pickerContainer}>
        <Picker
          selectedValue={currentLanguage}
          onValueChange={(langValue) => changeLanguage(langValue)}
          style={styles.picker}
        >
          <Picker.Item label="English (Base)" value="en" />
          <Picker.Item label="French (Français)" value="fr" />
          <Picker.Item label="Spanish (Español)" value="es" />
          <Picker.Item label="Yoruba (Èdè Yorùbá)" value="yo" />
          </Picker>
      </View>

      <TranslateText style={styles.subHeading}>
        Dynamic Feed Content apears here
      </TranslateText>

      {/* 2. FlatList implementation with automatic caching layer protection */}
      <FlatList
        data={SAMPLE_MOCK_DATA}
        keyExtractor={(item) => item.id}
        contentContainerStyle={styles.listContainer}
        renderItem={({ item }) => (
          <View style={styles.card}>
            {/* autoShrink is enabled here! If French/Yoruba text gets 
              too long, it safely shrinks the font to keep layouts unbroken.
            */}
          <TranslateText style={styles.itemText}>{item.phrase}</TranslateText>
          </View>
        )}
      />
    </SafeAreaView>
  );
}

export default function App() {
  return (
    /* 3. Wrap your root with the Provider. 
      Replace this URL string with your deployed Google Cloud Function Webhook endpoint!
    */
    <TranslationProvider
      apiUrl="https://"  //url to your deployed agentic ai in the cloud
      bundles={MY_APP_BUNDLES}
      apiKey={"Your X-API-Key"}// Developers pass their own runtime API key safely via configuration or props!

    >
      <MainAppScreen />
    </TranslationProvider>
  );
}


Enter fullscreen mode Exit fullscreen mode

4. Create the polyglot-ai-agent(Engine) using Langgraph and deploy it to GCP.

import functions_framework
import os
from typing import TypedDict, List
import json
from google import genai
from langgraph.graph import StateGraph, END

# === Single-string agent (unchanged) ===
class AgentState(TypedDict):
    text: str
    target_lang: str
    translation: str
    quality_check: bool
    iterations: int

client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

def translation_node(state: AgentState) -> dict:
    prompt = f"""
    You are an expert mobile UI string localization assistant. 
    Translate the following text into natural, fluent, context-aware {state['target_lang']}.
    Preserve technical variables and UI intent cleanly.
    Return ONLY the raw translated string.

    Text: "{state['text']}"
    """
    response = client.models.generate_content(model='gemini-2.5-flash', contents=prompt)
    return {
        "translation": response.text.strip(),
        "iterations": state.get('iterations', 0) + 1
    }

def validation_node(state: AgentState) -> dict:
    if state.get('iterations', 0) >= 2:
        return {"quality_check": True}

    validation_prompt = f"""
    Analyze this translated mobile application UI string. 
    Did the translation introduce unnecessary conversational phrasing, markdown, or text quotes? 
    Answer with exactly 'YES' if it is clean or 'NO' if it needs a rewrite.

    Translation: "{state['translation']}"
    """
    response = client.models.generate_content(model='gemini-2.5-flash', contents=validation_prompt)
    is_clean = "YES" in response.text.upper()
    return {"quality_check": is_clean}

def should_continue(state: AgentState):
    if state["quality_check"]:
        return END
    return "translator"

workflow = StateGraph(AgentState)
workflow.add_node("translator", translation_node)
workflow.add_node("validator", validation_node)
workflow.set_entry_point("translator")
workflow.add_edge("translator", "validator")
workflow.add_conditional_edges("validator", should_continue)
agent = workflow.compile()

# === Batch translation (new) ===
def translate_batch(texts: List[str], target_lang: str) -> List[str]:
    """
    Translates a list of strings in a single Gemini call.
    Uses a numbered-list format so the model returns strings in order,
    and we can reliably split the response back into a list.
    """
    numbered_input = "\n".join(f"{i+1}. {t}" for i, t in enumerate(texts))

    prompt = f"""
You are an expert mobile UI string localization assistant.
Translate each of the following numbered UI strings into natural, fluent,
context-aware {target_lang}. Preserve technical variables and UI intent.

Return ONLY a JSON array of strings, in the exact same order as the input,
with no markdown, no code fences, no extra commentary. The array must have
exactly {len(texts)} elements.

Input strings:
{numbered_input}
"""

    response = client.models.generate_content(model='gemini-2.5-flash', contents=prompt)
    raw = response.text.strip()

    # Strip markdown code fences if the model added them anyway
    if raw.startswith("```

"):
        raw = raw.split("\n", 1)[1] if "\n" in raw else raw
        if raw.endswith("

```"):
            raw = raw[:-3]
        raw = raw.strip()
        if raw.startswith("json"):
            raw = raw[4:].strip()

    try:
        translated = json.loads(raw)
        if not isinstance(translated, list):
            raise ValueError("Response is not a JSON array")
    except (json.JSONDecodeError, ValueError):
        # Fallback: if parsing fails, return originals untouched
        return texts

    # Pad/truncate to match input length defensively
    if len(translated) < len(texts):
        translated = translated + texts[len(translated):]
    elif len(translated) > len(texts):
        translated = translated[:len(texts)]

    return [str(t).strip() for t in translated]

@functions_framework.http
def polyglot_endpoint(request):
    if request.method == 'OPTIONS':
        headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'POST',
            'Access-Control-Allow-Headers': 'Content-Type,X-API-Key',
            'Access-Control-Max-Age': '3600'
        }
        return ('', 204, headers)

    headers = {'Access-Control-Allow-Origin': '*'}

    provided_key = request.headers.get('X-API-Key')
    expected_key = os.getenv("TRANSLATION_API_KEY")

    if not expected_key or provided_key != expected_key:
        return (json.dumps({"error": "Unauthorized"}), 401, headers)

    try:
        request_json = request.get_json(silent=True)
        target_lang = request_json.get('target_lang')

        if not target_lang:
            return (json.dumps({"error": "Missing target_lang parameter"}), 400, headers)

        # === Batch mode: { "texts": [...], "target_lang": "..." } ===
        texts = request_json.get('texts')
        if texts is not None:
            if not isinstance(texts, list) or not all(isinstance(t, str) for t in texts):
                return (json.dumps({"error": "'texts' must be an array of strings"}), 400, headers)

            if len(texts) == 0:
                return (json.dumps({"translated_texts": []}), 200, headers)

            translated_texts = translate_batch(texts, target_lang)
            return (json.dumps({"translated_texts": translated_texts}), 200, headers)

        # === Single mode (existing behavior, unchanged) ===
        text = request_json.get('text')
        if not text:
            return (json.dumps({"error": "Missing input text or target_lang parameters"}), 400, headers)

        initial_input = {"text": text, "target_lang": target_lang, "iterations": 0}
        final_state = agent.invoke(initial_input)

        response_payload = {"translated_text": final_state["translation"]}
        return (json.dumps(response_payload), 200, headers)

    except Exception as e:
        return (json

Enter fullscreen mode Exit fullscreen mode

5. Pop in build folder that holds code for automatically extracting all your app strings and automatically translating all the strings to the languages you wish your app speaks.

Extract-strings.js


const fs = require("fs");
const path = require("path");

const SOURCE_DIR = process.argv[2] || "./src";
const OUTPUT_FILE = process.argv[3] || "strings.json";

const VALID_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];

// Matches: <TranslateText ...>SOME TEXT</TranslateText>
// Captures only simple string children (no JSX expressions/interpolation)
const TRANSLATE_TEXT_REGEX =
  /<TranslateText\b[^>]*>([^<{][\s\S]*?)<\/TranslateText>/g;

function walk(dir, files = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      walk(fullPath, files);
    } else if (VALID_EXTENSIONS.includes(path.extname(entry.name))) {
      files.push(fullPath);
    }
  }
  return files;
}

function extractFromFile(filePath, strings) {
  const content = fs.readFileSync(filePath, "utf8");
  let match;
  while ((match = TRANSLATE_TEXT_REGEX.exec(content)) !== null) {
    let text = match[1];

    // Collapse whitespace/newlines, trim
    text = text.replace(/\s+/g, " ").trim();

    // Skip empty or expression-only content (e.g. {variable})
    if (!text || text.startsWith("{")) continue;

    if (!strings[text]) {
      strings[text] = { sources: [] };
    }
    const relPath = path.relative(process.cwd(), filePath);
    if (!strings[text].sources.includes(relPath)) {
      strings[text].sources.push(relPath);
    }
  }
}

function main() {
  if (!fs.existsSync(SOURCE_DIR)) {
    console.error(`Source directory not found: ${SOURCE_DIR}`);
    process.exit(1);
  }

  const files = walk(SOURCE_DIR);
  console.log(`Scanning ${files.length} files in ${SOURCE_DIR}...`);

  const strings = {};
  for (const file of files) {
    extractFromFile(file, strings);
  }

  const uniqueStrings = Object.keys(strings);
  console.log(`Found ${uniqueStrings.length} unique strings.`);

  // Output format: array of { text, sources }
  const output = uniqueStrings.map((text) => ({
    text,
    sources: strings[text].sources,
  }));

  fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
  console.log(`Written to ${OUTPUT_FILE}`);
}

main();

Enter fullscreen mode Exit fullscreen mode

Translate-bundle.jsx


const fs = require("fs");
const path = require("path");

const API_URL =
  "paste your deployed ai agent url here";

const STRINGS_FILE = process.argv[2] || "strings.json";
const OUTPUT_DIR = process.argv[3] || "./locales";
const API_KEY = process.argv[4];
const LANGUAGES = (process.argv[5] || "fr,es,yo,zu,de,it")
  .split(",")
  .map((l) => l.trim())
  .filter(Boolean);

// Delay between requests to avoid rate-limiting / quota issues (ms)
const REQUEST_DELAY_MS = 1200;

// Retry settings
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 3000;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function translateOne(text, targetLang) {
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    try {
      const response = await fetch(API_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-API-Key": API_KEY,
        },
        body: JSON.stringify({ text, target_lang: targetLang }),
      });

      if (!response.ok) {
        const errBody = await response.text();
        throw new Error(`HTTP ${response.status}: ${errBody}`);
      }

      const data = await response.json();
      if (!data.translated_text) {
        throw new Error("No translated_text in response");
      }
      return data.translated_text;
    } catch (err) {
      console.warn(
        `  Attempt ${attempt}/${MAX_RETRIES} failed for "${text.slice(0, 40)}..." -> ${targetLang}: ${err.message}`,
      );
      if (attempt < MAX_RETRIES) {
        await sleep(RETRY_DELAY_MS);
      } else {
        throw err;
      }
    }
  }
}

async function main() {
  if (!API_KEY) {
    console.error(
      "Missing API key. Usage: node translate-bundle.js <strings.json> <output-dir> <api-key> [languages]",
    );
    process.exit(1);
  }

  if (!fs.existsSync(STRINGS_FILE)) {
    console.error(`Strings file not found: ${STRINGS_FILE}`);
    process.exit(1);
  }

  const strings = JSON.parse(fs.readFileSync(STRINGS_FILE, "utf8"));
  const texts = strings.map((s) => s.text);

  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  console.log(
    `Translating ${texts.length} strings into ${LANGUAGES.length} languages (${LANGUAGES.join(", ")})...`,
  );
  console.log(
    `Estimated time: ~${Math.ceil((texts.length * LANGUAGES.length * REQUEST_DELAY_MS) / 1000 / 60)} minutes\n`,
  );

  for (const lang of LANGUAGES) {
    const outputFile = path.join(OUTPUT_DIR, `${lang}.json`);

    // Load existing translations to resume
    let bundle = {};
    if (fs.existsSync(outputFile)) {
      bundle = JSON.parse(fs.readFileSync(outputFile, "utf8"));
      console.log(
        `[${lang}] Resuming — ${Object.keys(bundle).length} already translated.`,
      );
    } else {
      console.log(`[${lang}] Starting fresh.`);
    }

    let translatedCount = 0;
    let skippedCount = 0;

    for (let i = 0; i < texts.length; i++) {
      const text = texts[i];

      if (bundle[text]) {
        skippedCount++;
        continue;
      }

      try {
        const translated = await translateOne(text, lang);
        bundle[text] = translated;
        translatedCount++;

        console.log(
          `[${lang}] (${i + 1}/${texts.length}) "${text.slice(0, 50)}${text.length > 50 ? "..." : ""}" -> "${translated.slice(0, 50)}${translated.length > 50 ? "..." : ""}"`,
        );

        // Save progress after every translation (resumable)
        fs.writeFileSync(outputFile, JSON.stringify(bundle, null, 2));

        await sleep(REQUEST_DELAY_MS);
      } catch (err) {
        console.error(
          `[${lang}] FAILED for "${text.slice(0, 50)}...": ${err.message}`,
        );
        console.error(
          `[${lang}] Skipping this string for now — re-run script later to retry.`,
        );
      }
    }

    console.log(
      `[${lang}] Done. Translated ${translatedCount} new, skipped ${skippedCount} cached. Total: ${Object.keys(bundle).length}\n`,
    );
  }

  console.log("All languages processed. Bundles written to:", OUTPUT_DIR);
}

main();


Enter fullscreen mode Exit fullscreen mode

Bind the Automator (package.json)

JSON
"scripts": {
"translate": "node src/build/extract-strings.js src/app strings.json && node src/build/translate-bundle.js strings.json src/locales $EXPO_PUBLIC_POLYGLOT_API_KEY fr,es,de,ja"
}
Enter fullscreen mode Exit fullscreen mode

Run It
Whenever you add copy, build new screens, or scale to new countries, just fire:

npm run translate
Enter fullscreen mode Exit fullscreen mode

The script analyzes your files incrementally, ignores previously translated pairs to protect your API usage, and builds missing language targets in under 30 seconds.
And just like that your app is multi-lingual. When you build it, all translations will be there locally.

🔥 Retention is Readability
Stop asking your international users to meet you in English. Because an app your users can read is an app they'll actually keep.

_Built for Expo developers who'd rather ship features than maintain translation files.
_


📦 npm: npm install expo-polyglot-ai ⭐ GitHub: github.com/Inspire00/expo-polyglot-ai

Top comments (0)