coldstart

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:

config.ts
routing.ts
request.ts
navigation.ts
layout.tsx
page.tsx
middleware.ts
en.json
fr.json

src/i18n/config.ts — locale list and default locale:

apps/web/src/i18n/config.ts
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:

apps/web/src/i18n/routing.ts
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:

apps/web/src/i18n/navigation.ts
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:

index.ts
en.ts
fr.ts
es.ts

lib/i18n/index.ts — i18next init with device locale detection:

apps/mobile/lib/i18n/index.ts
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:

apps/web/src/middleware.ts
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:

apps/web/src/app/[locale]/page.tsx
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:

apps/web/src/components/my-component.tsx
"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:

Using locale-aware Link
import { Link } from "@/i18n/navigation";

<Link href="/dashboard">Dashboard</Link>

Use the useTranslation hook from react-i18next:

apps/mobile/app/(tabs)/index.tsx
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:

apps/web/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:

apps/mobile/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:

apps/web/messages/es.json
{
  "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:

apps/mobile/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:

Terminal
coldstart add i18n --locales en,fr,es

This 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:

NamespaceKeysUsed in
metadatatitle, descriptionWeb only (SEO)
landing.herotitle, subtitle, ctaWeb landing page
auth.signIntitle, email, password, submit, ...Auth pages
auth.signUptitle, name, email, password, submit, ...Auth pages
dashboardtitleDashboard
commonloading, error, save, cancel, backShared
hometitleMobile home screen
settingstitleMobile settings screen
tabshome, settingsMobile tab bar

On this page