From 4655362ff450e47f78bd5b58b3aa98cb91f1edf6 Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Fri, 22 May 2026 23:30:41 +0700
Subject: [PATCH] feat: implement core-i18n package with decentralized
namespace support and integrate into landing and web apps
---
README.md | 26 +-
apps/landing/package.json | 3 +
apps/landing/src/app.tsx | 2 +
apps/landing/src/locales/en/home.json | 4 +
apps/landing/src/locales/id/home.json | 4 +
apps/landing/src/main.tsx | 17 +-
.../src/presentation/I18nLandingSample.tsx | 50 +++
apps/landing/src/types/i18next.d.ts | 14 +
apps/web/package.json | 3 +
.../src/apps/modules/example/example.page.tsx | 4 +
.../features/i18n/locales/en/booking.json | 8 +
.../features/i18n/locales/id/booking.json | 8 +
.../features/i18n/presentation/I18nSample.tsx | 308 ++++++++++++++++++
apps/web/src/main.tsx | 19 +-
apps/web/src/types/i18next.d.ts | 14 +
packages/core-i18n/README.md | 183 +++++++++++
packages/core-i18n/package.json | 26 ++
packages/core-i18n/src/index.ts | 4 +
packages/core-i18n/src/locales/en/common.json | 12 +
packages/core-i18n/src/locales/id/common.json | 12 +
packages/core-i18n/src/manager.ts | 56 ++++
packages/core-i18n/src/react-i18next.d.ts | 9 +
packages/core-i18n/src/setup.ts | 57 ++++
packages/core-i18n/tsconfig.json | 9 +
pnpm-lock.yaml | 93 ++++++
25 files changed, 932 insertions(+), 13 deletions(-)
create mode 100644 apps/landing/src/locales/en/home.json
create mode 100644 apps/landing/src/locales/id/home.json
create mode 100644 apps/landing/src/presentation/I18nLandingSample.tsx
create mode 100644 apps/landing/src/types/i18next.d.ts
create mode 100644 apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json
create mode 100644 apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json
create mode 100644 apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx
create mode 100644 apps/web/src/types/i18next.d.ts
create mode 100644 packages/core-i18n/README.md
create mode 100644 packages/core-i18n/package.json
create mode 100644 packages/core-i18n/src/index.ts
create mode 100644 packages/core-i18n/src/locales/en/common.json
create mode 100644 packages/core-i18n/src/locales/id/common.json
create mode 100644 packages/core-i18n/src/manager.ts
create mode 100644 packages/core-i18n/src/react-i18next.d.ts
create mode 100644 packages/core-i18n/src/setup.ts
create mode 100644 packages/core-i18n/tsconfig.json
diff --git a/README.md b/README.md
index b533b4a..e86f1fc 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
├── packages/
│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption)
+│ ├── core-i18n/ # Enterprise Internationalization Architecture
│ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations
@@ -249,7 +250,26 @@ Provides a unified, Promise-based interface for interacting with browser storage
---
-### 7. `packages/utils`
+### 7. `packages/core-i18n`
+
+The **Enterprise Internationalization Architecture** for the monorepo.
+
+Provides a Hybrid Namespace Architecture combining a centralized i18n engine with decentralized, lazy-loaded feature dictionaries. Features strict TypeScript typings (including nested keys), optional backend synchronization with automatic error rollbacks, and a deep-merge mechanism for dynamic tenant-specific vocabulary overrides.
+
+**Key Capabilities**:
+
+| Feature | Description |
+|---|---|
+| 🌐 Hybrid Namespaces | Centralized `common` corpus + lazy-loaded feature dictionaries. |
+| 🛡️ Strict Typings | Native TS autocomplete for nested paths (e.g., `header.title`) via module augmentation. |
+| 🔄 Safe Backend Sync | `changeLanguage` accepts a `syncCallback` with built-in rollback if the API fails. |
+| 🏢 Tenant Overrides | `applyTenantOverrides` performs a partial deep-merge to selectively override terminology. |
+
+**Documentation**: [README.md](packages/core-i18n/README.md)
+
+---
+
+### 8. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -257,7 +277,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
---
-### 8. `packages/ui`
+### 9. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -266,7 +286,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
---
-### 9. `packages/configs`
+### 10. `packages/configs`
Single source of truth for tooling configuration.
diff --git a/apps/landing/package.json b/apps/landing/package.json
index 45cc029..1d78f4c 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -12,11 +12,14 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
+ "@repo/core-i18n": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
+ "i18next": "^24.2.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+ "react-i18next": "^15.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
diff --git a/apps/landing/src/app.tsx b/apps/landing/src/app.tsx
index 9e88335..6abf839 100644
--- a/apps/landing/src/app.tsx
+++ b/apps/landing/src/app.tsx
@@ -1,6 +1,7 @@
import { ThemeProvider } from '@repo/ui/provider';
import { Button } from '@repo/ui/components';
import LandingSample from './features/public-content/presentation/LandingSample';
+import I18nLandingSample from './presentation/I18nLandingSample';
export default function App() {
return (
@@ -36,6 +37,7 @@ export default function App() {
Enterprise Web App
+
);
diff --git a/apps/landing/src/locales/en/home.json b/apps/landing/src/locales/en/home.json
new file mode 100644
index 0000000..ffdd0d9
--- /dev/null
+++ b/apps/landing/src/locales/en/home.json
@@ -0,0 +1,4 @@
+{
+ "welcome": "Welcome to Our Product",
+ "cta": "Get Started Now"
+}
diff --git a/apps/landing/src/locales/id/home.json b/apps/landing/src/locales/id/home.json
new file mode 100644
index 0000000..70de94c
--- /dev/null
+++ b/apps/landing/src/locales/id/home.json
@@ -0,0 +1,4 @@
+{
+ "welcome": "Selamat Datang di Produk Kami",
+ "cta": "Mulai Sekarang"
+}
diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx
index 12ade2e..2bfdd13 100644
--- a/apps/landing/src/main.tsx
+++ b/apps/landing/src/main.tsx
@@ -15,10 +15,17 @@ initTelemetry({
import './main.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { setupI18n } from '@repo/core-i18n';
import App from './app';
-createRoot(document.getElementById('app')!).render(
-
-
- ,
-);
+async function bootstrap() {
+ await setupI18n();
+
+ createRoot(document.getElementById('app')!).render(
+
+
+ ,
+ );
+}
+
+bootstrap();
diff --git a/apps/landing/src/presentation/I18nLandingSample.tsx b/apps/landing/src/presentation/I18nLandingSample.tsx
new file mode 100644
index 0000000..27a1840
--- /dev/null
+++ b/apps/landing/src/presentation/I18nLandingSample.tsx
@@ -0,0 +1,50 @@
+import { useEffect } from 'react';
+import { useTranslation, changeLanguage, i18n } from '@repo/core-i18n';
+
+// Decentralized locale imports
+import homeId from '../locales/id/home.json';
+import homeEn from '../locales/en/home.json';
+
+export default function I18nLandingSample() {
+ const { t } = useTranslation(['common', 'home']);
+
+ // 1. Lazy-load the 'home' namespace when the module mounts
+ useEffect(() => {
+ i18n.addResourceBundle('id', 'home', homeId, true, false);
+ i18n.addResourceBundle('en', 'home', homeEn, true, false);
+ }, []);
+
+ // 2. Change language without syncCallback to prove decoupling
+ const setLanguage = (lng: string) => {
+ // We intentionally omit the second argument (syncCallback)
+ // because the landing page is public and doesn't need backend syncing.
+ changeLanguage(lng).catch(console.error);
+ };
+
+ return (
+
+
+ {t('home:welcome')}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/types/i18next.d.ts b/apps/landing/src/types/i18next.d.ts
new file mode 100644
index 0000000..fd7316b
--- /dev/null
+++ b/apps/landing/src/types/i18next.d.ts
@@ -0,0 +1,14 @@
+import 'react-i18next';
+// Import the core types so we don't break the common namespace
+import type { resources as coreResources } from '@repo/core-i18n/src/setup';
+import homeEn from '../locales/en/home.json';
+
+// Combine core resources with app-specific decentralized resources
+declare module 'react-i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'common';
+ resources: typeof coreResources['en'] & {
+ home: typeof homeEn;
+ };
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index eac6aec..945b931 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,13 +14,16 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
+ "@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"dayjs": "^1.11.19",
+ "i18next": "^24.2.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+ "react-i18next": "^15.4.0",
"react-router-dom": "^7.11.0",
"tailwindcss": "^4.1.18"
},
diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx
index 2711f00..f77b202 100644
--- a/apps/web/src/apps/modules/example/example.page.tsx
+++ b/apps/web/src/apps/modules/example/example.page.tsx
@@ -1,5 +1,6 @@
import BookingSample from "./features/booking/presentation/BookingSample";
import StorageSample from "./features/storage/presentation/StorageSample";
+import I18nSample from "./features/i18n/presentation/I18nSample";
export default function ExamplePage() {
return example
@@ -11,5 +12,8 @@ export default function ExamplePage() {
Enterprise Web App
+
+
+
;
}
diff --git a/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json b/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json
new file mode 100644
index 0000000..8e8835d
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json
@@ -0,0 +1,8 @@
+{
+ "module_name": "Purchasing",
+ "select_date": "Select Date",
+ "header": {
+ "title": "Transaction List",
+ "subtitle": "Manage all your transactions here"
+ }
+}
diff --git a/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json b/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json
new file mode 100644
index 0000000..bf22e68
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json
@@ -0,0 +1,8 @@
+{
+ "module_name": "Pembelanjaan",
+ "select_date": "Pilih Tanggal",
+ "header": {
+ "title": "Daftar Transaksi",
+ "subtitle": "Kelola semua transaksi Anda di sini"
+ }
+}
diff --git a/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx b/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx
new file mode 100644
index 0000000..cd68b5e
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx
@@ -0,0 +1,308 @@
+import { useEffect, useState, useCallback } from 'react';
+import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
+import { demoIndexedDB } from '@repo/core-storage';
+
+// Decentralized locale imports
+import bookingId from '../locales/id/booking.json';
+import bookingEn from '../locales/en/booking.json';
+
+// ─── Shared Styles ──────────────────────────────────────────────
+
+const sectionStyle = {
+ marginTop: 24,
+ padding: 24,
+ border: '1px solid #334155',
+ borderRadius: 8,
+ background: '#0f172a',
+};
+
+const btnStyle = (color: string) => ({
+ padding: '8px 16px',
+ fontSize: 14,
+ fontWeight: 600 as const,
+ cursor: 'pointer' as const,
+ background: color,
+ color: '#fff',
+ border: 'none',
+ borderRadius: 6,
+ marginRight: 8,
+});
+
+// ─── Component ──────────────────────────────────────────────────
+
+export default function I18nSample() {
+ const { t } = useTranslation(['common', 'booking']);
+ const [syncStatus, setSyncStatus] = useState('');
+ const [activeTenant, setActiveTenant] = useState('default');
+ const [isFetchingConfig, setIsFetchingConfig] = useState(false);
+
+ // ─── Admin Panel State ──────────────────────────────────────────
+ const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
+ const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
+ const [dbPayloadStr, setDbPayloadStr] = useState('No data in DB');
+
+ const MOCK_DB_KEY = 'mock_db_company_a';
+
+ const loadDbPayload = useCallback(async () => {
+ try {
+ const data = await demoIndexedDB.getItem(MOCK_DB_KEY);
+ setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
+ } catch (e) {
+ setDbPayloadStr('Error reading DB');
+ }
+ }, []);
+
+ useEffect(() => {
+ loadDbPayload();
+ }, [loadDbPayload]);
+
+ const handleAdminSave = async () => {
+ const payload = {
+ namespace: 'booking',
+ overrides: {
+ module_name: adminModuleName,
+ header: { title: adminHeaderTitle }
+ }
+ };
+ await demoIndexedDB.setItem(MOCK_DB_KEY, payload);
+ setSyncStatus('✅ Saved tenant config to IndexedDB!');
+ await loadDbPayload();
+ };
+
+ // ─── Mock API ───────────────────────────────────────────────────
+ const mockFetchTenantConfig = async (companyId: string): Promise => {
+ if (companyId === 'company-a') {
+ const data = await demoIndexedDB.getItem(MOCK_DB_KEY);
+ if (!data) {
+ throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
+ }
+ return data;
+ } else if (companyId === 'company-b') {
+ // Hardcoded fallback for B
+ return {
+ namespace: 'booking',
+ overrides: {
+ module_name: 'PROCUREMENT (B)',
+ header: { title: 'Procurement List (B)' }
+ }
+ };
+ }
+ throw new Error('Unknown company');
+ };
+
+ // 1. Lazy-load the 'booking' namespace when the module mounts
+ useEffect(() => {
+ // Check if it's already loaded to prevent duplicate work, but for safety:
+ i18n.addResourceBundle('id', 'booking', bookingId, true, false);
+ i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
+ }, []);
+
+ // ─── Section A: Language Switcher ──────────────────────────────
+
+ const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
+ setSyncStatus('Syncing with backend...');
+
+ try {
+ await changeLanguage(newLng, async (lng, _prevLng) => {
+ // Mock API Call
+ await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (shouldFail) {
+ reject(new Error('Mock API 500: Failed to save preference'));
+ } else {
+ resolve(true);
+ }
+ }, 1000);
+ });
+
+ // If success
+ setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
+ });
+ } catch (error) {
+ setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ };
+
+ // ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
+
+ const handleSimulateLogin = async (companyId: string) => {
+ setIsFetchingConfig(true);
+ setActiveTenant(companyId);
+
+ try {
+ // 1. App successfully authenticates and fetches config
+ const config = await mockFetchTenantConfig(companyId);
+
+ // 2. Inject the deep-merge payload returned from the server
+ // In a real app, you might apply this to the current active language or all languages.
+ applyTenantOverrides(config.namespace, config.overrides, 'id');
+ applyTenantOverrides(config.namespace, config.overrides, 'en');
+
+ } catch (err) {
+ console.error('Failed to fetch config', err);
+ } finally {
+ setIsFetchingConfig(false);
+ }
+ };
+
+ const resetTenant = () => {
+ // To reset, we just reload the original bundles
+ i18n.addResourceBundle('id', 'booking', bookingId, true, true);
+ i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
+ setActiveTenant('default');
+ };
+
+ return (
+
+
🌐 Enterprise i18n Demo
+
+ Current Active Language: {i18n.language}
+
+
+ {/* ─── Admin Panel ──────────────────────────────────────────── */}
+
+
Admin Panel (Company A Config)
+
+ Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
+
+
+
+
+
+
+
+
+
+
+
Raw JSON in DB:
+
+ {dbPayloadStr}
+
+
+
+
+ {/* ─── Section A ────────────────────────────────────────────── */}
+
+
A. Language Switcher & Backend Sync
+
+ Change the language. The callback simulates a 1-second backend API request.
+
+
+
+
+
+
+
+
+ {syncStatus && (
+
+ {syncStatus}
+
+ )}
+
+
+ {/* ─── Section B ────────────────────────────────────────────── */}
+
+
B. Dynamic Tenant Overrides (End-to-End)
+
+ Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the deep-merge override.
+
+
+
+
+
+
+
+
+ {isFetchingConfig && (
+
+ ⏳ Fetching tenant config...
+
+ )}
+
+ {/* Display the localized strings */}
+
+
UI Result:
+
+
+
+ | Key |
+ Value |
+
+ {/* Type-safe keys from the common and booking namespaces */}
+
+ booking:module_name |
+ {t('booking:module_name')} |
+
+
+ booking:header.title |
+ {t('booking:header.title')} |
+
+
+ booking:header.subtitle |
+ {t('booking:header.subtitle')} |
+
+
+ booking:select_date |
+ {t('booking:select_date')} |
+
+
+ common:save |
+ {t('common:save')} |
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 8414dc5..40f43fc 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -15,10 +15,19 @@ initTelemetry({
import './main.css';
import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { setupI18n } from '@repo/core-i18n';
+
const App = lazy(() => import('./apps'));
-createRoot(document.getElementById('app')!).render(
-
-
- ,
-);
+async function bootstrap() {
+ // Initialize i18next and load language from secureStorage
+ await setupI18n();
+
+ createRoot(document.getElementById('app')!).render(
+
+
+ ,
+ );
+}
+
+bootstrap();
diff --git a/apps/web/src/types/i18next.d.ts b/apps/web/src/types/i18next.d.ts
new file mode 100644
index 0000000..9714310
--- /dev/null
+++ b/apps/web/src/types/i18next.d.ts
@@ -0,0 +1,14 @@
+import 'react-i18next';
+// Import the core types so we don't break the common namespace
+import type { resources as coreResources } from '@repo/core-i18n/src/setup';
+import bookingEn from '../apps/modules/example/features/i18n/locales/en/booking.json';
+
+// Combine core resources with app-specific decentralized resources
+declare module 'react-i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'common';
+ resources: typeof coreResources['en'] & {
+ booking: typeof bookingEn;
+ };
+ }
+}
diff --git a/packages/core-i18n/README.md b/packages/core-i18n/README.md
new file mode 100644
index 0000000..c34d7ba
--- /dev/null
+++ b/packages/core-i18n/README.md
@@ -0,0 +1,183 @@
+# Enterprise i18n Architecture (`@repo/core-i18n`)
+
+A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
+
+It uses a **Hybrid Namespace Strategy**:
+1. **Centralized Engine**: Setup, local persistence (`@repo/core-storage`), and global words (`common`).
+2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
+
+This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API and networking decisions entirely to the consuming applications.
+
+---
+
+## 1. App-Level Setup (Bootstrap)
+
+Initialize the engine *before* your React application mounts to prevent UI flashing.
+
+```tsx
+// apps/web/src/main.tsx
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { setupI18n } from '@repo/core-i18n';
+import App from './app';
+
+async function bootstrap() {
+ // Synchronously reads preferred language from storage & inits i18next
+ await setupI18n();
+
+ createRoot(document.getElementById('app')!).render(
+ ,
+ );
+}
+bootstrap();
+```
+
+---
+
+## 2. Module-Level Setup (Decentralized Dictionaries)
+
+Dictionaries live right next to the UI components that use them.
+
+### Folder Structure
+```text
+apps/web/src/apps/modules/booking/
+├── presentation/BookingTable.tsx
+└── locales/
+ ├── id/booking.json
+ └── en/booking.json
+```
+
+### Lazy Loading & Type Safety
+Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types.
+
+**1. Augment Types:**
+```ts
+// apps/web/src/types/i18next.d.ts
+import 'react-i18next';
+import type { resources as coreResources } from '@repo/core-i18n/src/setup';
+import bookingEn from '../apps/modules/booking/locales/en/booking.json';
+
+declare module 'react-i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'common';
+ resources: typeof coreResources['en'] & { booking: typeof bookingEn };
+ }
+}
+```
+
+**2. Use in Component:**
+```tsx
+import { useEffect } from 'react';
+import { i18n, useTranslation } from '@repo/core-i18n';
+import bookingId from '../locales/id/booking.json';
+import bookingEn from '../locales/en/booking.json';
+
+export default function BookingFeature() {
+ const { t } = useTranslation(['common', 'booking']);
+
+ useEffect(() => {
+ i18n.addResourceBundle('id', 'booking', bookingId, true, false);
+ i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
+ }, []);
+
+ return {t('booking:header.title')}
; // Autocomplete works!
+}
+```
+
+---
+
+## 3. Real-World Implementation Flow
+
+The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
+
+### A. The Tenant Override Flow (After Login)
+
+If "Company A" calls "Purchasing" -> "Procurement", they shouldn't need a custom build. The backend returns an override config, and the frontend dynamically merges it using `applyTenantOverrides`.
+
+```tsx
+// Example inside an AuthProvider or Post-Login useEffect
+import { useEffect } from 'react';
+import { applyTenantOverrides } from '@repo/core-i18n';
+import { api } from '@/api';
+
+export function AuthProvider({ children }) {
+ useEffect(() => {
+ async function fetchTenantConfig() {
+ try {
+ // 1. Fetch tenant-specific overrides from the API
+ const response = await api.get('/v1/tenant/i18n-config');
+
+ // 2. Inject into the engine.
+ // `deep: true` ensures only provided keys are overridden.
+ applyTenantOverrides(
+ response.data.namespace,
+ response.data.overrides
+ );
+ } catch (err) {
+ console.error("Failed to fetch tenant configuration", err);
+ }
+ }
+
+ fetchTenantConfig();
+ }, []);
+
+ return <>{children}>;
+}
+```
+
+### B. User Preference Sync (With Rollback)
+
+When a logged-in user changes their language, we update the UI instantly, save it locally, and sync it to the backend. If the backend fails, the engine automatically rolls back.
+
+```tsx
+import { changeLanguage } from '@repo/core-i18n';
+import { api } from '@/api';
+
+const handleSwitch = async (newLng: string) => {
+ try {
+ await changeLanguage(newLng, async (lng) => {
+ // The core engine waits for this Promise.
+ // If it throws, the UI reverts to the previous language automatically.
+ await api.patch('/v1/user/profile', { language: lng });
+ });
+ toast.success('Language saved!');
+ } catch (err) {
+ toast.error('Sync failed. Reverted to previous language.');
+ }
+};
+```
+> [!NOTE]
+> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
+
+---
+
+## 4. Backend API Contract (For Backend Engineers)
+
+To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
+
+### Identification
+The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
+
+### Expected JSON Response Format
+The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
+
+If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
+
+**Example Request:**
+`GET /v1/tenant/i18n-config`
+*(Authorization: Bearer eyJhbG...)*
+
+**Expected Response (200 OK):**
+```json
+{
+ "data": {
+ "namespace": "booking",
+ "overrides": {
+ "module_name": "Procurement",
+ "header": {
+ "title": "Procurement List"
+ }
+ }
+ }
+}
+```
diff --git a/packages/core-i18n/package.json b/packages/core-i18n/package.json
new file mode 100644
index 0000000..a7a83cd
--- /dev/null
+++ b/packages/core-i18n/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@repo/core-i18n",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./react-i18next": "./src/react-i18next.d.ts"
+ },
+ "scripts": {
+ "lint": "eslint \"src/**/*.ts\"",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@repo/core-storage": "workspace:*",
+ "@repo/utils": "workspace:*",
+ "i18next": "^24.2.2",
+ "react-i18next": "^15.4.0"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@types/react": "^19.0.8",
+ "typescript": "5.5.4"
+ }
+}
diff --git a/packages/core-i18n/src/index.ts b/packages/core-i18n/src/index.ts
new file mode 100644
index 0000000..5bcc610
--- /dev/null
+++ b/packages/core-i18n/src/index.ts
@@ -0,0 +1,4 @@
+export { setupI18n, type SupportedLanguage } from './setup';
+export { changeLanguage, applyTenantOverrides } from './manager';
+export { useTranslation, Trans } from 'react-i18next';
+export { default as i18n } from 'i18next';
diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json
new file mode 100644
index 0000000..e0c848b
--- /dev/null
+++ b/packages/core-i18n/src/locales/en/common.json
@@ -0,0 +1,12 @@
+{
+ "common": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "success": "Success",
+ "error": "Error",
+ "settings": "Settings",
+ "loading": "Loading...",
+ "delete": "Delete",
+ "edit": "Edit"
+ }
+}
diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json
new file mode 100644
index 0000000..cf07cb6
--- /dev/null
+++ b/packages/core-i18n/src/locales/id/common.json
@@ -0,0 +1,12 @@
+{
+ "common": {
+ "save": "Simpan",
+ "cancel": "Batal",
+ "success": "Sukses",
+ "error": "Galat",
+ "settings": "Pengaturan",
+ "loading": "Memuat...",
+ "delete": "Hapus",
+ "edit": "Ubah"
+ }
+}
diff --git a/packages/core-i18n/src/manager.ts b/packages/core-i18n/src/manager.ts
new file mode 100644
index 0000000..d25db0d
--- /dev/null
+++ b/packages/core-i18n/src/manager.ts
@@ -0,0 +1,56 @@
+import i18n from 'i18next';
+import { demoSecureStorage, StorageKey } from '@repo/core-storage';
+
+/**
+ * Changes the active language, saves the preference locally, and optionally syncs with the backend.
+ *
+ * @param newLng The new language code (e.g., 'en', 'id')
+ * @param syncCallback An optional callback to sync the preference to the backend. It receives the new language and the previous language.
+ */
+export async function changeLanguage(
+ newLng: string,
+ syncCallback?: (newLng: string, prevLng: string) => Promise
+): Promise {
+ const prevLng = i18n.language;
+
+ if (prevLng === newLng) return;
+
+ // 1. Update local storage & i18next optimistically
+ await demoSecureStorage.setItem(StorageKey.LOCALE, newLng);
+ await i18n.changeLanguage(newLng);
+
+ // 2. Trigger optional backend sync
+ if (syncCallback) {
+ try {
+ await syncCallback(newLng, prevLng);
+ } catch (error) {
+ console.error('[i18n] Backend sync failed, rolling back language', error);
+ // Rollback on failure
+ await demoSecureStorage.setItem(StorageKey.LOCALE, prevLng);
+ await i18n.changeLanguage(prevLng);
+ throw error; // Rethrow so the caller can show an error toast
+ }
+ }
+}
+
+/**
+ * Injects tenant-specific vocabulary overrides dynamically at runtime.
+ *
+ * Uses a deep-merge strategy. Overrides are applied to the currently active language,
+ * or across all loaded languages if needed.
+ *
+ * @param namespace The i18n namespace to override (e.g., 'common', 'booking')
+ * @param overrides A deeply nested object containing the overridden string keys and values.
+ * @param lng Specific language to override. Defaults to currently active language.
+ */
+export function applyTenantOverrides(
+ namespace: string,
+ overrides: Record,
+ lng?: string
+): void {
+ const targetLng = lng || i18n.language;
+
+ // deep: true -> merges with existing keys rather than replacing the whole namespace
+ // overwrite: true -> allows replacing existing specific keys
+ i18n.addResourceBundle(targetLng, namespace, overrides, true, true);
+}
diff --git a/packages/core-i18n/src/react-i18next.d.ts b/packages/core-i18n/src/react-i18next.d.ts
new file mode 100644
index 0000000..762d244
--- /dev/null
+++ b/packages/core-i18n/src/react-i18next.d.ts
@@ -0,0 +1,9 @@
+import 'react-i18next';
+import type { resources } from './setup';
+
+declare module 'react-i18next' {
+ interface CustomTypeOptions {
+ defaultNS: 'common';
+ resources: typeof resources['en'];
+ }
+}
diff --git a/packages/core-i18n/src/setup.ts b/packages/core-i18n/src/setup.ts
new file mode 100644
index 0000000..5ff0136
--- /dev/null
+++ b/packages/core-i18n/src/setup.ts
@@ -0,0 +1,57 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import { demoSecureStorage, StorageKey } from '@repo/core-storage';
+import commonEn from './locales/en/common.json';
+import commonId from './locales/id/common.json';
+
+const DEFAULT_LANGUAGE = 'id';
+const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
+
+export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
+
+export const resources = {
+ en: { common: commonEn.common },
+ id: { common: commonId.common },
+} as const;
+
+/**
+ * Bootstraps the central i18n engine.
+ *
+ * This reads the preferred locale from secureStorage and initializes
+ * i18next synchronously before React renders.
+ */
+export async function setupI18n(): Promise {
+ let initialLng = DEFAULT_LANGUAGE;
+ try {
+ const storedLng = await demoSecureStorage.getItem(StorageKey.LOCALE);
+ if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) {
+ initialLng = storedLng;
+ }
+ } catch (err) {
+ console.warn('[i18n] Failed to read locale from storage', err);
+ }
+
+ await i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: initialLng,
+ fallbackLng: DEFAULT_LANGUAGE,
+ defaultNS: 'common',
+ interpolation: {
+ escapeValue: false, // React already escapes values
+ },
+ });
+
+ // Apply initial language to the DOM for SEO/Accessibility
+ if (typeof document !== 'undefined') {
+ document.documentElement.lang = i18n.language;
+ }
+
+ // Ensure DOM updates whenever the language changes later
+ i18n.on('languageChanged', (lng) => {
+ if (typeof document !== 'undefined') {
+ document.documentElement.lang = lng;
+ }
+ });
+}
diff --git a/packages/core-i18n/tsconfig.json b/packages/core-i18n/tsconfig.json
new file mode 100644
index 0000000..65866cf
--- /dev/null
+++ b/packages/core-i18n/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/library.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4222c32..bd8343b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -103,6 +103,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
+ '@repo/core-i18n':
+ specifier: workspace:*
+ version: link:../../packages/core-i18n
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -112,12 +115,18 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@5.4.17)
+ i18next:
+ specifier: ^24.2.2
+ version: 24.2.3(typescript@5.5.4)
react:
specifier: ^19.2.3
version: 19.2.3
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
+ react-i18next:
+ specifier: ^15.4.0
+ version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@@ -152,6 +161,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
+ '@repo/core-i18n':
+ specifier: workspace:*
+ version: link:../../packages/core-i18n
'@repo/core-storage':
specifier: workspace:*
version: link:../../packages/core-storage
@@ -167,12 +179,18 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
+ i18next:
+ specifier: ^24.2.2
+ version: 24.2.3(typescript@5.5.4)
react:
specifier: ^19.2.3
version: 19.2.3
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
+ react-i18next:
+ specifier: ^15.4.0
+ version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
react-router-dom:
specifier: ^7.11.0
version: 7.11.0(react-dom@19.2.3)(react@19.2.3)
@@ -285,6 +303,34 @@ importers:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
+ packages/core-i18n:
+ dependencies:
+ '@repo/core-storage':
+ specifier: workspace:*
+ version: link:../core-storage
+ '@repo/utils':
+ specifier: workspace:*
+ version: link:../utils
+ i18next:
+ specifier: ^24.2.2
+ version: 24.2.3(typescript@5.5.4)
+ react-i18next:
+ specifier: ^15.4.0
+ version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
+ devDependencies:
+ '@repo/eslint-config':
+ specifier: workspace:*
+ version: link:../configs/eslint
+ '@repo/typescript-config':
+ specifier: workspace:*
+ version: link:../configs/typescript
+ '@types/react':
+ specifier: ^19.0.8
+ version: 19.2.7
+ typescript:
+ specifier: 5.5.4
+ version: 5.5.4
+
packages/core-storage:
dependencies:
'@repo/utils':
@@ -6218,6 +6264,12 @@ packages:
lru-cache: 10.4.3
dev: false
+ /html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+ dependencies:
+ void-elements: 3.1.0
+ dev: false
+
/http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
dev: true
@@ -6281,6 +6333,18 @@ packages:
ms: 2.1.3
dev: true
+ /i18next@24.2.3(typescript@5.5.4):
+ resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==}
+ peerDependencies:
+ typescript: ^5
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.28.4
+ typescript: 5.5.4
+ dev: false
+
/iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10}
@@ -8296,6 +8360,30 @@ packages:
react: 19.2.3
scheduler: 0.27.0
+ /react-i18next@15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4):
+ resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
+ peerDependencies:
+ i18next: '>= 23.4.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.28.4
+ html-parse-stringify: 3.0.1
+ i18next: 24.2.3(typescript@5.5.4)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ typescript: 5.5.4
+ dev: false
+
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
@@ -10145,6 +10233,11 @@ packages:
- yaml
dev: true
+ /void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+ dev: false
+
/walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
dev: false