Internationalization
next-intl for web, i18next for mobile — multi-language from day one
Coldstart generates a complete i18n setup when you enable it during coldstart init or add it later with coldstart add i18n. Web uses next-intl, mobile uses i18next — both share the same locale list and translation keys where possible.
What's generated
next-intl v4 with App Router integration:
src/i18n/config.ts — locale list and default locale:
export const locales = ["en", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";src/i18n/routing.ts — next-intl routing config:
import { defineRouting } from "next-intl/routing";
import { defaultLocale, locales } from "./config";
export const routing = defineRouting({
locales,
defaultLocale,
});src/i18n/navigation.ts — locale-aware navigation helpers:
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);i18next + react-i18next with Expo Localization for device locale detection:
lib/i18n/index.ts — i18next init with device locale detection:
import * as Localization from "expo-localization";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en";
import fr from "./locales/fr";
const resources = {
en: { translation: en },
fr: { translation: fr },
};
i18n.use(initReactI18next).init({
resources,
lng: Localization.getLocales()[0]?.languageCode ?? "en",
fallbackLng: "en",
interpolation: { escapeValue: false },
});
export default i18n;Translations are pre-generated for up to 7 locales: en, fr, es, pt, de, ar, ja. Each locale file exports a typed object with keys for home, settings, common, and tabs.
How locale routing works (Web)
Next.js middleware intercepts every request and determines the locale from the URL path:
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
const intlMiddleware = createIntlMiddleware(routing);
export const middleware = (request: NextRequest) => {
// Auth-protected paths are checked before i18n
const { pathname } = request.nextUrl;
const segments = pathname.split("/");
const locales: string[] = ["en", "fr"];
const pathWithoutLocale = locales.includes(segments[1])
? `/${segments.slice(2).join("/")}`
: pathname;
const isProtected = protectedPaths.some(p => pathWithoutLocale.startsWith(p));
if (isProtected) {
const sessionCookie = request.cookies.get("better-auth.session_token");
if (!sessionCookie?.value) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
}
return intlMiddleware(request);
};
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};URLs follow the pattern /<locale>/path — for example /fr/dashboard or /en/pricing. The default locale can optionally be prefix-free.
Using translations
In Server Components, use getTranslations:
import { getTranslations } from "next-intl/server";
const HomePage = async () => {
const t = await getTranslations("landing.hero");
return (
<h1>{t("title")}</h1>
);
};
export default HomePage;In Client Components, use useTranslations:
"use client";
import { useTranslations } from "next-intl";
export const MyComponent = () => {
const t = useTranslations("common");
return <button>{t("save")}</button>;
};For navigation, use the locale-aware Link from @/i18n/navigation:
import { Link } from "@/i18n/navigation";
<Link href="/dashboard">Dashboard</Link>Use the useTranslation hook from react-i18next:
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
const HomeScreen = () => {
const { t } = useTranslation();
return (
<View className="flex-1 items-center justify-center bg-background">
<Text className="text-2xl font-bold text-foreground">
{t("home.title")}
</Text>
</View>
);
};
export default HomeScreen;Adding a new language
Update the locale list
Add the new locale to src/i18n/config.ts:
export const locales = ["en", "fr", "es"] as const; // added "es"Update the middleware locale array to match.
Add the import and resource entry in lib/i18n/index.ts:
import es from "./locales/es";
const resources = {
en: { translation: en },
fr: { translation: fr },
es: { translation: es }, // added
};Create translation files
Copy messages/en.json to messages/es.json and translate all values:
{
"metadata": { "title": "Mi App", "description": "..." },
"landing": {
"hero": {
"title": "Bienvenido a Mi App",
"subtitle": "...",
"cta": "Comenzar"
}
},
"auth": {
"signIn": { "title": "Iniciar Sesión", "email": "Correo", ... }
},
"common": { "save": "Guardar", "cancel": "Cancelar", ... }
}Create lib/i18n/locales/es.ts:
export default {
home: { title: "Mi App" },
settings: { title: "Configuración" },
common: {
loading: "Cargando...",
error: "Ocurrió un error",
save: "Guardar",
cancel: "Cancelar",
back: "Volver",
},
tabs: { home: "Inicio", settings: "Configuración" },
} as const;Verify
Run pnpm build to check for missing keys or type errors. On web, next-intl will warn at runtime if a key is missing for a locale.
Adding i18n after scaffold
If you scaffolded without i18n, add it later:
coldstart add i18n --locales en,fr,esThis generates all the files above and patches existing layouts and middleware.
Mobile detects the device language via expo-localization and falls back to the default locale. No URL routing is needed — the language follows the user's device settings.
Translation key structure
Both web and mobile share a similar key structure for consistency:
| Namespace | Keys | Used in |
|---|---|---|
metadata | title, description | Web only (SEO) |
landing.hero | title, subtitle, cta | Web landing page |
auth.signIn | title, email, password, submit, ... | Auth pages |
auth.signUp | title, name, email, password, submit, ... | Auth pages |
dashboard | title | Dashboard |
common | loading, error, save, cancel, back | Shared |
home | title | Mobile home screen |
settings | title | Mobile settings screen |
tabs | home, settings | Mobile tab bar |