UI Language (Internationalization)
| Library | next-intl v4 (App Router, no URL routing) |
| Default locale | en (English) |
| Locales | en (English), tet (Tetum / Tetun Dili) |
| Locale store | NEXT_LOCALE cookie (1-year, path=/, SameSite=Lax) |
| Message files | messages/en.json, messages/tet.json — 513 keys each, 100% parity |
| Toggle | components/ui/LanguageToggle.tsx |
What it does
Lets the user switch the app interface language (buttons, labels, menus, modals, error/toast text) between English and Tetum. The choice is stored in a cookie and applied across the whole app on the next render.
Important distinction — two different "languages":
Controlled by Scope UI / chrome language NEXT_LOCALEcookie + next-intl (this feature)App buttons, labels, menus Story content language The 21-language picker on /create, saved to FirestorelanguagefieldAI-generated story text, quiz, TTS narration These are completely independent. A user can browse the app in Tetum while generating a story in Japanese, or browse in English while generating in Tetum. This feature does not touch the story-generation pipeline, the
languageFirestore field,lib/gcp/gemini.ts, or TTS. See database.md for the story-contentlanguagefield.
Why "no URL routing"
The textbook next-intl setup puts the locale in the URL (/en/dashboard, /tet/dashboard). KidStory deliberately uses the cookie-based, locale-less setup instead because:
- The app is client-heavy — Firebase Auth lives in IndexedDB, providers wrap the root layout, almost every page is
"use client". - It is wrapped in a Capacitor Android shell that loads from
https://ai.kidstory.appviaserver.url. - A
[locale]path segment would require moving every route intoapp/[locale]/, rewritingmiddleware.ts(which already does auth redirects) and every internal link, and could interfere with the Capacitor shell and the public OG share routes (/s/[id]).
Cookie-based switching leaves URLs, middleware, and the Android shell untouched.
Architecture
i18n/request.ts → reads NEXT_LOCALE cookie, falls back to "en",
loads messages/{locale}.json
i18n/actions.ts → "use server" setLocale() — writes the NEXT_LOCALE cookie
next.config.mjs → createNextIntlPlugin("./i18n/request.ts")
app/layout.tsx → <NextIntlClientProvider> + dynamic <html lang={locale}>
messages/*.json → the translation catalogs (one per locale)How a string is rendered
- Client components (
"use client"):tsimport { useTranslations } from "next-intl"; const t = useTranslations("Nav"); // ... t("newStory") // "New Story" / "Istória Foun" t("leavingConfirm", { name }) // ICU interpolation t.rich("consent", { terms: ... }) // embedded JSX / links - Server components (no
"use client"):tsimport { getTranslations } from "next-intl/server"; const t = await getTranslations("UI");
Naming note: several pages already use a local variable named
tfor theme-class objects. In those files the translation hook is namedtrinstead (e.g.app/create/page.tsx,app/(dashboard)/dashboard/page.tsx) to avoid a collision.
Switching flow
User taps the LanguageToggle
│
▼
setLocale(next) ← "use server" action (i18n/actions.ts)
└── writes NEXT_LOCALE cookie
│
▼
window.location.reload() ← so server components re-render in the new locale
│
▼
i18n/request.ts reads the cookie → loads messages/{locale}.json
│
▼
<NextIntlClientProvider> supplies messages → every t(...) resolves in the new languageA full reload (rather than a soft navigation) is used so server-rendered content also re-renders in the new locale. This works everywhere, including the Capacitor Android shell.
The toggle button
components/ui/LanguageToggle.tsx — a compact, theme-aware pill: a circular flag chip + the locale code (EN / TET), styled to blend in with the round nav icon buttons.
| Prop | Type | Default | Description |
|---|---|---|---|
isDark | boolean | true | Matches the surrounding navbar (dark vs light styling) |
className | string | "" | Extra classes |
The flag artwork is reused from components/ui/FlagImage.tsx (Tetum → tl / Timor-Leste flag).
Where the toggle appears
Placed next to the dark/light theme toggle on every page that has one, so it is always within reach:
| Location | How |
|---|---|
components/dashboard/DashboardNavbar.tsx | Shared navbar (Characters, Story Map) |
app/(dashboard)/dashboard/page.tsx | Custom dashboard navbar |
app/(dashboard)/profile/page.tsx | Profile navbar |
app/(dashboard)/parent-insights/page.tsx | Parent Insights navbar |
app/create/page.tsx | Create-story header |
app/(auth)/login/page.tsx | Login screen (pick a language pre-auth) |
Each call site passes isDark so the toggle matches its surroundings.
Message catalogs
messages/en.json and messages/tet.json — 513 keys each, organized into 9 namespaces:
| Namespace | Keys | Covers |
|---|---|---|
Common | 1 | Shared/global strings |
Login | 17 | Login screen, referral popup, consent |
Nav | 11 | DashboardNavbar (labels, logout modal) |
Create | 101 | Story-creation wizard, progress UI, safety/credit modals, banner |
Dashboard | 73 | Dashboard page, StoryCard, filters, library, adapted-story card |
UI | 47 | Shared modals, onboarding tour, toasts, 404 page |
Feedback | 20 | In-app feedback form (refactored from its old manual en/tet map) |
Story | 83 | Story reader, quiz modal, page nav, share/PDF |
Insights | 160 | Profile, Parent Insights, Story Map, Characters, observability |
The two files are kept at exact key parity (verified with a flatten + diff). Dynamic values use ICU args ({name}, {count}, {score}), plurals use ICU plural, and embedded links/JSX use t.rich.
Intentionally NOT translated
- Proper nouns / brand:
KidStory, mascot names (Stella, Luna, …) - Agent identifiers shown in the observability panel (SafetyGuardian, StoryWriter, …) and model names (
gemini-2.5-pro) - The 21 story-content language names/codes in the create picker
- AI-generated content (story text, quiz questions, recommendations) — that is data, already in the story's own language
- Emoji,
className,console.*, and other non-rendered strings
Files
| File | Role |
|---|---|
i18n/request.ts | getRequestConfig — reads cookie, loads messages, defines locales |
i18n/actions.ts | "use server" setLocale() — writes NEXT_LOCALE cookie |
next.config.mjs | createNextIntlPlugin("./i18n/request.ts") |
app/layout.tsx | NextIntlClientProvider + dynamic <html lang> |
components/ui/LanguageToggle.tsx | The EN/TET toggle button |
messages/en.json | English catalog (default) |
messages/tet.json | Tetum catalog |
Adding a new UI language
- Add the code to
localesini18n/request.ts(e.g.["en", "tet", "pt"]). - Create
messages/<code>.jsonwith the same keys asen.json(keep parity). - Make sure
components/ui/FlagImage.tsxmaps the code to a country flag (most are already mapped). - (Optional) The toggle currently swaps between exactly two locales (
en⇄tet). For 3+ UI languages, changeLanguageToggle.tsxfrom a two-way swap to a dropdown/picker.
Notes
- The cookie name
NEXT_LOCALEis the next-intl convention. - New users with no cookie get the default
en. - Switching triggers a full page reload by design (so server components re-render); this is intentional and Capacitor-safe.
- This feature added zero new lint errors and the production build compiles all routes.
