Internationalisation (i18n)
Add a `[lang]` segment, translation modules, and a locale switcher — the same pattern this docs site uses across 23 languages.
On this page
Internationalisation
TL;DR — Move pages under
app/[lang]/, registerlocalesanddefaultLocalein a translation module, return them all fromgenerateStaticParams, and readparams.langper page. Mandu prerenders one HTML per locale.
Why
i18n is the place agents drop bugs — hardcoded strings, missing locale on a sub-route, broken RTL. The Mandu pattern keeps locale in the URL and translation strings in typed modules, so an agent can search for t.nav.docs and find every place a copy lives.
How
1. Translation modules
src/shared/utils/client/i18n/
├── types.ts # Translation interface (typed schema)
├── translations/
│ ├── en.ts # default locale
│ ├── ko.ts
│ ├── ja.ts
│ └── ...
└── index.ts # locales[], defaultLocale, getTranslation(), isValidLocale(), isRtl()
// types.ts
export interface Translation {
nav: { docs: string; getStarted: string };
hero: { title: string; subtitle: string };
}
// translations/en.ts
import type { Translation } from "../types";
export const en: Translation = {
nav: { docs: "Docs", getStarted: "Get Started" },
hero: { title: "Build with agents", subtitle: "..." },
};
2. Route under [lang]
// app/[lang]/page.tsx
import { getTranslation, isValidLocale, defaultLocale, locales } from "@/shared/utils/client/i18n";
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export default function HomePage({ params }: { params: { lang: string } }) {
if (!isValidLocale(params.lang)) return <NotFound />;
const t = getTranslation(params.lang);
return <h1>{t.hero.title}</h1>;
}
3. Locale-aware layout (RTL + fonts)
// app/[lang]/layout.tsx
import { isValidLocale, defaultLocale, isRtl } from "@/shared/utils/client/i18n";
export default function LangLayout({ children, params }) {
const lang = isValidLocale(params.lang) ? params.lang : defaultLocale;
return <div dir={isRtl(lang) ? "rtl" : "ltr"}>{children}</div>;
}
🤖 Agent Prompt
Add i18n to my Mandu app:
1. Create `src/shared/utils/client/i18n/types.ts` with a `Translation`
interface, then `translations/<locale>.ts` per locale exporting a
typed Translation. Add an `index.ts` that re-exports `locales`
(string[]), `defaultLocale`, `getTranslation(lang)`,
`isValidLocale(lang)`, `isRtl(lang)` (true for ar/he/ur/fa).
2. Move pages that need localisation under `app/[lang]/...`. In each
page: validate via `isValidLocale(params.lang)`; if invalid, render
a 404 fallback. Read copy as `getTranslation(params.lang)`.
3. Add `export function generateStaticParams() { return locales.map(
(lang) => ({ lang })); }` so the static build emits every locale.
4. In `app/[lang]/layout.tsx`, set `dir={isRtl(lang) ? 'rtl' : 'ltr'}`
on the root wrapper.
Required invariants:
- params.lang is the only source of truth for locale.
- Never import a locale module directly in a page; always go through
getTranslation(lang).
- generateStaticParams must enumerate every entry of `locales`.
- dir='rtl' must be applied for ar, he, ur, fa.
After writing the files, run `bun run guard`, `bun run check`, and
verify a couple of locale URLs in the dev server.
Pitfalls
- Don't hardcode strings. Every visible text goes through
getTranslation(lang). Mandu Guard flags string literals in JSX underapp/[lang]/. - Validate
params.langfirst. Otherwise a malformed URL like/zz/aboutthrows insidegetTranslation. generateStaticParamsis not optional. Without it, only the default locale prerenders.- RTL is more than
dir. Some Tailwind classes (e.g.ml-2) are direction-bound; preferstart-2/end-2for RTL-safe spacing.
Related
- start/quickstart — project layout
- architect/overview — where i18n fits in the layer model
For Agents
Wrap localised pages under `app/[lang]/` and pass `params.lang` to a `getTranslation(lang)` helper that returns a typed Translation object. Validate `lang` against `locales` and fall back to `defaultLocale`. Set `dir="rtl"` for ar/he/ur. Use named export `generateStaticParams` to prerender every locale.
- Localised pages live under `app/[lang]/...` — `params.lang` is the only source of truth for locale
- Validate `params.lang` with `isValidLocale(params.lang)` and fall back to `defaultLocale` for unknown values
- Translation modules export a typed `Translation` object; never import locale strings inline
- `dir="rtl"` must be set on the layout's root element when `isRtl(lang)` is true (ar, he, ur, fa)
- `generateStaticParams` must return every locale so the static build emits one HTML per language