- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Overview
This is the architecture reference for the i18n system. For adoption patterns (form labels, selects, validation, toasts, server-action errors) see Translation.
The application supports Arabic (RTL, default) and English (LTR). The system handles static UI translations via dictionary JSON and single-language user-generated content with on-demand translation via Google Translate + DB cache.
Supported Languages
| Locale | Name | Direction | Flag | Currency | Default |
|---|---|---|---|---|---|
ar | Arabic | RTL | SA | SAR | Yes |
en | English | LTR | US | USD | No |
Key Features
- URL-based routing:
/[lang]/path(e.g.,/ar/dashboard,/en/dashboard) - Single-language storage: One
langfield, translated on demand via Google Translate API - Logical CSS properties:
ms-,me-,ps-,pe-for auto-mirroring - Direction switching:
dirattribute on<html>element - Smart fallbacks: Falls back to available language if preferred is empty
Quick Start
Server Components
import type { Locale } from "@/components/internationalization/config"
import { getDictionary } from "@/components/internationalization/dictionaries"
export default async function Page({ params }: { params: { lang: string } }) {
const { lang } = await params
const dictionary = await getDictionary(lang as Locale)
return (
<div>
<h1>{dictionary.common.home}</h1>
<p>{dictionary.common.loading}</p>
</div>
)
}Client Components
"use client"
import { useLocale } from "@/components/internationalization/use-locale"
export function LocaleIndicator() {
const { locale, isRTL, localeConfig } = useLocale()
return (
<span className={isRTL ? "text-end" : "text-start"}>
{localeConfig.flag} {localeConfig.nativeName}
</span>
)
}User Content Storage
All user-generated content uses single-language storage with a lang field. Content is stored in one language only. Translation happens on-demand via Google Translate API with database caching through the Translation model.
Rules
- Generic field names only —
title,body,name,description(nevertitleAr,nameEn) langfield — Every content model haslang String @default("ar")indicating the stored language- On-demand translation — Use
getText()from@/components/translation/displayto translate at display time - Translation — Translated strings are cached in the database to avoid repeated API calls
- School's preferred language —
School.preferredLanguagedetermines the default storage language
Schema Pattern
model Announcement {
id String @id @default(cuid())
schoolId String
title String? // Generic field name (NOT titleEn/titleAr)
body String? @db.Text // Generic field name (NOT bodyEn/bodyAr)
lang String @default("ar") // Language of stored content
}Display with Translation
Lists — batched localize() (preferred). One DB query for the whole
page, three-tier resolution (in-memory LRU → Translation cache → provider
chain: Google → Groq LLM fallback, each behind a circuit breaker):
import { localize } from "@/components/translation/localize"
// names / arbitrary short labels — batched + transliteration fallback
import { getLabels, getNames } from "@/components/translation/person"
// rows of a registered model (see registry.ts TRANSLATABLE map)
const rows = await localize("Announcement", announcements, { schoolId, lang })
const labels = await getLabels(
rows.map((r) => r.name),
lang,
schoolId
)Single values — getText() (LRU-backed; never inside .map()):
import { getText } from "@/components/translation/display"
// Stored in Arabic, user views in English -> translates via API
const title = await getText(announcement.title, "ar", "en", schoolId)
// Same language -> returns directly, no API call
const title = await getText(announcement.title, "ar", "ar", schoolId)Writes — prewarm the cache so the first reader in the other language never waits:
import { after } from "next/server"
import { prewarm } from "@/components/translation/prewarm"
after(() => prewarm("Announcement", created, { schoolId }))Migration from legacy bilingual fields
The migration off titleEn/titleAr patterns to single-language storage is complete. New code must use generic field names (title, body, name) plus a lang field. CLAUDE.md gotcha #12 enforces this. For the historical SQL and adoption tracker, see Translation.
Infrastructure
| File | Purpose |
|---|---|
src/components/translation/localize.ts | Batched localize()/localizeOne() (preferred) |
src/components/translation/registry.ts | TRANSLATABLE map: model → translatable fields |
src/components/translation/prewarm.ts | prewarm() — cache-fill on write via after() |
src/components/translation/person.ts | getNames()/getLabels() — batched names/labels |
src/components/translation/memory-cache.ts | Process LRU (hot short terms, zero DB round-trips) |
src/components/translation/display.ts | getText(), getFields() — single-value path |
src/components/translation/actions.ts | translate() core (LRU → DB → provider chain) |
src/components/translation/engine.ts | Provider chain: Google → Groq, circuit breakers |
src/components/translation/google.ts | Google client: timeout, chunking, retry policy |
src/components/translation/groq.ts | Groq LLM fallback provider (free tier) |
src/components/translation/sweep.ts | Cache sweep core (i18n:backfill + daily cron) |
src/components/translation/util.ts | withLang, detectScript |
prisma/models/translation.prisma | Translation model (per-school cache) |
Configuration
Locale Config
Location: src/components/internationalization/config.ts
export const i18n = {
defaultLocale: "ar",
locales: ["en", "ar"],
} as const
export type Locale = (typeof i18n)["locales"][number]
export const localeConfig = {
en: {
name: "English",
nativeName: "English",
dir: "ltr",
flag: "🇺🇸",
dateFormat: "MM/dd/yyyy",
currency: "USD",
},
ar: {
name: "Arabic",
nativeName: "العربية",
dir: "rtl",
flag: "🇸🇦",
dateFormat: "dd/MM/yyyy",
currency: "SAR",
},
} as const
export function isRTL(locale: Locale): boolean {
return localeConfig[locale]?.dir === "rtl"
}Dictionary Loaders
Every namespace is registered ONCE in
src/components/internationalization/namespaces.ts — the server loader
(dictionaries.ts) and the client loader (get-dictionary-client.ts) both
derive from it, so their shapes can never drift (guarded by
dictionary-loader-sync.test.ts). All loaders are wrapped in React
cache(): one merge per request regardless of call-site count.
Route-scoped loaders keep the RSC payload down — use them in
DictionaryProvider layouts:
import {
getDictionary, // full merge (23 namespaces)
getExamDictionary, // core + marking + generate + results
getMessagingDictionary, // core + messages + messaging
getSaasDashboardDictionary, // core + sales + messages
} from "@/components/internationalization/dictionaries"For form / select / validation / toast / server-action adoption patterns, see Translation.
Translation Guard (CI)
en/ar drift cannot ship — these run in pnpm test and CI:
| Guard | Catches |
|---|---|
src/tests/i18n/dictionary-parity.test.ts | Any en/ar key drift + committed [AR]/[EN] stubs |
src/tests/i18n/dictionary-loader-sync.test.ts | Server vs client dictionary shape divergence |
src/tests/i18n/hardcoded-ratchet.test.ts | NEW hardcoded English strings (8 anti-patterns) |
src/tests/i18n/rtl-physical-class.test.ts | Physical CSS classes (ml-, text-left…) — at 0 |
pnpm i18n:check (CI step) | Same parity engine, fail-fast before tests |
RTL Support with Logical Properties
CSS Logical Properties (Preferred)
Always use logical CSS properties instead of physical directions. These automatically adapt to RTL/LTR:
| Physical (Avoid) | Logical (Use) | Description |
|---|---|---|
ml-* | ms-* | Margin inline-start |
mr-* | me-* | Margin inline-end |
pl-* | ps-* | Padding inline-start |
pr-* | pe-* | Padding inline-end |
left-* | start-* | Position start |
right-* | end-* | Position end |
text-left | text-start | Text alignment start |
text-right | text-end | Text alignment end |
border-l-* | border-s-* | Border start |
border-r-* | border-e-* | Border end |
rounded-l-* | rounded-s-* | Border radius start |
rounded-r-* | rounded-e-* | Border radius end |
Example:
// Wrong - physical properties don't adapt
<div className="ml-4 pr-2 text-left border-l-2">
// Correct - logical properties adapt to RTL/LTR
<div className="ms-4 pe-2 text-start border-s-2">Layout Integration
The root layout sets direction based on locale:
// src/app/[lang]/layout.tsx
import {
localeConfig,
type Locale,
} from "@/components/internationalization/config"
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
const { lang } = (await params) as { lang: Locale }
const config = localeConfig[lang]
return (
<html lang={lang} dir={config.dir}>
<body>{children}</body>
</html>
)
}When to Use rtl: Variants
Only use rtl: variants for cases that cannot be solved with logical properties:
// Flex direction reversal (no logical equivalent)
<div className="flex flex-row rtl:flex-row-reverse">
// Icon mirroring for directional icons
<ChevronRight className="rtl:scale-x-[-1]" />
// Space reversal in flex
<div className="flex space-x-2 rtl:space-x-reverse">Client-Side Hooks
useLocale
"use client"
import { useLocale } from "@/components/internationalization/use-locale"
export function MyComponent() {
const { locale, isRTL, localeConfig } = useLocale()
return (
<div dir={isRTL ? "rtl" : "ltr"}>
<p>Current: {localeConfig.nativeName}</p>
<p>Direction: {localeConfig.dir}</p>
</div>
)
}useDictionary
"use client"
import { useDictionary } from "@/components/internationalization/use-dictionary"
export function ClientComponent() {
const { dictionary, isLoading } = useDictionary()
if (isLoading) return <Spinner />
return <h1>{dictionary?.common.welcome}</h1>
}useSwitchLocaleHref
"use client"
import Link from "next/link"
import { useSwitchLocaleHref } from "@/components/internationalization/use-locale"
export function LanguageSwitcher() {
const switchLocaleHref = useSwitchLocaleHref()
return (
<nav className="flex gap-2">
<Link href={switchLocaleHref("ar")}>العربية</Link>
<Link href={switchLocaleHref("en")}>English</Link>
</nav>
)
}Arabic Number & Date Formatting
Number Formatting
const formatter = new Intl.NumberFormat(locale, {
style: "decimal",
maximumFractionDigits: 2,
})
formatter.format(1234567) // ar: "١٬٢٣٤٬٥٦٧" | en: "1,234,567"
const currencyFormatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: locale === "ar" ? "SAR" : "USD",
})
currencyFormatter.format(99.99) // ar: "٩٩٫٩٩ ر.س" | en: "$99.99"Date Formatting
const dateFormatter = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
})
dateFormatter.format(new Date()) // ar: "١ ديسمبر ٢٠٢٥" | en: "December 1, 2025"
const relativeFormatter = new Intl.RelativeTimeFormat(locale, {
numeric: "auto",
})
relativeFormatter.format(-2, "day") // ar: "منذ يومين" | en: "2 days ago"Arabic Pluralization
Arabic has three plural forms: singular (1), dual (2), and plural (3+).
function arabicPlural(
count: number,
singular: string,
dual: string,
plural: string
): string {
if (count === 1) return singular
if (count === 2) return dual
return plural
}
// Usage
arabicPlural(count, "طالب", "طالبان", "طلاب") // student/students
arabicPlural(count, "يوم", "يومان", "أيام") // day/daysBest Practices
UI Guidelines
- Use logical properties —
ms-,me-,ps-,pe-instead ofml-,mr-, etc. - Mirror directional icons — Arrows and chevrons should flip in RTL
- Test both languages — Always verify layouts in ar and en
- Numbers stay Western — Unless explicitly localized for display
Content Guidelines
- Avoid string concatenation — Use full-sentence translations
- Generic field names only —
title,body,name(nevertitleEn/titleAr) - Single-language storage — Store in one language with a
langfield - On-demand translation — Use
getText()for display-time translation
Typography
- Arabic font: Rubik (system default)
- English font: GeistSans
- Line height: 1.8 recommended for Arabic
- No font-size hardcoding — Use semantic HTML (h1-h6, p, small)
Folder Structure
src/
components/internationalization/
config.ts # Locale config, types, isRTL helper
namespaces.ts # SINGLE namespace registry (flat + 19 feature)
dictionaries.ts # Server loaders (cache()-wrapped, route-scoped)
get-dictionary-client.ts # Client loader (same registry)
locale-detect.ts # detectLocale/pathnameHasLocale (live proxy logic)
use-locale.ts # Client hooks: useLocale, useSwitchLocaleHref
use-dictionary.ts # useDictionary hook (context-first)
dictionary-context.tsx # DictionaryProvider
language-switcher.tsx # Language switcher component
actions.ts # setLocale server action
helpers/index.ts # ValidationHelper, ToastHelper, ErrorHelper
lib/key-diff.ts # Pure en/ar diff engine
lib/parity.ts # Real-file parity + placeholder scanner
en.json / ar.json # General translations (flat)
school-{en,ar}.json # School dashboard (flat, largest pair)
lumos-{en,ar}.json # Lumos/LMS (flat)
operator-{en,ar}.json # SaaS operator (flat)
dictionaries/{en,ar}/ # 19 feature namespaces: admin, attendance,
# banking, compliance, finance, generate, lab,
# library, live-classes, marking, messages,
# messaging, notifications, parentPortal,
# profile, results, sales, transportation,
# whatsapp
components/translation/ # System B: dynamic content engine (own README)
References
Internal
- Translation Guide — deep guide to DB-content (on-demand) translation
- Translation — adoption patterns, coverage tracker
src/components/translation/display.ts—getText()for on-demand translationsrc/components/translation/google.ts— Google Translate API clientsrc/components/translation/actions.ts— Translation with database cachingsrc/components/internationalization/— Full i18n implementation
External
On This Page
OverviewSupported LanguagesKey FeaturesQuick StartServer ComponentsClient ComponentsUser Content StorageRulesSchema PatternDisplay with TranslationMigration from legacy bilingual fieldsInfrastructureConfigurationLocale ConfigDictionary LoadersTranslation Guard (CI)RTL Support with Logical PropertiesCSS Logical Properties (Preferred)Layout IntegrationWhen to Usertl: VariantsClient-Side HooksuseLocaleuseDictionaryuseSwitchLocaleHrefArabic Number & Date FormattingNumber FormattingDate FormattingArabic PluralizationBest PracticesUI GuidelinesContent GuidelinesTypographyFolder StructureReferencesInternalExternal