10 locales, 4 layers: how we internationalized Storyie across Next.js, Expo, email, and AI
Storyie ships in English, Japanese, Chinese, Spanish, Arabic, Hindi, Portuguese, Russian, German, and French. Ten locales sounds ambitious for a solo-built diary app — but the product category makes it unavoidable. A diary is the most personal software a person uses. If the UI speaks the wrong language, the session ends.
This post documents how we approached i18n across the four distinct layers where translated content has to live: the Expo mobile app, the Next.js web app, transactional email, and AI-generated monthly reports. The strategy is different for each layer, and that difference is intentional.
TL;DR
- Four layers, four approaches — trying to unify them under one strategy creates more friction than it solves.
- Mobile: static locale files bundled into the app, detected from the device at startup.
- Web: a small custom Accept-Language parser plus a cookie override — no URL prefixes, no next-intl routing overhead.
- Email: one Markdown template per email type with placeholder substitution; translation maps live in a shared package.
- AI analysis: no translation files at all — a system prompt instruction handles everything.
Layer | Technology | Translation approach |
|---|---|---|
Mobile app | Expo + i18next | Static locale files (10 languages), bundled |
Web app | Next.js Server Components | Accept-Language header + cookie override |
React Email + SST Lambda | Placeholder substitution + translation mapper | |
AI analysis | Anthropic API (Lambda) | Prompt instruction; LLM responds in-language |
Mobile: i18next with static locale files
The Expo side uses i18next and react-i18next — the standard choice for React Native. At startup, the app reads the device locale via expo-localization, matches it against the supported list, and initializes i18next with that language.
// apps/expo/utils/i18n.ts
import * as Localization from "expo-localization";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
export const supportedLanguages = [
"en", "ja", "zh", "es", "ar", "hi", "pt", "ru", "de", "fr"
];
function getDeviceLanguage(): string {
const locales = Localization.getLocales();
if (locales?.length > 0) {
const deviceLang = locales[0].languageTag.substring(0, 2);
return supportedLanguages.includes(deviceLang) ? deviceLang : "en";
}
return "en";
}
i18n.use(initReactI18next).init({
resources, // translation objects for all 10 locales
lng: getDeviceLanguage(),
fallbackLng: "en",
interpolation: { escapeValue: false },
});The BCP 47 languageTag from expo-localization looks like ja-JP — we take the first two characters and check against our list. Nothing fancy; it just works.
Each locale is a single TypeScript file under apps/expo/locales/, structured as a nested object:
// apps/expo/locales/ja.ts (excerpt)
export default {
translation: {
common: {
save: "保存",
cancel: "キャンセル",
delete: "削除",
},
diary: {
title: "日記",
newEntry: "新しい日記を書く",
},
// ...
},
};The repo has a key-parity check wired as a post-edit hook: whenever a locale file is changed, the hook diffs every language against en.ts and reports any missing or extra keys. That catches the most common failure — adding a string in English and forgetting to add it to the other nine.
Translation in practice: AI does the heavy lifting
Maintaining translations for 10 locales manually is not feasible for a small team. In practice:
- We write and maintain English and Japanese by hand.
- We generate the other eight with Claude, using the English file as the source.
- Quality signals come from App Store reviews, not pre-launch native checks.
AI translation quality has reached a level where for UI copy — short strings, common vocabulary — it is good enough. The gap between "no translation" and "AI translation" is far larger than the gap between "AI translation" and "professional translation." That asymmetry makes the approach defensible.
Web: a small custom locale resolver
The Next.js app detects locale server-side on every request — no client-side hydration needed, since most content is in Server Components. We deliberately avoided next-intl's routing middleware because its default behavior adds locale prefixes to URLs (e.g., /ja/diary/123). Diary URLs are shared between users, and locale information in the URL felt like the wrong trade-off. Instead, locale is invisible: inferred automatically, overridable explicitly.
// apps/web/lib/utils/locale.ts
export async function getPreferredLocale(): Promise<SupportedLocale> {
// 1. Explicit user preference stored in a cookie
const cookieStore = await cookies();
const cookieLang = cookieStore.get("NEXT_LOCALE")?.value;
if (cookieLang && SUPPORTED_LOCALES.includes(cookieLang)) {
return cookieLang;
}
// 2. Browser's Accept-Language header
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
if (acceptLanguage) {
const matched = parseAcceptLanguage(acceptLanguage);
if (matched) return matched;
}
return "en"; // default
}The Accept-Language parser is hand-rolled — small enough that a third-party dependency adds more weight than value:
export function parseAcceptLanguage(header: string): SupportedLocale | undefined {
const languages = header
.split(",")
.map((lang) => {
const [code, qValue] = lang.trim().split(";q=");
const quality = qValue ? parseFloat(qValue) : 1.0;
return { code: code.split("-")[0].toLowerCase(), quality };
})
.filter((lang) => lang.quality > 0)
.sort((a, b) => b.quality - a.quality);
for (const lang of languages) {
if (SUPPORTED_LOCALES.includes(lang.code)) {
return lang.code;
}
}
return undefined;
}It respects q-values, strips the region subtag (en-US → en), and returns the highest-priority supported locale. If the user explicitly picks a language from the UI, we write NEXT_LOCALE to a cookie and the cookie wins on subsequent requests.
Content language detection: tinyld
The web app also needs to know what language a diary entry is written in — separate from the user's UI language. Someone might use Storyie in English but write their diary in French. That distinction matters for filtering public content and for passing a language hint to the AI analysis pipeline.
import { detect } from "tinyld";
const MIN_TEXT_LENGTH = 20;
export function detectLanguage(text: string, fallback = "en"): string {
if (text.length < MIN_TEXT_LENGTH) return fallback;
const detected = detect(text);
return detected && SUPPORTED_LANGUAGES.has(detected) ? detected : fallback;
}tinyld runs server-side. We extract plain text from the Lexical JSON, run detection, and store the result in a language column. Entries shorter than 20 characters get the fallback — they're too short for reliable detection.
Email: one template, 10 languages
Transactional emails (monthly reports, streak reminders) are rendered with React Email and sent via an SST Lambda cron job. For i18n, we chose placeholder substitution over per-language templates. The alternative — maintaining 10 versions of each email layout — means a copy change to a single button touches 10 files.
The file layout:
apps/web/content/emails/monthly-report.md ← single Markdown template with placeholders
packages/emails/src/translations/ja.ts ← per-language string maps
packages/emails/src/lib/translation-mapper.ts ← injects translated values at send timeThe template itself uses simple curly-brace placeholders:
{greeting}
{intro}
- **{diariesLabel}:** {diaryCount}
- **{wordsLabel}:** {totalWords}
- **{streakLabel}:** {streakDays} {daysLabel}At send time, the translation mapper resolves the recipient's locale and replaces each placeholder with the correct string. Layout changes happen once; only the string values vary by language.
AI analysis: no translation files required
The monthly report includes an AI-written analysis of the user's writing patterns. This is the one layer where we have zero translation infrastructure — and it works.
The system prompt instructs the model to respond in the same language as the diary content:
Rules:
- Match the language of the diary content in your response (Japanese diary → Japanese analysis).LLMs are inherently multilingual. A Japanese diary gets a Japanese analysis; a Spanish diary gets Spanish; a mixed-language diary gets whichever language dominates. We pass the language column value from tinyld detection as an additional hint for short or ambiguous entries.
This is an underused pattern. For any feature where the output language should follow the input language — analysis, summarization, suggestions — instructing the LLM directly is simpler, cheaper, and more maintainable than building a translation layer on top.
What we learned
Each layer has a different optimum
A single i18n strategy across all four layers would require more compromises than the alternatives it avoids:
- Mobile → static locale files. Startup speed and offline support require translations to be in the bundle at runtime.
- Web → header/cookie detection. Server Components can resolve locale before rendering; URL prefixes add routing complexity without user-visible benefit.
- Email → template + substitution. Separates layout concerns from string concerns; a single template survives indefinitely as the string maps grow.
- AI → prompt instruction. No translation infrastructure needed when the model's own multilingual capability is sufficient.
Translation coverage beats translation perfection
The quality gap between AI translation and professional translation is real but small for UI copy. The gap between no translation and AI translation is enormous. Shipping eight AI-translated locales and iterating on quality is the right call — especially when App Store reviews give you direct feedback from native speakers.
Centralize the locale list
The one thing we would change: the list of supported locale codes (["en", "ja", "zh", ...]) currently lives independently in the Expo app, the web app, and the emails package. They have drifted in the past — a new locale added to the web side was missing from email for two weeks. The fix is straightforward: a single SUPPORTED_LOCALES constant in a platform-agnostic shared package, imported everywhere. That refactor is on the list.
Related Posts
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how we share editor logic across Next.js and Expo in the same monorepo - Building a Monorepo with pnpm and TypeScript — workspace conventions and dependency rules
- Building a Cross-Platform Mobile App with Expo — the broader Expo architecture context
Try Storyie
Write in any language at storyie.com or on the iOS app — the UI will match your device locale, the AI analysis will respond in your diary's language, and the formatting carries across both platforms unchanged.