Internationalization (i18n)

next-intl, cookie-based locale, message files

Aexy's frontend uses next-intl with a cookie-based locale system. URLs stay clean — there's no /en/... or /hi/... prefix.

Stack#

ConcernWhere
Librarynext-intl (App Router)
Locale storageNEXT_LOCALE cookie (set client-side, read by middleware)
Default localeen
Supported localesen (English), hi (Hindi)
Server-side configfrontend/src/i18n/request.ts
Middlewarefrontend/src/middleware.ts sets x-locale request header from the cookie
Client storefrontend/src/stores/localeStore.ts (Zustand)
Switcher UIfrontend/src/components/LocaleSelector.tsx
Provider wiringfrontend/src/app/providers.tsx (NextIntlClientProvider)
Layout loads messagesfrontend/src/app/layout.tsx

File layout#

Messages live as small per-module JSON files and are merged into one big bundle per locale at build time:

frontend/messages/
├── en/
│   ├── common.json        # { "common": { "save": "Save", "cancel": "Cancel", ... } }
│   ├── reviews.json       # { "reviews": { "title": "Performance Reviews", ... } }
│   ├── sidebar.json
│   └── settings.json
├── hi/
│   ├── common.json        # Same keys, Hindi values
│   ├── reviews.json
│   ├── sidebar.json
│   └── settings.json
├── en.json                # ← auto-generated, do not hand-edit
└── hi.json                # ← auto-generated, do not hand-edit

The merge script is frontend/scripts/merge-messages.js, wired to npm run prebuild. Run it manually any time you change a JSON file:

cd frontend && npm run i18n:merge

If you forget, npm run build will run it for you, but npm run dev won't — so new keys won't show up locally until you re-run dev or merge by hand.

Using translations in a component#

"use client";
import { useTranslations } from "next-intl";

export default function ReviewsPage() {
  const t = useTranslations("reviews");
  const tc = useTranslations("common"); // for shared strings

  return (
    <>
      <h1>{t("title")}</h1>
      <button>{tc("save")}</button>
    </>
  );
}

useTranslations(namespace) — the namespace matches the module name (reviews, crm, sprints, etc.). Always import common for the shared verbs (Cancel, Save, Delete, Loading, status labels) instead of duplicating those keys in every module file.

Server components#

Server-component translations come through getTranslations from next-intl/server. The locale is resolved from the x-locale header set by the middleware:

import { getTranslations } from "next-intl/server";

export default async function Page() {
  const t = await getTranslations("reviews");
  return <h1>{t("title")}</h1>;
}

Placeholders & rich text#

Use ICU message syntax. Placeholders look like {name}, {count}, {date, date, short}:

{
  "reviews": {
    "greeting": "Hello, {name}!",
    "count": "You have {count, plural, =0 {no reviews} =1 {1 review} other {# reviews}} this cycle"
  }
}
t("greeting", { name: user.name });
t("count", { count: reviews.length });

For inline rich text (bold, links), pass JSX-as-function:

t.rich("terms", { link: (chunks) => <a href="/terms">{chunks}</a> });
{ "terms": "I agree to the <link>terms of service</link>." }

Adding a new module's translations#

  1. Create frontend/messages/en/<module>.json with one top-level namespace key:
    { "myModule": { "title": "My Module" } }
    
  2. Create frontend/messages/hi/<module>.json with the same keys, Hindi values.
  3. Run npm run i18n:merge (or restart npm run dev after the next prebuild).
  4. In components: const t = useTranslations("myModule");.

The namespace key inside the file ("myModule") is what useTranslations reads. Filename is just for organization — keep them consistent for sanity.

Adding a new locale#

  1. Duplicate messages/en/ to messages/<locale>/ and translate.
  2. Run npm run i18n:merge.
  3. Add "<locale>" to SUPPORTED_LOCALES in frontend/src/stores/localeStore.ts.
  4. Add the human label in LOCALE_LABELS in the same file.
  5. Add "<locale>" to SUPPORTED_LOCALES in frontend/src/middleware.ts and frontend/src/i18n/request.ts.
  6. (Optional) Update LocaleSelector.tsx if you want to gate the new locale behind a feature flag during translation.

There's no locale-by-URL routing — URLs are stable across languages.

Conventions#

  • Every user-facing string in a new component must use useTranslations(). No hardcoded English in app/(app)/... JSX.
  • Use common for shared verbs (Save, Cancel, Delete, Loading, Yes, No, status labels like Active/Archived).
  • Keep technical terms in English in Hindi values — API, GitHub, PR, Sprint, Webhook, etc. should not be transliterated. The Hindi reader is generally a developer.
  • Don't translate user-generated content — only chrome. CRM record names, task titles, document bodies stay in whatever the user typed.
  • Don't put pluralization in code. Use ICU {count, plural, =0 {...} =1 {...} other {#...}}.
  • Dates and numbers: prefer next-intl's useFormatter() over hand-rolled formatters so locale settings (number format, date layout) flow through consistently.

Common pitfalls#

  • New key, untranslated: keys present in en.json but missing in hi.json render the namespaced key path (reviews.title) to the user. Add Hindi values at the same time you add English, even as a placeholder.
  • Editing en.json / hi.json directly: those are merge outputs. Your edit gets clobbered on the next build. Always edit messages/<locale>/<module>.json.
  • Forgetting to re-merge in dev: npm run dev doesn't run i18n:merge. Add a watcher in your workflow or just kick the dev server after touching a JSON file.
  • Cookie not set on first visit: NEXT_LOCALE is set when the user picks a locale; before that, the middleware falls back to en. If you need to respect Accept-Language, extend the middleware — it's a single file.