Dynamic CFBundleLocalizations
Sur cette page
When you add a new language in the Better i18n dashboard, iOS needs to know about it at build time via CFBundleLocalizations in Info.plist. Without it, iOS system features like Siri locale and input mode may fall back incorrectly.
The problem: app.config.ts does not support async/await. Expo evaluates the config file synchronously at build time, so you can't fetch() the manifest there. Languages end up hardcoded — and get out of sync every time the dashboard changes.
import { ExpoConfig } from 'expo/config';
import { execFileSync } from 'child_process';
const PROJECT = 'your-org/your-project';
const url = `https://cdn.better-i18n.com/${PROJECT}/manifest.json`; // [!code highlight]
let locales: string[] = ['en'];
try {
// Array args — no shell injection risk // [!code highlight]
const raw = execFileSync('curl', ['-sf', '--max-time', '5', url], { // [!code highlight]
encoding: 'utf-8',
});
const manifest = JSON.parse(raw);
locales = manifest.languages.map((l: { code: string }) => l.code); // [!code highlight]
} catch {
console.warn('[better-i18n] Could not fetch manifest — using fallback locales');
}
const config: ExpoConfig = {
name: 'my-app',
// ...
ios: {
infoPlist: {
CFBundleLocalizations: locales, // [!code highlight]
CFBundleAllowMixedLocalizations: true,
},
},
};
export default config;For CI / EAS Builds #
EAS Build Hooks run before the native build — they're async, isolated, and the right place for network calls. Use this approach when you need to avoid a curl dependency in CI.
Create the hook script #
const https = require('https');
const fs = require('fs');
// Set BETTER_I18N_PROJECT=org/project in eas.json env
const project = process.env.BETTER_I18N_PROJECT;
if (!project) {
console.warn('[better-i18n] BETTER_I18N_PROJECT not set — writing fallback locales');
fs.writeFileSync('.expo-locales.json', JSON.stringify({ locales: ['en'] }));
process.exit(0);
}
const url = `https://cdn.better-i18n.com/${project}/manifest.json`; // [!code highlight]
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
const manifest = JSON.parse(data);
const locales = manifest.languages.map((l) => l.code); // [!code highlight]
fs.writeFileSync('.expo-locales.json', JSON.stringify({ locales }));
console.log(`[better-i18n] Wrote ${locales.length} locales:`, locales);
} catch (e) {
console.warn('[better-i18n] Failed to parse manifest — writing fallback:', e.message);
fs.writeFileSync('.expo-locales.json', JSON.stringify({ locales: ['en'] }));
}
});
}).on('error', (e) => {
console.warn('[better-i18n] Network error — writing fallback:', e.message);
fs.writeFileSync('.expo-locales.json', JSON.stringify({ locales: ['en'] }));
});Register the hook and read the file #
{
"build": {
"production": {
"hooks": {
"pre-install": "node eas-build-pre-install.js" // [!code highlight]
},
"env": {
"BETTER_I18N_PROJECT": "your-org/your-project" // [!code highlight]
}
}
}
}Then in app.config.ts, replace the execFileSync block with:
let locales: string[] = ['en'];
try {
const { locales: saved } = require('./.expo-locales.json'); // [!code highlight]
if (Array.isArray(saved) && saved.length > 0) locales = saved;
} catch {
// File doesn't exist yet (local dev without running the hook)
}Verification #
After running expo prebuild (or eas build), open ios/<YourApp>/Info.plist and confirm the CFBundleLocalizations array contains the same locales as your Better i18n project.
Better I18N