Internationalization (i18n)
How to add new languages and modify translations in Ship AI SaaS.
Overview
Internationalization is powered by next-intl. Four languages ship out of the box: English (en), French (fr), German (de), and Spanish (es). English is the default.
Translation strings live in messages/{locale}.json — one JSON file per language, organized by feature namespace (e.g., auth, dashboard, pricing).
Architecture
The app uses two distinct routing strategies depending on where the user is:
| Area | Locale source | Example URL |
|---|---|---|
Marketing & Docs (/[locale]/...) | URL segment | /fr/pricing |
| App & Auth routes | NEXT_LOCALE cookie | /dashboard (no prefix) |
This means locale switches on marketing pages change the URL, while locale switches inside the app silently update a cookie and reload.
Key files:
| File | Purpose |
|---|---|
i18n/config.ts | Locale list, display labels, flag emojis |
i18n/request.ts | Server-side locale resolution (URL → cookie → default) |
i18n/navigation.ts | next-intl Link, useRouter, redirect helpers |
messages/ | JSON translation files (one per locale) |
types/next-intl.d.ts | Auto-generated TypeScript types from en.json |
Adding a New Language
Add the locale to i18n/config.ts
Open i18n/config.ts and add your locale code to all three exported objects:
export const locales = ['en', 'fr', 'de', 'es', 'it'] as const; // add 'it'
export const localeLabels: Record<Locale, string> = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
es: 'Español',
it: 'Italiano', // add this
};
export const localeFlags: Record<Locale, string> = {
en: '🇺🇸',
fr: '🇫🇷',
de: '🇩🇪',
es: '🇪🇸',
it: '🇮🇹', // add this
};Create the translation file
Copy messages/en.json to messages/it.json and translate all the values. Keep every key identical — only the values change:
{
"common": {
"save": "Salva",
"cancel": "Annulla",
"loading": "Caricamento..."
},
"nav": {
"dashboard": "Dashboard",
"pricing": "Prezzi"
}
}You don't need to translate every string immediately. Any missing key falls back to the en.json value at runtime.
TypeScript types update automatically
types/next-intl.d.ts derives its types by importing en.json. No manual changes are needed — TypeScript will validate your usage of the new locale at compile time as soon as you save.
Restart the dev server
npm run devYour new language is now live. Marketing routes are accessible at /{locale}/... (e.g., /it/pricing) and the language switcher in both the marketing header and the app sidebar will show the new option.
Modifying Existing Translations
Edit the relevant messages/{locale}.json file directly. The files use a nested structure grouped by feature:
{
"auth": {
"login": {
"title": "Sign in to your account",
"submitButton": "Sign in"
},
"messages": {
"invalidCredentials": "Invalid email or password."
}
},
"dashboard": {
"title": "Dashboard"
}
}Find the key you want to change, update its value, and save. The dev server picks up changes instantly via hot reload.
When you change a key in one locale file, update all other locale files to match. A key present in en.json but missing from fr.json will render the English string as a fallback — no runtime error, but inconsistent UX.
Using Translations in Your Own Components
Server components — use the async getTranslations() function:
import { getTranslations } from "next-intl/server";
export default async function PricingPage() {
const t = await getTranslations("pricing");
return <h1>{t("title")}</h1>;
}Client components — use the useTranslations() hook:
"use client";
import { useTranslations } from "next-intl";
export function PricingCard() {
const t = useTranslations("pricing");
return <button>{t("cta")}</button>;
}The string passed to getTranslations / useTranslations is the top-level namespace key in your JSON files. Nested keys are accessed with dot notation: t("login.title").
When you add a new namespace or new keys to en.json, add the same keys to every other locale file in messages/ before shipping to production.