Skip to content

Migrate from next-intl to i18next - #405

Open
SkandarS0 wants to merge 72 commits into
mainfrom
399-translation-keys-cannot-be-used-in-vanilla-ts-only-in-nextjs-apps-in-react-code
Open

Migrate from next-intl to i18next#405
SkandarS0 wants to merge 72 commits into
mainfrom
399-translation-keys-cannot-be-used-in-vanilla-ts-only-in-nextjs-apps-in-react-code

Conversation

@SkandarS0

@SkandarS0 SkandarS0 commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator

🌐 Migrate from next-intl to i18next

This PR replaces next-intl with i18next (via next-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/i18n and are organised by app and locale:

packages/i18n/src/
├── apps/
│   ├── dashboard/locales/{ar,en,fr}/pages.json
│   ├── storefront/locales/{ar,en,fr}/pages.json
│   └── web/locales/{ar,en,fr}/home.json
└── shared/locales/{ar,en,fr}/ui.json

Each JSON file maps to a namespace (e.g. pages, home, ui). To add a new translation:

  1. Add the key/value to the relevant JSON file in every locale folder (or run the extract command and fill in the gaps).
  2. Re-run type generation for the affected app (see below).

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:

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:types is always a prerequisite and always runs first.
  • If you only touch src/apps/dashboard/locales, only dashboard:types (+ shared:types) re-executes — storefront and web tasks stay cached.
  • The i18n:status tasks 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:status task tracks packages/ui/src/** as input, because the shared namespace is extracted from the @dukkani/ui package 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):

  1. Create packages/i18n/src/shared/locales/{ar,en,fr}/models.json
  2. Add "models" to the ns array in the instance.ts of every app that needs it
  3. Run pnpm --filter @dukkani/i18n shared:types to regenerate shared/resources.ts
  4. 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 ui namespace.
  • 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 }

@SkandarS0

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, the modularity of the shared locales has changes, update the description u did above

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me inspect the current state of the shared locales structure.


🏁 Script executed:

#!/bin/bash
echo "===SHARED LOCALES STRUCTURE==="
find . -path "*/packages/i18n/src/shared*" -not -path "*/node_modules/*" | sort

echo "===SHARED LOCALES FR (source of truth)==="
find . -path "*/packages/i18n/src/shared/locales*" -not -path "*/node_modules/*" | sort

echo "===SHARED fr JSON files==="
find . -path "*/packages/i18n/src/shared/locales/fr*" -name "*.json" -not -path "*/node_modules/*" -exec echo "FILE: {}" \; -exec cat {} \;

echo "===SHARED en JSON files==="
find . -path "*/packages/i18n/src/shared/locales/en*" -name "*.json" -not -path "*/node_modules/*" -exec echo "FILE: {}" \; -exec cat {} \;

echo "===SHARED ar JSON files==="
find . -path "*/packages/i18n/src/shared/locales/ar*" -name "*.json" -not -path "*/node_modules/*" -exec echo "FILE: {}" \; -exec cat {} \;

echo "===SHARED index.ts==="
cat packages/i18n/src/shared/index.ts

echo "===SHARED resources.ts==="
cat packages/i18n/src/shared/ui/resources.ts 2>/dev/null || cat packages/i18n/src/shared/resources.ts 2>/dev/null

echo "===SHARED types.d.ts==="
cat packages/i18n/src/shared/ui/types.d.ts 2>/dev/null || cat packages/i18n/src/shared/types.d.ts 2>/dev/null

echo "===ALL SHARED TS FILES==="
find . -path "*/packages/i18n/src/shared*" -name "*.ts" -not -path "*/node_modules/*" -exec echo "FILE: {}" \; -exec cat {} \;

echo "===DASHBOARD instance.ts (current)==="
cat packages/i18n/src/apps/dashboard/instance.ts

echo "===WEB instance.ts (current)==="
cat packages/i18n/src/apps/web/instance.ts

echo "===STOREFRONT instance.ts (current)==="
cat packages/i18n/src/apps/storefront/instance.ts

echo "===DASHBOARD types.d.ts (current)==="
cat packages/i18n/src/apps/dashboard/types.d.ts

echo "===WEB types.d.ts (current)==="
cat packages/i18n/src/apps/web/types.d.ts 2>/dev/null

echo "===TURBO.JSON (current)==="
cat turbo.json 2>/dev/null || find . -maxdepth 2 -name "turbo.json" -not -path "*/node_modules/*" -exec cat {} \;

Length of output: 19221


@SkandarS0, here's the key structural change I found: the shared/ui/ sub-folder has been flattenedresources.ts, types.d.ts, and i18next.config.ts now live directly under src/shared/ (no ui/ sub-directory). Consequently, all Turborepo tasks renamed from ui:types/ui:statusshared:types/shared:status.

Here is the fully updated description — please paste it into the PR body:


🌐 Migrate from next-intl to i18next

This PR replaces next-intl with i18next (via next-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/i18n and are organised by app and locale:

packages/i18n/src/
├── apps/
│   ├── dashboard/locales/{ar,en,fr}/pages.json
│   ├── storefront/locales/{ar,en,fr}/pages.json
│   └── web/locales/{ar,en,fr}/home.json
└── shared/locales/{ar,en,fr}/ui.json

Each JSON file maps to a namespace (e.g. pages, home, ui). To add a new translation:

  1. Add the key/value to the relevant JSON file in every locale folder (or run the extract command and fill in the gaps).
  2. Re-run type generation for the affected app (see below).

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:

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:types is always a prerequisite and always runs first.
  • If you only touch src/apps/dashboard/locales, only dashboard:types (+ shared:types) re-executes — storefront and web tasks stay cached.
  • The i18n:status tasks 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:status task tracks packages/ui/src/** as input, because the shared namespace is extracted from the @dukkani/ui package 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):

  1. Create packages/i18n/src/shared/locales/{ar,en,fr}/models.json
  2. Add "models" to the ns array in the instance.ts of every app that needs it
  3. Run pnpm --filter @dukkani/i18n shared:types to regenerate shared/resources.ts
  4. 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 ui namespace.
  • 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 }

@SkandarS0

Copy link
Copy Markdown
Collaborator Author

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

@SkandarS0

Copy link
Copy Markdown
Collaborator Author

@FindMalek you might want to have look at Locize for CI, or related automation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Translation keys cannot be used in vanilla ts, only in nextjs apps in react code

2 participants