When you ship the same product on mobile and on the web, translations tend to drift apart: a label gets fixed in the app and stays wrong on the site. The simplest cure is one dictionary, shared by both, with one clear rule for what happens when a key is missing.
This is the setup I'd recommend after working on NEXO, a daily medical diagnosis game for clinical reasoning practice, which runs in six languages on an Expo app and a Next.js site. The code below is a generic example with a tiny dictionary, not NEXO's actual files. It uses no i18n library, just TypeScript. Libraries like i18next are great too, and the concepts are the same.
How should I structure translation files for React Native and Next.js?
Keep them in a folder both apps can import (a workspace package, or a shared folder with a path alias):
// shared/i18n/messages.ts
export const LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ru'] as const;
export type Locale = (typeof LOCALES)[number];
export const FALLBACK: Locale = 'en';
export function isLocale(value: string | null | undefined): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
}
type Dict = Record<string, string>;
export const messages: Record<Locale, Dict> = {
en: { 'home.title': 'Today', 'home.greeting': 'Hi, {name}!', 'streak.one': '{count} day', 'streak.other': '{count} days' },
es: { 'home.title': 'Hoy', 'home.greeting': '¡Hola, {name}!' },
fr: { 'home.title': "Aujourd'hui", 'home.greeting': '' },
de: {},
pt: { 'home.title': 'Hoje' },
ru: { 'home.title': 'Сегодня', 'streak.one': '{count} день', 'streak.few': '{count} дня', 'streak.many': '{count} дней', 'streak.other': '{count} дня' },
};
Flat keys ('home.title') are easy to grep, easy to diff, and easy to check for missing entries in CI. Notice the empty French greeting. Half-finished files look like that all the time, and your fallback should handle it.
What should happen when a translation is missing?
Decide it once, in one function. A sensible chain is: current language, then the fallback language, then the key itself. An empty string counts as missing, so a blank entry never renders as a blank button:
// shared/i18n/t.ts
import { messages, FALLBACK, type Locale } from './messages';
export function lookup(locale: Locale, key: string): string | undefined {
const value = messages[locale][key];
return value ? value : undefined; // '' counts as missing
}
export function translate(
locale: Locale,
key: string,
params?: Record<string, string | number>,
): string {
const template = lookup(locale, key) ?? lookup(FALLBACK, key) ?? key;
return template.replace(/\{(\w+)\}/g, (_, k) =>
params && k in params ? String(params[k]) : `{${k}}`,
);
}
translate('fr', 'home.greeting', { name: 'Ana' }); // "Hi, Ana!" (French entry is empty)
translate('es', 'home.subtitle'); // "home.subtitle" (missing everywhere)
Returning the raw key is deliberate. In development, home.subtitle on screen tells you exactly what's missing. In code, you can detect it (result === key) and choose a different fallback for a specific case.
A tip from experience: long content (articles, product descriptions, help pages) often deserves a different rule from interface labels. A missing button label in English is fine. A paragraph that switches language halfway through is not. Whatever you choose there, make it an explicit decision rather than a side effect of the UI fallback.
How do I handle plurals across languages?
English has two plural forms. Russian has more, and the choice depends on the number's last digits: 1 день, 2 дня, 5 дней, 21 день. Intl.PluralRules knows the rules for each locale:
import { lookup, translate } from './t';
export function plural(locale: Locale, baseKey: string, count: number) {
const category = new Intl.PluralRules(locale).select(count); // 'one' | 'few' | 'many' | 'other' ...
const key = `${baseKey}.${category}`;
const hasKey = lookup(locale, key) ?? lookup(FALLBACK, key);
return translate(locale, hasKey ? key : `${baseKey}.other`, { count });
}
plural('ru', 'streak', 3); // "3 дня"
plural('ru', 'streak', 21); // "21 день"
plural('en', 'streak', 3); // "3 days"
Always define .other in every language that has plurals. It's the category Intl.PluralRules returns for fractions in Russian, and it's the safety net when a category is missing.
Intl.PluralRules is available in modern browsers and Node. On React Native, check what your JavaScript engine supports; if it's missing, add a polyfill such as @formatjs/intl-pluralrules with the locale data you need.
Use Intl.DateTimeFormat(locale) and Intl.NumberFormat(locale) for dates and numbers too, instead of formatting them by hand.
How do I detect the device language in Expo?
expo-localization gives you the user's preferred locales in order:
// app/i18n/LanguageProvider.tsx
import { getLocales } from 'expo-localization';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { FALLBACK, isLocale, type Locale } from '@/shared/i18n/messages';
import { translate } from '@/shared/i18n/t';
const KEY = 'app.language';
function deviceLocale(): Locale {
for (const l of getLocales()) {
if (isLocale(l.languageCode)) return l.languageCode;
}
return FALLBACK;
}
type I18n = {
locale: Locale;
setLocale: (l: Locale) => void;
t: (key: string, params?: Record<string, string | number>) => string;
};
const Ctx = createContext<I18n | null>(null);
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(deviceLocale);
const userChose = useRef(false);
useEffect(() => {
AsyncStorage.getItem(KEY).then((saved) => {
if (!userChose.current && isLocale(saved)) setLocaleState(saved);
});
}, []);
const setLocale = (l: Locale) => {
userChose.current = true; // an explicit choice beats anything loaded later
setLocaleState(l);
AsyncStorage.setItem(KEY, l);
};
const t = (key: string, params?: Record<string, string | number>) => translate(locale, key, params);
return <Ctx.Provider value={{ locale, setLocale, t }}>{children}</Ctx.Provider>;
}
export const useI18n = () => {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useI18n must be used inside LanguageProvider');
return ctx;
};
The userChose ref covers a subtle case: if saved settings (or a user profile from your backend) load after the user has picked a language, they shouldn't overwrite that choice.
How do I set up locale routes in the Next.js App Router?
Put the locale in the URL. It's shareable, cacheable and good for SEO:
app/
[lang]/
layout.tsx
page.tsx
middleware.ts
Redirect bare paths to a locale based on the Accept-Language header:
// middleware.ts (Next.js 16 renames this file to proxy.ts and the export to `proxy`)
import { NextResponse, type NextRequest } from 'next/server';
import { LOCALES, FALLBACK, isLocale } from '@/shared/i18n/messages';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return;
const header = request.headers.get('accept-language') ?? '';
const preferred = header.split(',').map((p) => p.split(';')[0].trim().toLowerCase().slice(0, 2));
const locale = preferred.find(isLocale) ?? FALLBACK;
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };
Browsers send their languages in order of preference, so taking the first supported one works for almost all traffic. If you need full q-value handling, the negotiator package parses the header properly.
Set <html lang>, reject unknown locales, and tell search engines about every version of the page:
// app/[lang]/layout.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { LOCALES, FALLBACK, isLocale } from '@/shared/i18n/messages';
import { translate } from '@/shared/i18n/t';
export function generateStaticParams() {
return LOCALES.map((lang) => ({ lang }));
}
export async function generateMetadata(
{ params }: { params: Promise<{ lang: string }> },
): Promise<Metadata> {
const { lang } = await params;
const locale = isLocale(lang) ? lang : FALLBACK;
return {
metadataBase: new URL('https://example.com'),
title: translate(locale, 'home.title'),
alternates: {
canonical: `/${locale}`,
languages: {
...Object.fromEntries(LOCALES.map((l) => [l, `/${l}`])),
'x-default': `/${FALLBACK}`,
},
},
};
}
export default async function Layout(
{ children, params }: { children: React.ReactNode; params: Promise<{ lang: string }> },
) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}
In Next.js 15 and later params is a promise, so it's awaited. Typing it as string and narrowing with isLocale keeps TypeScript honest, because the URL can contain anything. The alternates.languages map becomes hreflang links (with metadataBase making them absolute), which help search engines show the right language to the right user.
How do I keep six languages in sync?
A fallback hides gaps, which is good for users and bad for noticing them. Add a CI check that lists keys present in the fallback language but missing or empty elsewhere. It compares base keys, because Russian legitimately has .few and .many entries that English doesn't:
// scripts/check-messages.ts (run with: npx tsx scripts/check-messages.ts)
import { messages, LOCALES, FALLBACK } from '../shared/i18n/messages';
const PLURAL = /\.(zero|one|two|few|many|other)$/;
const base = (key: string) => key.replace(PLURAL, '');
const required = new Set(Object.keys(messages[FALLBACK]).map(base));
let failed = false;
for (const locale of LOCALES) {
if (locale === FALLBACK) continue;
const have = new Set(
Object.entries(messages[locale]).filter(([, v]) => v !== '').map(([k]) => base(k)),
);
const missing = [...required].filter((k) => !have.has(k));
if (missing.length) {
failed = true;
console.error(`${locale}: ${missing.join(', ')}`);
}
}
process.exit(failed ? 1 : 0);
With the example dictionary, it fails and prints lines like fr: home.greeting, streak and de: home.title, home.greeting, streak.
Two more habits help. First, have specialized text reviewed by a native speaker who knows the field; machine translation is a first draft at best, and for a medical product like NEXO one wrong term can change the meaning. Second, use one locale format everywhere (en or en-US) and convert only at the edges.
What I'd keep from this
- One shared dictionary for the app and the site, imported by both.
- One definition of "missing" (absent or empty), applied in one function, so the two platforms can never disagree about it.
-
Intl.PluralRulesinstead of hand-written plural logic, with.otheralways defined. - The language in the URL on the web, and the device language (overridable by the user) in the app.
- A CI check, so gaps show up in a pull request instead of in front of a user.
FAQ
Do I need an i18n library?
Not for small or medium apps. A typed dictionary and a translate() function go a long way. Reach for a library when you need ICU message syntax or translation-platform integrations.
Should the fallback be English?
Usually, for interface text. Choose whatever language most of your users can read.
Can the Expo app and the Next.js site share the same files?
Yes. Plain TypeScript objects work in both, and sharing them is what keeps the two apps from drifting.
I'm building NEXO, a daily medical diagnosis game for clinical reasoning practice, where a wrong guess is scored by its proximity in the ICD-10 hierarchy, available in six languages. See how it works, or play in Spanish or German. On iPhone: App Store.
Every case in NEXO is fictional and written for teaching. NEXO is not medical advice.
Top comments (0)