expo-polyglot-ai
v1.0.2
Published
Dynamic AI text translation for React Native and Expo apps featuring background LLM execution, built-in FlatList batch translation, and persistent local caching.
Maintainers
Readme
expo-polyglot-ai
Most apps speak one language and silently lose everyone else. expo-polyglot-ai changes that — drop it in, point it at any LLM, and your Expo app speaks 200+ languages automatically. Polyglot (n.) — a person, or in this case an app, fluent in many languages. Stop asking your users to meet you in English. Let them open your app and feel at home — in Spnaish, French, Arabic, Hindi, or whichever of the 200+ supported languages they call their own. No dictionary maintenance. No heavy lifting, just automated translating.
Just your existing app strings, your LLM of choice, and persistent local caching that means each string is only ever translated once. expo-polyglot-ai — because an app your users can read is an app they'll actually keep.
Features
- 🔄 Drop-in
<TranslateText>component — swap<Text>for<TranslateText>and any string translates automatically. No keys, no string IDs, just your actual text. - 💾 Permanent per-language cache — every translated string is stored in AsyncStorage. Each unique string is only ever sent to your API once. After that, it's instant — even offline.
- 🌍 Zero-config language detection — reads the device locale via
expo-localizationon first launch. If the user's phone is in French, your app is in French. No prompt, no onboarding step. - 🔌 LLM-agnostic backend — point it at Claude, OpenAI, Gemini, or any custom endpoint. You own the model, the cost, and the prompt. The package just handles the wiring.
- 🗂️ Static bundle support — pre-load auto generated translations via the
bundlesprop for your all app strings. Bundle hits are checked first, before cache, before any network call. - 🧠 Smart Flatlist batch engine with
flushBatch— everytranslate()call within a 50ms window is grouped into one API request. Ten components mounting at once with dynamic data? One network call, not ten. - 🔁
currentLanguage&changeLanguage— read and set the active language anywhere in your app. Switching language flushes the pending queue instantly so no stale translations bleed through. - ⏳
cacheReadyflag — know exactly when the AsyncStorage cache has loaded for the current language. Translations wait for it automatically so nothing resolves against an empty cache.
Installation
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 down menu list'Setup
Wrap your app with TranslationProvider, pointing apiUrl at your translation backend:
import { TranslationProvider } from 'expo-polyglot-ai';
export default function App() {
// The developer defines what language translations got generated in their specific project
const MY_APP_BUNDLES = {
fr: require("../../locales/fr.json"), //French translation
es: require("../../locales/es.json"), //Spanish translation
yo: require("../../locales/yo.json"), //Yoruba translation
zu: require("../../locales/zu.json"), //IsiZulu translation
//de: require("../../locales/de.json"), // They can add German!
//it: require("../../locales/it.json"), // They can add Italian!
};
return (
<TranslationProvider
apiUrl="https://..." //This is the url of your cloud function that hosts your Translation LLM Agent.
bundles={MY_APP_BUNDLES}
apiKey={"5Sl-..."} // Developers pass their own runtime API key safely via configuration or props!
>
<YourApp />
</TranslationProvider>
);
}Usage
Translate a single string
import { TranslateText } from 'expo-polyglot-ai';
<TranslateText style={styles.title}>Welcome back!</TranslateText>Switch languages
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 "../../polyglot/src/index.js";
// 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?",
},
{ id: "4", phrase: "View profile notification alerts." },
];
// 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"),
zu: require("../../locales/zu.json"),
//de: require("../../locales/de.json"), // They can add German!
//it: require("../../locales/it.json"), // They can add Italian!
};
function MainAppScreen() {
const { currentLanguage, changeLanguage } = useTranslation();
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" />
{/* Dynamic Translated Static Heading */}
{/* You can style the text in NativeWind e.g className */}
<TranslateText style={styles.heading} autoShrink>Choose Language</TranslateText>
{/*Nativewind styling */}
<TranslateText
autoShrink
className="text-lg font-bold text-slate-900 dark:text-white tracking-tight"
>
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.Item label="Zulu (isiZulu)" value="zu" />
</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}>Header for:</TranslateText>
<TranslateText autoShrink 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://us-central1-..."
bundles={MY_APP_BUNDLES}
apiKey={"5Sl-..."} // Developers pass their own runtime API key safely via configuration or props!
>
<MainAppScreen />
</TranslationProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
backgroundColor: "#f8f9fa",
},
heading: {
fontSize: 26,
fontWeight: "bold",
marginTop: 20,
marginBottom: 10,
color: "#1a1a1a",
},
subHeading: {
fontSize: 18,
fontWeight: "600",
marginTop: 15,
marginBottom: 10,
color: "#555",
},
pickerContainer: {
backgroundColor: "#ffffff",
borderRadius: 10,
borderWidth: 1,
borderColor: "#e0e0e0",
marginBottom: 10,
overflow: "hidden",
},
picker: {
width: "100%",
height: 50,
},
listContainer: {
paddingBottom: 20,
},
card: {
padding: 18,
backgroundColor: "#ffffff",
borderRadius: 12,
marginVertical: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
itemText: {
fontSize: 16,
color: "#333333",
lineHeight: 22,
},
});
Backend API contract
Your apiUrl endpoint should accept POST requests:
Single text:
{ "text": "Hello", "target_lang": "fr" }Response:
{ "translated_text": "Bonjour" }Batch:
{ "texts": ["Hello", "Goodbye"], "target_lang": "fr" }Response:
{ "translated_texts": ["Bonjour", "Au revoir"] }The backend is yours to build — point it at Claude, OpenAI, Gemini, or any LLM/translation API.
Langgraph LLM AI Translation Agent using Gemini
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_final(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.dumps({"error": str(e)}), 500, headers)
Requirement Txt document
functions-framework==3.* google-genai>=1.2.0 langgraph>=0.0.10
gcloud function to deploy the llm-translation agent
cd into where main.py and requirements.txt are and run these commands in the Google Cloud SDK Shell:
gcloud functions deploy polyglot-ai-agent --gen2 --runtime python311 --trigger-http --allow-unauthenticated --region=us-central1 --entry-point polyglot_endpoint --project=your project id --set-env-vars GEMINI_API_KEY="Your Gemini API-KEY",TRANSLATION_API_KEY="Your X-API_KEY"
To give you clearance and authority to use the deployed agent:
gcloud beta run services add-iam-policy-binding polyglot-endpoint --region=us-central1 --member="allUsers" --role="roles/run.invoker"Add this script in your Package.json to run the command "npm run translate", add extract-strings.js and translate-bundle.js files below before you run the command.
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"translate": "node src/build/extract-strings.js src/app strings.json && node src/build/translate-bundle.js strings.json src/locales Your XPI-Key fr,es,yo,zu"
},
"private": true
Create build folder and paste these in:
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();
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();
npm run translate
This is for automatically crawling your app and getting all strings in xxx tag and translating them for you to the languages you want. Please run the the "npm run translate" as soon as your configurations are done.
Ensure this const LANGUAGES = (process.argv[5] || "fr,es,yo,zu,de,it") in translte bundle has the same language values in package.json script "translate": "node src/build/extract-strings.js src/app strings.json && node src/build/translate-bundle.js strings.json src/locales Your XPI-Key fr,es,yo,zu".
How the translate works
const fs = require("fs");
const path = require("path");
const API_URL =
"API_KEY = Your X-API-KEY";
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")
.split(",")
.map((l) => l.trim())
.filter(Boolean);
// Delay between requests to avoid rate-limiting / quota issues (ms)
const REQUEST_DELAY_MS = 1200;
// Retry settingsThe arguments — process.argv explained
process.argv is how Node.js reads what you type after node script.js in the terminal. The positions are fixed:
| Position | process.argv[n] | What it is | Default if omitted |
|---|---|---|---|
| 0 | process.argv[0] | Path to Node itself | Always there |
| 1 | process.argv[1] | Path to the script | Always there |
| 2 | process.argv[2] | Your Strings file | "strings.json" |
| 3 | process.argv[3] | Output folder | "./locales" |
| 4 | process.argv[4] | Your X-API Key | none — required |
| 5 | process.argv[5] | Languages to translate | "fr,es,yo,zu" |//examples
So when npm run translate runs, it's calling something like:
src/build/translate-bundle.js strings.json ./locales YOUR_API_KEY fr,es,yo,zuWhat each argument does
STRINGS_FILE — argv[2]
The JSON file that extract-strings.js produced in the step before this one. It contains every unique string found in your source code — things like "Welcome back", "Save Changes", "Add to Cart". This is the input.
OUTPUT_DIR — argv[3]
The folder where translated files land. After running, you'll get something like:
locales/
fr.json
es.json
yo.json
zu.jsonEach file is a key-value map of { "original string": "translated string" }.
API_KEY — argv[4]
Your secret key sent as X-API-Key in the request header to your Cloud Function (polyglot-agent). This is the same apiKey prop you pass to in your app. No default — if you omit it the Cloud Function will reject the request.
LANGUAGES — argv[5]
A comma-separated list of language codes to translate into. The default "fr,es,yo,zu" means French, Spanish, Yoruba, and Zulu. You can pass as many as you need: bashnode translate-bundle.js strings.json ./locales YOUR_KEY fr,es,yo,zu,ar,hi,sw REQUEST_DELAY_MS — hardcoded at 1200
Not an argument — it's baked in. It adds a 1.2 second pause between each language request so you don't hammer your Cloud Function and hit Gemini's rate limit (which is exactly the 429 RESOURCE_EXHAUSTED error visible in your terminal at the bottom of the screenshot).
-N/B
Please follow ISO 639-1 — the two-letter language code standard — which is also what expo-localization returns from getLocales()[0]?.languageCode. That's the standard your developers need to follow. The Wikipedia link for ISO 639-1:
https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
Props
TranslationProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| apiUrl | string | required | Your translation backend endpoint |
| bundles | key-value pairs | required | The developer defines what they built in their specific project
example const MY_APP_BUNDLES = {
fr: require("../../locales/fr.json")} |
| apiKey | string | required | Developers pass their own runtime API key safely via configuration or props! |
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 down menu list'
Peer Dependencies
| Package | Version |
|---------|---------|
| expo-localization | >=56.0.6 |
| expo-router | >=56.2.9 |
| @react-native-async-storage/async-storage | >=2.2.0 |
| react | >=19.2.3 |
| react-native | >=0.85.3 |
Compatibility
| Expo SDK | React Native | Status | |----------|-------------|--------| | 56 | 0.81 | ✅ Tested | | 54+ | 0.76+ | ✅ Should work | | Below 52 | Below 0.76 | ⚠️ Not tested |
Troubleshooting
🔍 Troubleshooting & Common Fixes
1. Error: Cannot find module '../../locales/fr.json'
This happens when the relative path inside your require() statement doesn't accurately match your file's location. Because Expo apps use nested file routing, your path depends on how deep your component sits.
- If your file is in
src/app/index.jsx: Use two sets of dots:fr: require("../locales/fr.json") - If your file is deeply nested (e.g.,
src/app/(tabs)/settings.jsx): You need to step up three full levels to exit the routing groups and reach your locales:fr: require("../../../locales/fr.json") - Best Fix: Use Expo's path aliasing shortcut to bypass dot-counting altogether:
fr: require("@/locales/fr.json") // or "~/locales/fr.json"
2. New languages are not showing up in the app picker
If you added a new language code (like ,de,ja) to your package.json script and ran npm run translate, the .json files are physically created on your hard drive, but your app doesn't know they exist yet.
- Fix: You must explicitly register the new files inside your
MY_APP_BUNDLESobject where you initialize the provider:const MY_APP_BUNDLES = { fr: require("../locales/fr.json"), es: require("../locales/es.json"), de: require("../locales/de.json"), // <-- Add this! ja: require("../locales/ja.json"), // <-- Add this! };
3. I updated my code or added a language, but nothing changed
The Metro Bundler heavily caches local asset files (like JSON) to keep your development environment fast. Sometimes it completely misses the fact that a build script modified or generated new translation files.
- Fix: Kill your running terminal process (
Ctrl + C) and restart Expo while forcing it to completely purge its memory cache:npx expo start --clear
4. Error: X-API-Key is missing or invalid
This occurs if the translation script cannot read your AI proxy credential during the build phase.
- Fix (Mac/Linux): Make sure your variable is assigned in your local project terminal session before executing, or prefix the run:
EXPO_PUBLIC_POLYGLOT_API_KEY="your_key_here" npm run translate - Fix (Windows PowerShell): If your
package.jsonscript fails to parse$EXPO_PUBLIC_POLYGLOT_API_KEY, explicitly replace the variable name in your script line with your actual string token, or pass it via standard Windows environment parameters.
5. My translated text is running too long and breaking buttons
Certain languages use significantly longer phrase layouts than English, which can cause text strings to overlap design elements or wrap awkwardly.
- Fix: Avoid using regular Expo
<Text>components for strings that change layout length. Always pass your text into the package's declarative layout guard with theautoShrinkparameter activated:
This automatically downsizes the font-size micro-proportionally to keep your UI looking flawless on small screens.<TranslateText autoShrink style={{ fontSize: 18 }}> Your long dynamic text string here </TranslateText>
📂 Repository
https://github.com/Inspire00/expo-polyglot-ai
📄 License
MIT © Vincent
🪵 Changelog
1.0.0 (2026-06-14)
- 🎉 Initial release
TranslationProvidercontainer with dynamic engine injection- Incremental
extract-strings.jspipeline parser tool - Fully resumable
translate-bundle.jscompiler with context-aware Gemini AI integrations <TranslateText>UI protection layout layer with nativeautoShrinkdownscaling- Full utility-class interoperability supporting NativeWind / Tailwind CSS
- Local
AsyncStoragecaching systems optimized for zero dynamic network latency - Secure environment token proxy layer preventing public API credential exposure
