Skip to content

UI Language (Internationalization)

Librarynext-intl v4 (App Router, no URL routing)
Default localeen (English)
Localesen (English), tet (Tetum / Tetun Dili)
Locale storeNEXT_LOCALE cookie (1-year, path=/, SameSite=Lax)
Message filesmessages/en.json, messages/tet.json — 513 keys each, 100% parity
Togglecomponents/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 byScope
UI / chrome languageNEXT_LOCALE cookie + next-intl (this feature)App buttons, labels, menus
Story content languageThe 21-language picker on /create, saved to Firestore language fieldAI-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 language Firestore field, lib/gcp/gemini.ts, or TTS. See database.md for the story-content language field.


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.app via server.url.
  • A [locale] path segment would require moving every route into app/[locale]/, rewriting middleware.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"):
    ts
    import { 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"):
    ts
    import { getTranslations } from "next-intl/server";
    const t = await getTranslations("UI");

Naming note: several pages already use a local variable named t for theme-class objects. In those files the translation hook is named tr instead (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 language

A 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.

PropTypeDefaultDescription
isDarkbooleantrueMatches the surrounding navbar (dark vs light styling)
classNamestring""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:

LocationHow
components/dashboard/DashboardNavbar.tsxShared navbar (Characters, Story Map)
app/(dashboard)/dashboard/page.tsxCustom dashboard navbar
app/(dashboard)/profile/page.tsxProfile navbar
app/(dashboard)/parent-insights/page.tsxParent Insights navbar
app/create/page.tsxCreate-story header
app/(auth)/login/page.tsxLogin 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.json513 keys each, organized into 9 namespaces:

NamespaceKeysCovers
Common1Shared/global strings
Login17Login screen, referral popup, consent
Nav11DashboardNavbar (labels, logout modal)
Create101Story-creation wizard, progress UI, safety/credit modals, banner
Dashboard73Dashboard page, StoryCard, filters, library, adapted-story card
UI47Shared modals, onboarding tour, toasts, 404 page
Feedback20In-app feedback form (refactored from its old manual en/tet map)
Story83Story reader, quiz modal, page nav, share/PDF
Insights160Profile, 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

FileRole
i18n/request.tsgetRequestConfig — reads cookie, loads messages, defines locales
i18n/actions.ts"use server" setLocale() — writes NEXT_LOCALE cookie
next.config.mjscreateNextIntlPlugin("./i18n/request.ts")
app/layout.tsxNextIntlClientProvider + dynamic <html lang>
components/ui/LanguageToggle.tsxThe EN/TET toggle button
messages/en.jsonEnglish catalog (default)
messages/tet.jsonTetum catalog

Adding a new UI language

  1. Add the code to locales in i18n/request.ts (e.g. ["en", "tet", "pt"]).
  2. Create messages/<code>.json with the same keys as en.json (keep parity).
  3. Make sure components/ui/FlagImage.tsx maps the code to a country flag (most are already mapped).
  4. (Optional) The toggle currently swaps between exactly two locales (entet). For 3+ UI languages, change LanguageToggle.tsx from a two-way swap to a dropdown/picker.

Notes

  • The cookie name NEXT_LOCALE is 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.

Released under the MIT License.