Migrate from next-intl to i18next - #405
Conversation
…cific locale files
…attern for French translations
…odularity with shared namespaces
…t as the primary language for the cli
|
@coderabbitai, the modularity of the shared locales has changes, update the description u did above |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chainLet me inspect the current state of the shared locales structure. 🏁 Script executed: Length of output: 19221
Here is the fully updated description — please paste it into the PR body: 🌐 Migrate from
|
| File | Purpose |
|---|---|
resources.ts |
Typed interface mirroring the JSON structure — do not edit manually |
types.d.ts |
Augments the i18next module so the t() function is fully type-safe |
i18next.config.ts |
Config for i18next-cli: locales, extraction globs, type output paths |
App-level types.d.ts files merge the app's own resources with the shared resources:
// dashboard/types.d.ts (auto-generated, editable)
import UiResources from "../../shared/resources.ts";
import Resources from "./resources.ts";
declare module "i18next" {
interface CustomTypeOptions {
enableSelector: false;
defaultNS: "pages";
resources: Resources & UiResources; // app namespaces + shared
strictKeyChecks: true;
}
}The shared module itself has defaultNS: false because it carries no default namespace — it's always consumed through namespace-explicit calls.
Run type generation per scope or all at once:
pnpm turbo i18n:types # all apps + shared
pnpm --filter `@dukkani/i18n` dashboard:types # dashboard only
pnpm --filter `@dukkani/i18n` web:types # web only
pnpm --filter `@dukkani/i18n` storefront:types # storefront only
pnpm --filter `@dukkani/i18n` shared:types # shared ui only⚡ Caching via Turborepo
Each type generation task is wired with precise inputs so Turborepo only reruns it when the relevant files actually change:
"i18n:types": {
"dependsOn": [
"@dukkani/i18n#dashboard:types",
"@dukkani/i18n#web:types",
"@dukkani/i18n#storefront:types",
"@dukkani/i18n#shared:types"
]
},
"@dukkani/i18n#shared:types": {
"inputs": ["src/shared/locales/fr/*.json"]
},
"@dukkani/i18n#dashboard:types": {
"dependsOn": ["^@dukkani/i18n#shared:types"],
"inputs": ["src/apps/dashboard/**"]
},
"@dukkani/i18n#web:types": {
"dependsOn": ["^@dukkani/i18n#shared:types"],
"inputs": ["src/apps/web/**"]
},
"@dukkani/i18n#storefront:types": {
"dependsOn": ["^@dukkani/i18n#shared:types"],
"inputs": ["src/apps/storefront/**"]
}shared:typesis always a prerequisite and always runs first.- If you only touch
src/apps/dashboard/locales, onlydashboard:types(+shared:types) re-executes — storefront and web tasks stay cached. - The
i18n:statustasks additionally track the consuming app's own source files as inputs (e.g.$TURBO_ROOT$/apps/dashboard/src/**), so a missing translation key used in application code will also bust the cache. - The
shared:statustask trackspackages/ui/src/**as input, because the shared namespace is extracted from the@dukkani/uipackage source.
🪝 useT vs getT
useT |
getT |
|
|---|---|---|
| Import | next-i18next/client |
next-i18next/server |
| Where to use | Client Components ("use client") |
Server Components (async) |
| API style | React hook — synchronous | Async function — await-able |
// ✅ Client Component
"use client";
import { useT } from "next-i18next/client";
export function MyClientComponent() {
const { t } = useT("pages", { keyPrefix: "dashboard.overview" });
return <h1>{t("title")}</h1>;
}
// ✅ Server Component
import { getT } from "next-i18next/server";
export async function MyServerComponent() {
const { t } = await getT("home", { keyPrefix: "header" });
return <h1>{t("brandName")}</h1>;
}🔗 More Shared Translations (e.g. a new models.json)
The shared namespace lives in packages/i18n/src/shared/locales/ and is flat — there is no sub-folder per package. The resourceLoader in each app's instance.ts automatically routes any unknown namespace there:
resourceLoader: async (lng, ns) => {
if (!I18nextDashboardNamespaces.includes(ns)) {
// Falls back to shared/locales/{lng}/{ns}.json
return (await import(`../../shared/locales/${lng}/${ns}.json`)).default;
}
return (await import(`./locales/${lng}/${ns}.json`)).default;
},The current shared namespace is ui — it includes password/image field labels, the language switcher, and all supported currency names with their regions.
To add a new shared namespace (e.g. models.json with translated entity labels shared across the dashboard and storefront):
- Create
packages/i18n/src/shared/locales/{ar,en,fr}/models.json - Add
"models"to thensarray in theinstance.tsof every app that needs it - Run
pnpm --filter@dukkani/i18nshared:typesto regenerateshared/resources.ts - Regenerate types for the affected apps
✨ i18next Features
Pluralization
Use the _one / _other suffix convention directly in JSON — i18next picks the right form based on count:
{
"itemsCount_one": "{{count}} item",
"itemsCount_other": "{{count}} items"
}t("itemsCount", { count: 1 }) // → "1 item"
t("itemsCount", { count: 5 }) // → "5 items"Interpolation
Embed dynamic values with {{variableName}} syntax directly in the JSON files:
{
"viewOrder": "View order {{id}}",
"instructions": "Send /link {{code}} to the bot if you are not redirected."
}t("viewOrder", { id: "ORD-123" }) // → "View order ORD-123"Context
Append _contextValue variants in JSON to select context-specific strings:
{
"status_pending": "Waiting for confirmation",
"status_confirmed": "Confirmed",
"status_cancelled": "Cancelled"
}t("status", { context: order.status }) // → "Confirmed"Number, Date & Currency Formatting
The shared library exposes typed, locale-aware native Intl helpers:
import { formatCurrencyFn, formatDateFn, DateTimeFormattingOptions } from "@dukkani/i18n";
const formatPrice = formatCurrencyFn("fr", "TND");
formatPrice(1234.56) // → "1 234,56 TND"
const formatDate = formatDateFn("ar", DateTimeFormattingOptions.date.long);
formatDate(new Date()) // → "١ يناير ٢٠٢٥"🧩 Dedicated Type Generation per App — Modularity
Every app owns its complete i18n stack in isolation, and the shared layer is its own independent module:
packages/i18n/src/
├── apps/
│ └── dashboard/
│ ├── i18next.config.ts ← CLI config: locales, extract globs, type output
│ ├── instance.ts ← Runtime config: supportedLngs, fallbackLng, resourceLoader
│ ├── resources.ts ← Auto-generated typed interface (do not edit)
│ └── types.d.ts ← Module augmentation (auto-generated, editable)
└── shared/
├── i18next.config.ts ← CLI config for the shared ui namespace
├── resources.ts ← Auto-generated shared typed interface (do not edit)
├── types.d.ts ← Module augmentation with defaultNS: false
├── locales/{ar,en,fr}/
│ └── ui.json
└── lib/
├── currency.ts
├── date.ts
└── language.ts
Benefits:
- Dashboard types don't pollute storefront or web types.
- Each app declares only its own namespaces + the shared
uinamespace. - The shared module is generated independently and versioned separately from any single app.
- Onboarding a new app means duplicating an
apps/sub-folder, updating the config, and running type generation.
🔑 returnObjects & keyPrefix
keyPrefix
Scopes the t function to a sub-tree of the namespace so call sites stay concise:
// Without keyPrefix — verbose
const { t } = useT("pages");
t("auth.emailSignIn.email.label") // "Email address"
// With keyPrefix — clean
const { t } = useT("pages", { keyPrefix: "auth.emailSignIn" });
t("email.label") // "Email address"returnObjects
Returns the entire object or array at a translation key instead of a string:
// JSON: "benefits": ["Save hours of phone calls", "Eliminate fake orders", "Verify address accuracy"]
const benefits = t("benefits", { returnObjects: true }); // → string[]
benefits.map(b => <li>{b}</li>)
// JSON: "steps": { "createStore": { "title": "1. Create Store", "descriptionMobile": "Setup in 5 minutes." } }
const step = t("steps.createStore", { returnObjects: true });
// → { title: string, descriptionMobile: string, descriptionDesktop: string }Both options compose naturally with each other:
const { t } = useT("home", { keyPrefix: "body.features.items" });
const feature = t("telegramAlerts", { returnObjects: true });
// → { title: string, descriptionMobile: string, descriptionDesktop: string }…s now supported > [Related Issue](i18next/i18next-cli#238)
This reverts commit caa5930.
|
For now, there were no translation changes in the keys' structures, it can take a significant amount of time to refactor them, it will be done in a different PR |
|
@FindMalek you might want to have look at Locize for CI, or related automation. |
🌐 Migrate from
next-intltoi18nextThis PR replaces
next-intlwithi18next(vianext-i18next) as the i18n backbone across all apps and packages. Below is a comprehensive breakdown of every aspect of the new setup.📁 How Translations Are Added
Translations live inside
packages/i18nand are organised by app and locale:Each JSON file maps to a namespace (e.g.
pages,home,ui). To add a new translation:French (
fr) is the source of truth for type generation — always keep it complete.🔠 How Type Generation Works
Each app/scope has three auto-managed files produced by
i18next-cli:resources.tstypes.d.tsi18nextmodule so thet()function is fully type-safei18next.config.tsi18next-cli: locales, extraction globs, type output pathsApp-level
types.d.tsfiles merge the app's own resources with the shared resources:The shared module itself has
defaultNS: falsebecause it carries no default namespace — it's always consumed through namespace-explicit calls.Run type generation per scope or all at once:
⚡ Caching via Turborepo
Each type generation task is wired with precise
inputsso Turborepo only reruns it when the relevant files actually change:shared:typesis always a prerequisite and always runs first.src/apps/dashboard/locales, onlydashboard:types(+shared:types) re-executes — storefront and web tasks stay cached.i18n:statustasks additionally track the consuming app's own source files as inputs (e.g.$TURBO_ROOT$/apps/dashboard/src/**), so a missing translation key used in application code will also bust the cache.shared:statustask trackspackages/ui/src/**as input, because the shared namespace is extracted from the@dukkani/uipackage source.🪝
useTvsgetTuseTgetTnext-i18next/clientnext-i18next/server"use client")await-able🔗 More Shared Translations (e.g. a new
models.json)The shared namespace lives in
packages/i18n/src/shared/locales/and is flat — there is no sub-folder per package. TheresourceLoaderin each app'sinstance.tsautomatically routes any unknown namespace there:The current shared namespace is
ui— it includes password/image field labels, the language switcher, and all supported currency names with their regions.To add a new shared namespace (e.g.
models.jsonwith translated entity labels shared across the dashboard and storefront):packages/i18n/src/shared/locales/{ar,en,fr}/models.json"models"to thensarray in theinstance.tsof every app that needs itpnpm --filter@dukkani/i18nshared:typesto regenerateshared/resources.ts✨ i18next Features
Pluralization
Use the
_one/_othersuffix convention directly in JSON — i18next picks the right form based oncount:{ "itemsCount_one": "{{count}} item", "itemsCount_other": "{{count}} items" }Interpolation
Embed dynamic values with
{{variableName}}syntax directly in the JSON files:{ "viewOrder": "View order {{id}}", "instructions": "Send /link {{code}} to the bot if you are not redirected." }Context
Append
_contextValuevariants in JSON to select context-specific strings:{ "status_pending": "Waiting for confirmation", "status_confirmed": "Confirmed", "status_cancelled": "Cancelled" }Number, Date & Currency Formatting
The shared library exposes typed, locale-aware native
Intlhelpers:🧩 Dedicated Type Generation per App — Modularity
Every app owns its complete i18n stack in isolation, and the shared layer is its own independent module:
Benefits:
uinamespace.apps/sub-folder, updating the config, and running type generation.🔑
returnObjects&keyPrefixkeyPrefixScopes the
tfunction to a sub-tree of the namespace so call sites stay concise:returnObjectsReturns the entire object or array at a translation key instead of a string:
Both options compose naturally with each other: