Dynamic CFBundleLocalizations
Automatically sync iOS locale support with your Better i18n CDN languages
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.
CDN manifest already has what you need. The endpoint https://cdn.better-i18n.com/{org}/{project}/manifest.json
returns a languages array with every locale in your project — no new API needed.
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`;
let locales: string[] = ['en'];
try {
// Array args — no shell injection risk
const raw = execFileSync('curl', ['-sf', '--max-time', '5', url], {
encoding: 'utf-8',
});
const manifest = JSON.parse(raw);
locales = manifest.languages.map((l: { code: string }) => l.code);
} catch {
console.warn('[better-i18n] Could not fetch manifest — using fallback locales');
}
const config: ExpoConfig = {
name: 'my-app',
// ...
ios: {
infoPlist: {
CFBundleLocalizations: locales,
CFBundleAllowMixedLocalizations: true,
},
},
};
export default config;This runs only during expo prebuild or eas build — not at runtime. Your users never see the CDN call; it only affects the native build output.
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`;
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);
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"
},
"env": {
"BETTER_I18N_PROJECT": "your-org/your-project"
}
}
}
}Then in app.config.ts, replace the execFileSync block with:
let locales: string[] = ['en'];
try {
const { locales: saved } = require('./.expo-locales.json');
if (Array.isArray(saved) && saved.length > 0) locales = saved;
} catch {
// File doesn't exist yet (local dev without running the hook)
}Add .expo-locales.json to your .gitignore — it's a build artifact, not source code.
# .gitignore
.expo-locales.jsonVerification
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.