Aexy's frontend uses next-intl with a cookie-based locale system. URLs stay clean — there's no /en/... or /hi/... prefix.
Stack#
| Concern | Where |
|---|---|
| Library | next-intl (App Router) |
| Locale storage | NEXT_LOCALE cookie (set client-side, read by middleware) |
| Default locale | en |
| Supported locales | en (English), hi (Hindi) |
| Server-side config | frontend/src/i18n/request.ts |
| Middleware | frontend/src/middleware.ts sets x-locale request header from the cookie |
| Client store | frontend/src/stores/localeStore.ts (Zustand) |
| Switcher UI | frontend/src/components/LocaleSelector.tsx |
| Provider wiring | frontend/src/app/providers.tsx (NextIntlClientProvider) |
| Layout loads messages | frontend/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#
- Create
frontend/messages/en/<module>.jsonwith one top-level namespace key:{ "myModule": { "title": "My Module" } } - Create
frontend/messages/hi/<module>.jsonwith the same keys, Hindi values. - Run
npm run i18n:merge(or restartnpm run devafter the next prebuild). - 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#
- Duplicate
messages/en/tomessages/<locale>/and translate. - Run
npm run i18n:merge. - Add
"<locale>"toSUPPORTED_LOCALESinfrontend/src/stores/localeStore.ts. - Add the human label in
LOCALE_LABELSin the same file. - Add
"<locale>"toSUPPORTED_LOCALESinfrontend/src/middleware.tsandfrontend/src/i18n/request.ts. - (Optional) Update
LocaleSelector.tsxif 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 inapp/(app)/...JSX. - Use
commonfor 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'suseFormatter()over hand-rolled formatters so locale settings (number format, date layout) flow through consistently.
Common pitfalls#
- New key, untranslated: keys present in
en.jsonbut missing inhi.jsonrender 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.jsondirectly: those are merge outputs. Your edit gets clobbered on the next build. Always editmessages/<locale>/<module>.json. - Forgetting to re-merge in dev:
npm run devdoesn't runi18n: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_LOCALEis set when the user picks a locale; before that, the middleware falls back toen. If you need to respectAccept-Language, extend the middleware — it's a single file.