Internationalization & Localization Research¶
Project: Forma3D.Connect
Version: 1.0
Date: March 10, 2026
Status: Research
Scope: Per-user internationalization covering language, timezone, measurement system, date/number formatting, and locale-aware content
Related: saas-launch-readiness-research.md §14, multi-tenant-ux-redesign-research.md
Table of Contents¶
- Executive Summary
- Current State Audit
- Requirements
- Data Model — Per-User Preferences
- Frontend i18n Architecture
- Backend i18n Architecture
- Locale Dimensions
- Translation Workflow
- Email & Notification Localization
- API Localization
- Accessibility & RTL Considerations
- Testing Strategy
- Migration Plan
- Library Evaluation
- Effort Estimation
- Risks & Mitigations
- Recommendations
- References
1. Executive Summary¶
Forma3D.Connect is a multi-tenant B2B SaaS targeting European 3D print-on-demand businesses. Expanding beyond the initial Belgian/Dutch market requires full internationalization (i18n) and localization (l10n) support. This document researches how to make the platform multilingual with per-user locale preferences covering language, timezone, date/number formatting, and measurement units.
Key Findings¶
- Current state: All UI text is hardcoded English. Date formatting uses hardcoded
en-USoren-BElocales. No user or tenant-level locale preferences exist in the database. - Recommended approach: Two-tier locale system — tenant sets defaults, individual users can override. Uses
react-i18nextfor the frontend andnestjs-i18nor customIntl-based formatters for the backend. - Scope of localization: Language (UI text), timezone, date format, number format, measurement units (metric/imperial), currency display, and address formatting.
- Priority languages: English (default), Dutch, French, German — covering Belgium, Netherlands, Germany, Austria, Switzerland.
- Estimated effort: 80–120 hours for foundation + first two languages; 8–12 hours per additional language.
2. Current State Audit¶
2.1 What exists today¶
| Aspect | Current Implementation | Problem |
|---|---|---|
| UI text | Hardcoded English strings in JSX | Cannot translate without code changes |
| Date formatting | toLocaleDateString('en-US', ...) in libs/utils/src/lib/date.ts |
Hardcoded locale; ignores user preference |
| Relative time | Custom English strings ("just now", "X minutes ago") |
Not localizable |
| Number formatting | Mixed — some use .toLocaleString() without locale, some hardcode format |
Inconsistent; not user-aware |
| Currency | EUR hardcoded with locale-specific formatting in places | Correct currency, wrong display locale |
| Timezone | UTC storage (correct); display uses browser/system timezone | No user-configurable timezone |
| Units | Metric only (mm, cm) throughout codebase | No imperial option for US/UK users |
| Backend emails | toLocaleString('en-BE', ...) for dates in email services |
Hardcoded; should respect user locale |
| User model | No locale, timezone, or preference fields |
Cannot store per-user settings |
| Tenant model | No defaultLocale or timezone fields |
Cannot set tenant-wide defaults |
| Shopify theme | en.default.json locale file (Shopify i18n structure) |
Only English; structure supports adding languages |
2.2 Files requiring changes¶
| Category | Files / Patterns | Estimated string count |
|---|---|---|
| Pages | apps/web/src/pages/**/*.tsx |
~400 strings |
| Components | apps/web/src/components/**/*.tsx |
~250 strings |
| Shared UI | libs/ui/src/**/*.tsx |
~100 strings |
| Utility formatters | libs/utils/src/lib/date.ts, number formatters |
~30 strings |
| Backend errors | DTOs, exception messages, validation | ~80 strings |
| Email templates | Email services in apps/api/src |
~120 strings |
| Total | ~980 strings |
3. Requirements¶
3.1 Functional requirements¶
| ID | Requirement | Priority |
|---|---|---|
| I18N-01 | Users can select their preferred language from a dropdown in their profile/settings | Must |
| I18N-02 | Users can set their timezone (IANA format, e.g. Europe/Brussels) |
Must |
| I18N-03 | Users can choose their date format preference (e.g. DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD) | Must |
| I18N-04 | Users can choose metric or imperial measurement units | Must |
| I18N-05 | Tenants can set defaults for all locale preferences (applied to new users) | Must |
| I18N-06 | All UI text is translatable without code changes | Must |
| I18N-07 | Dates and times display in the user's timezone with their chosen format | Must |
| I18N-08 | Numbers and currencies format according to the user's locale | Must |
| I18N-09 | Transactional emails render in the recipient's preferred language | Should |
| I18N-10 | API error messages are localizable | Should |
| I18N-11 | Pluralization rules handle complex cases (e.g. 0 items, 1 item, 2 items) | Must |
| I18N-12 | Language switch is instant (no page reload) | Should |
| I18N-13 | Users can choose 12-hour or 24-hour time format | Should |
| I18N-14 | First day of week preference (Monday vs. Sunday) for calendar views | Nice |
| I18N-15 | RTL layout support for future Arabic/Hebrew markets | Nice |
3.2 Non-functional requirements¶
| Requirement | Target |
|---|---|
| Translation bundle size | < 50 KB per language (gzipped) |
| Language switching latency | < 200ms |
| Missing translation fallback | Fall back to English; log warning in development |
| Translation coverage before release | ≥ 98% per language |
| No hardcoded strings in JSX | Enforced by ESLint rule |
4. Data Model — Per-User Preferences¶
4.1 Schema changes¶
The per-user preference model stores locale settings at both the tenant level (defaults) and the user level (overrides). When a user has not set a specific preference, the tenant default is used. When a tenant has not set a default, the system default (English, UTC, metric) applies.
// Add to the User model
model User {
// ... existing fields ...
// i18n preferences (null = inherit from tenant)
locale String? // BCP 47 language tag: "en", "nl", "fr", "de"
timezone String? // IANA timezone: "Europe/Brussels", "America/New_York"
dateFormat String? // "DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"
timeFormat String? // "24h", "12h"
measurementSystem String? // "metric", "imperial"
firstDayOfWeek Int? // 0 = Sunday, 1 = Monday
}
// Add to the Tenant model
model Tenant {
// ... existing fields ...
// i18n defaults for all users in this tenant
defaultLocale String @default("en")
defaultTimezone String @default("UTC")
defaultDateFormat String @default("DD/MM/YYYY")
defaultTimeFormat String @default("24h")
defaultMeasurementSystem String @default("metric")
defaultFirstDayOfWeek Int @default(1) // Monday for EU
}
4.2 Preference resolution chain¶
User preference (if set)
└── Tenant default (if set)
└── System default (English, UTC, metric, DD/MM/YYYY, 24h, Monday)
4.3 API for preference management¶
// GET /api/v1/users/me/preferences
interface UserPreferences {
locale: string; // resolved value
timezone: string; // resolved value
dateFormat: string; // resolved value
timeFormat: string; // resolved value
measurementSystem: string; // resolved value
firstDayOfWeek: number; // resolved value
source: {
locale: 'user' | 'tenant' | 'system';
timezone: 'user' | 'tenant' | 'system';
dateFormat: 'user' | 'tenant' | 'system';
timeFormat: 'user' | 'tenant' | 'system';
measurementSystem: 'user' | 'tenant' | 'system';
firstDayOfWeek: 'user' | 'tenant' | 'system';
};
}
// PATCH /api/v1/users/me/preferences
interface UpdateUserPreferences {
locale?: string | null; // null = reset to tenant default
timezone?: string | null;
dateFormat?: string | null;
timeFormat?: string | null;
measurementSystem?: string | null;
firstDayOfWeek?: number | null;
}
// PATCH /api/v1/tenants/:id/defaults (admin only)
interface UpdateTenantDefaults {
defaultLocale?: string;
defaultTimezone?: string;
defaultDateFormat?: string;
defaultTimeFormat?: string;
defaultMeasurementSystem?: string;
defaultFirstDayOfWeek?: number;
}
4.4 Supported values¶
| Preference | Supported Values |
|---|---|
| locale | en, nl, fr, de, es, it (extensible) |
| timezone | Any valid IANA timezone (Europe/Brussels, Europe/Amsterdam, Europe/Berlin, America/New_York, etc.) |
| dateFormat | DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD |
| timeFormat | 24h, 12h |
| measurementSystem | metric, imperial |
| firstDayOfWeek | 0 (Sunday), 1 (Monday) |
5. Frontend i18n Architecture¶
5.1 Library: react-i18next¶
react-i18next is the recommended library based on ecosystem maturity, React 19 compatibility, and the existing SaaS readiness research recommendation.
apps/web/
├── src/
│ ├── i18n/
│ │ ├── index.ts # i18next initialization
│ │ ├── locales/
│ │ │ ├── en/
│ │ │ │ ├── common.json # shared strings (buttons, labels, nav)
│ │ │ │ ├── dashboard.json
│ │ │ │ ├── orders.json
│ │ │ │ ├── settings.json
│ │ │ │ ├── errors.json
│ │ │ │ └── ...
│ │ │ ├── nl/
│ │ │ │ ├── common.json
│ │ │ │ ├── dashboard.json
│ │ │ │ └── ...
│ │ │ └── fr/
│ │ │ └── ...
│ │ └── types.ts # typed translation keys
│ ├── contexts/
│ │ └── locale-context.tsx # locale state + preference resolution
│ └── hooks/
│ ├── use-locale.ts # access resolved locale preferences
│ ├── use-format-date.ts # locale-aware date formatting
│ ├── use-format-number.ts # locale-aware number formatting
│ └── use-measurement.ts # metric/imperial conversion
5.2 i18next configuration¶
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'nl', 'fr', 'de'],
ns: ['common', 'dashboard', 'orders', 'settings', 'errors'],
defaultNS: 'common',
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'forma3d-locale',
},
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
react: {
useSuspense: true,
},
});
5.3 Namespace strategy¶
Split translation files by feature area to enable lazy-loading and reduce initial bundle size.
| Namespace | Content | Load strategy |
|---|---|---|
common |
Nav, buttons, labels, status badges, shared UI | Eager (always loaded) |
dashboard |
Dashboard cards, charts, welcome text | Lazy (on route) |
orders |
Order list, detail, status labels | Lazy (on route) |
printjobs |
Print job status, printer labels | Lazy (on route) |
settings |
Settings page labels and descriptions | Lazy (on route) |
errors |
Validation and API error messages | Eager (always loaded) |
onboarding |
Onboarding wizard steps | Lazy (on route) |
5.4 Component usage patterns¶
// Before (hardcoded)
<h1>Orders</h1>
<p>No orders found</p>
<button>Create Order</button>
// After (i18n)
import { useTranslation } from 'react-i18next';
function OrdersPage() {
const { t } = useTranslation('orders');
return (
<>
<h1>{t('title')}</h1>
<p>{t('empty_state')}</p>
<button>{t('create_button')}</button>
</>
);
}
Pluralization with ICU MessageFormat¶
{
"order_count": "{count, plural, =0 {No orders} one {# order} other {# orders}}"
}
<p>{t('order_count', { count: orders.length })}</p>
5.5 Locale-aware formatting hooks¶
// use-format-date.ts
import { useLocale } from './use-locale';
export function useFormatDate() {
const { timezone, dateFormat, timeFormat, locale } = useLocale();
const formatDate = (date: string | Date): string => {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
};
const formatDateTime = (date: string | Date): string => {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: timeFormat === '12h',
}).format(d);
};
const formatRelativeTime = (date: string | Date): string => {
const d = typeof date === 'string' ? new Date(date) : date;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffMs = d.getTime() - Date.now();
const diffMinutes = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute');
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour');
return rtf.format(diffDays, 'day');
};
return { formatDate, formatDateTime, formatRelativeTime };
}
// use-measurement.ts
import { useLocale } from './use-locale';
const MM_PER_INCH = 25.4;
const CM_PER_INCH = 2.54;
export function useMeasurement() {
const { measurementSystem } = useLocale();
const isMetric = measurementSystem === 'metric';
const formatLength = (valueMm: number): string => {
if (isMetric) {
return valueMm >= 10
? `${(valueMm / 10).toFixed(1)} cm`
: `${valueMm.toFixed(1)} mm`;
}
return `${(valueMm / MM_PER_INCH).toFixed(2)} in`;
};
const formatWeight = (valueGrams: number): string => {
if (isMetric) {
return valueGrams >= 1000
? `${(valueGrams / 1000).toFixed(2)} kg`
: `${valueGrams.toFixed(0)} g`;
}
return `${(valueGrams / 28.3495).toFixed(2)} oz`;
};
const toMm = (value: number, unit: 'mm' | 'cm' | 'in'): number => {
switch (unit) {
case 'cm': return value * 10;
case 'in': return value * MM_PER_INCH;
default: return value;
}
};
return { formatLength, formatWeight, toMm, isMetric };
}
5.6 Locale context provider¶
interface LocaleContextValue {
locale: string;
timezone: string;
dateFormat: string;
timeFormat: string;
measurementSystem: 'metric' | 'imperial';
firstDayOfWeek: number;
updatePreference: (key: string, value: string | number | null) => Promise<void>;
}
The LocaleProvider wraps the app, fetches resolved preferences from GET /api/v1/users/me/preferences at startup, and provides them via context. When a preference changes, it calls the PATCH endpoint and updates context immediately (optimistic update).
6. Backend i18n Architecture¶
6.1 Scope¶
The backend does not serve rendered HTML but does produce localizable content in:
| Output | Content requiring localization |
|---|---|
| API error messages | Validation errors, business rule violations |
| Transactional emails | Order confirmations, shipping notifications, billing emails |
| PDF exports | Invoices, packing slips, reports |
| Push notifications | Notification titles and bodies |
| Audit log descriptions | Human-readable event descriptions |
6.2 Approach¶
Return i18n keys from the API; let the frontend resolve them. For server-rendered content (emails, PDFs), resolve on the backend using the user's stored locale.
// Backend returns structured errors with i18n keys
{
"statusCode": 400,
"error": "VALIDATION_ERROR",
"message": "errors.order.invalid_quantity",
"params": { "min": 1, "max": 999 }
}
// Frontend resolves:
// errors.json: { "order": { "invalid_quantity": "Quantity must be between {min} and {max}" } }
// errors.json (nl): { "order": { "invalid_quantity": "Aantal moet tussen {min} en {max} zijn" } }
6.3 Email template localization¶
apps/api/src/
├── i18n/
│ ├── locales/
│ │ ├── en.json
│ │ ├── nl.json
│ │ └── fr.json
│ └── i18n.service.ts
├── email/
│ ├── templates/
│ │ ├── order-confirmation/
│ │ │ ├── en.hbs
│ │ │ ├── nl.hbs
│ │ │ └── fr.hbs
│ │ └── shipping-notification/
│ │ ├── en.hbs
│ │ ├── nl.hbs
│ │ └── fr.hbs
│ └── email.service.ts
The EmailService looks up the recipient's locale preference and selects the matching template. Dates and numbers within emails are formatted using Intl.DateTimeFormat and Intl.NumberFormat with the user's resolved locale and timezone.
6.4 Middleware: locale resolution¶
@Injectable()
export class LocaleMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Priority: 1) User DB preference, 2) Accept-Language header, 3) Tenant default, 4) 'en'
const userLocale = req.user?.locale;
const tenantLocale = req.tenant?.defaultLocale;
const headerLocale = req.headers['accept-language']?.split(',')[0]?.split('-')[0];
req.resolvedLocale = userLocale ?? headerLocale ?? tenantLocale ?? 'en';
req.resolvedTimezone = req.user?.timezone ?? req.tenant?.defaultTimezone ?? 'UTC';
next();
}
}
7. Locale Dimensions¶
7.1 Language¶
| Code | Language | Target Market | Phase |
|---|---|---|---|
en |
English | Global, UK, Ireland | 1 (Launch) |
nl |
Dutch | Belgium (Flanders), Netherlands | 1 (Launch) |
fr |
French | Belgium (Wallonia), France | 2 (Q3 2026) |
de |
German | Germany, Austria, Switzerland | 2 (Q3 2026) |
es |
Spanish | Spain | 3 (2027) |
it |
Italian | Italy | 3 (2027) |
7.2 Timezone¶
All timestamps are stored as UTC in PostgreSQL (current behavior — correct). Display conversion happens at the presentation layer using the user's configured IANA timezone.
Common timezones for target markets:
| Market | IANA Timezone | UTC Offset (winter / summer) |
|---|---|---|
| Belgium | Europe/Brussels |
UTC+1 / UTC+2 |
| Netherlands | Europe/Amsterdam |
UTC+1 / UTC+2 |
| Germany | Europe/Berlin |
UTC+1 / UTC+2 |
| France | Europe/Paris |
UTC+1 / UTC+2 |
| UK | Europe/London |
UTC+0 / UTC+1 |
| US East | America/New_York |
UTC-5 / UTC-4 |
| US West | America/Los_Angeles |
UTC-8 / UTC-7 |
The timezone dropdown should show timezone names in the user's language with current UTC offset, e.g. "Brussels (UTC+1)" or "Brussel (UTC+1)" in Dutch.
7.3 Date and time format¶
| Format ID | Example | Typical Markets |
|---|---|---|
DD/MM/YYYY |
10/03/2026 | Belgium, Netherlands, France, UK, most of EU |
MM/DD/YYYY |
03/10/2026 | United States |
YYYY-MM-DD |
2026-03-10 | ISO 8601, technical users, Scandinavia |
24h |
14:30 | Continental Europe |
12h |
2:30 PM | US, UK |
7.4 Number and currency formatting¶
Use Intl.NumberFormat with the user's resolved BCP 47 locale tag.
| Locale | Number | Currency |
|---|---|---|
en |
1,490.00 | €1,490.00 |
nl-BE |
1.490,00 | € 1.490,00 |
nl-NL |
1.490,00 | € 1.490,00 |
fr-BE |
1 490,00 | 1 490,00 € |
de-DE |
1.490,00 | 1.490,00 € |
Currency remains EUR for all EU markets. The format position (symbol before/after number) and separator style vary by locale.
7.5 Measurement system¶
| System | Length | Weight | Temperature | Target Markets |
|---|---|---|---|---|
| Metric | mm, cm, m | g, kg | °C | EU (all markets), most of world |
| Imperial | in, ft | oz, lb | °F | US, UK (partial) |
Conversion constants (stored centrally):
| From | To | Factor |
|---|---|---|
| mm | inches | ÷ 25.4 |
| cm | inches | ÷ 2.54 |
| g | oz | ÷ 28.3495 |
| kg | lb | ÷ 0.453592 |
| °C | °F | × 9/5 + 32 |
All internal storage remains metric (mm, g). Conversion happens at the display layer only.
7.6 Address format¶
| Country | Format | Example |
|---|---|---|
| Belgium | {street} {number}, {postalCode} {city} |
Kerkstraat 42, 2000 Antwerpen |
| Netherlands | {street} {number}, {postalCode} {city} |
Damstraat 1, 1012 JM Amsterdam |
| Germany | {street} {number}, {postalCode} {city} |
Hauptstraße 5, 10115 Berlin |
| France | {number} {street}, {postalCode} {city} |
5 Rue de Rivoli, 75001 Paris |
| US | {number} {street}, {city}, {state} {zip} |
123 Main St, New York, NY 10001 |
8. Translation Workflow¶
8.1 Process¶
8.2 Translation tooling options¶
| Tool | Type | Cost | Fit |
|---|---|---|---|
| Tolgee | Self-hosted / Cloud OSS | Free (self-hosted) | Excellent — integrates with react-i18next, in-context editing |
| Crowdin | Cloud | Free for OSS, paid for private | Good — industry standard, great CI integration |
| Lokalise | Cloud | From €120/mo | Good — developer-friendly, over-the-air updates |
| POEditor | Cloud | From €15/mo | Adequate — simple, affordable |
| Manual JSON | File-based | Free | Viable for 2–3 languages; does not scale well |
Recommendation: Start with manual JSON files for English + Dutch (Phase 1). Adopt Tolgee (self-hosted) or Crowdin when adding French/German in Phase 2, as managing 4+ languages manually becomes error-prone.
8.3 Translation key naming conventions¶
{namespace}.{feature}.{element}_{qualifier}
Examples:
common.button.save → "Save"
common.button.cancel → "Cancel"
orders.list.title → "Orders"
orders.list.empty_state → "No orders yet"
orders.detail.status_label → "Status"
orders.detail.items_count → "{count, plural, one {# item} other {# items}}"
settings.locale.timezone_label → "Timezone"
errors.validation.required → "This field is required"
errors.order.not_found → "Order not found"
9. Email & Notification Localization¶
9.1 Email language resolution¶
Recipient's user.locale preference
└── Tenant's defaultLocale
└── "en" (system fallback)
9.2 Email content that requires localization¶
| Dynamic content | Locale-sensitive formatting | |
|---|---|---|
| Order confirmation | Product names, quantities, prices | Date, currency, measurement |
| Print job started | Printer name, estimated time | Date/time in user's timezone |
| Shipping notification | Carrier, tracking number, ETA | Date in user's format |
| Payment receipt | Amount, plan name, period | Currency, date |
| Trial ending | Days remaining, plan options | Date, currency |
9.3 Push notification localization¶
Push notification titles and bodies should be pre-localized on the backend before sending, since the notification payload is rendered by the browser/OS and cannot be translated client-side.
// Resolve push notification text in user's language
const title = i18nService.t('notifications.order_received.title', {
lng: user.locale ?? tenant.defaultLocale ?? 'en',
orderNumber: order.shopifyOrderNumber,
});
10. API Localization¶
10.1 Request locale negotiation¶
| Mechanism | Header / Parameter | Priority |
|---|---|---|
| User preference (DB) | — (server-side lookup) | 1 (highest) |
Accept-Language header |
Accept-Language: nl-BE,nl;q=0.9,en;q=0.8 |
2 |
| Tenant default (DB) | — (server-side lookup) | 3 |
| System fallback | — | 4 (lowest) |
10.2 Response conventions¶
API responses return data in a locale-neutral format (ISO dates, numbers as numbers, dimensions in mm). Formatting is the frontend's responsibility.
{
"order": {
"createdAt": "2026-03-10T14:30:00.000Z",
"total": 149.99,
"currency": "EUR",
"items": [
{
"name": "Custom Drawer Organizer",
"widthMm": 200,
"depthMm": 150,
"heightMm": 50,
"weightG": 120
}
]
}
}
The frontend's formatting hooks then convert widthMm: 200 to "20.0 cm" or "7.87 in" based on the user's measurement preference, and createdAt to "10/03/2026 15:30" for a Europe/Brussels user.
11. Accessibility & RTL Considerations¶
11.1 HTML lang attribute¶
Set <html lang="nl"> dynamically based on the active language. Screen readers use this to select the correct pronunciation engine.
11.2 RTL preparation¶
Although Phase 1–3 languages are all LTR, the CSS architecture should be prepared for RTL:
- Use logical CSS properties (
margin-inline-startinstead ofmargin-left) - Tailwind CSS supports RTL via the
rtl:variant (available since Tailwind v3) - Set
dir="rtl"on<html>when an RTL language is active - Test with
dir="rtl"periodically to catch layout assumptions
11.3 Font considerations¶
Latin-script languages (EN, NL, FR, DE, ES, IT) share the same font stack. No additional web fonts are needed for Phase 1–3. Future CJK or Arabic support would require additional font loading.
12. Testing Strategy¶
12.1 Unit tests¶
| What to test | How |
|---|---|
| Preference resolution chain | Unit test the resolver with various combinations of user/tenant/system values |
| Date formatting | Test useFormatDate with different locales, timezones, and formats |
| Number formatting | Test useFormatNumber with different locale decimal/grouping separators |
| Measurement conversion | Test useMeasurement for mm↔in, g↔oz accuracy |
| Pluralization | Test with 0, 1, 2, and high counts for each language |
| Missing translation fallback | Verify English fallback when key is missing in target language |
12.2 Integration tests¶
| What to test | How |
|---|---|
| Preference CRUD | Test PATCH/GET endpoints for user and tenant preferences |
| Email language selection | Verify correct template is selected based on user locale |
| Middleware locale resolution | Test priority chain (user → header → tenant → system) |
12.3 Visual / E2E tests¶
| What to test | How |
|---|---|
| Text overflow | Verify UI does not break with longer translations (German is ~30% longer than English) |
| Language switching | Switch language and verify all visible text updates without reload |
| Date/number formatting | Verify display matches the user's configured preferences |
| Settings UI | Verify locale preferences can be changed and persist |
12.4 Translation completeness CI check¶
Add a CI step that:
1. Runs i18next-parser to extract all used keys from source code
2. Compares against each locale's JSON files
3. Fails if coverage drops below 98% for any enabled language
4. Reports missing keys in PR comments
13. Migration Plan¶
13.1 Phase 1: Foundation (English + Dutch)¶
| Step | Description | Effort |
|---|---|---|
| 1 | Add locale fields to User and Tenant Prisma models; create migration |
2h |
| 2 | Create preference resolution service (backend) | 4h |
| 3 | Create preferences API endpoints (GET, PATCH) | 4h |
| 4 | Set up react-i18next with namespace structure and lazy loading |
4h |
| 5 | Create LocaleProvider context and formatting hooks |
6h |
| 6 | Extract all English strings from JSX into en/*.json files |
16h |
| 7 | Replace libs/utils/src/lib/date.ts formatters with locale-aware versions |
4h |
| 8 | Add locale settings section to Settings page | 4h |
| 9 | Translate all keys to Dutch (nl/*.json) |
12h |
| 10 | Update email templates for locale selection | 8h |
| 11 | Add ESLint rule to prevent hardcoded strings in JSX | 2h |
| 12 | Add CI translation completeness check | 2h |
| 13 | Testing and QA | 8h |
| Phase 1 Total | ~76h |
13.2 Phase 2: French + German (Q3 2026)¶
| Step | Description | Effort |
|---|---|---|
| 1 | Set up translation management tool (Tolgee or Crowdin) | 4h |
| 2 | Translate all keys to French | 10h |
| 3 | Translate all keys to German | 10h |
| 4 | Translate email templates to French and German | 6h |
| 5 | Address German text overflow issues (longer strings) | 4h |
| 6 | Testing and QA | 6h |
| Phase 2 Total | ~40h |
13.3 Phase 3: Additional languages (2027)¶
~16h per language (translation + email templates + QA).
14. Library Evaluation¶
14.1 Frontend i18n libraries¶
| Library | Bundle Size | React 19 | Namespaces | Lazy Load | ICU Plurals | Verdict |
|---|---|---|---|---|---|---|
| react-i18next | 12 KB | Yes | Yes | Yes | Via plugin | Recommended — most popular, excellent docs, SSR-ready |
| react-intl (FormatJS) | 25 KB | Yes | No (flat) | Manual | Native ICU | Good alternative — heavier, stronger ICU support |
| LinguiJS | 5 KB | Yes | Yes | Yes | ICU | Lightweight — smaller community, less ecosystem support |
| Rosetta | 1 KB | Yes | No | No | No | Too minimal for this use case |
14.2 Backend i18n libraries¶
| Library | NestJS Integration | Locale Files | Middleware | Verdict |
|---|---|---|---|---|
| nestjs-i18n | Native module | JSON/YAML | Built-in | Recommended — purpose-built for NestJS |
| i18next (Node) | Manual setup | JSON | Manual | Good — share locale files with frontend |
| Custom Intl wrappers | Manual | None (Intl API) | Manual | Viable for formatting-only (no string translation) |
14.3 Date/time libraries¶
| Library | Size | Locale Support | Timezone | Verdict |
|---|---|---|---|---|
| Intl API (built-in) | 0 KB | Full | Yes (timeZone option) |
Recommended — zero dependency, sufficient for display |
| date-fns + date-fns-tz | 15 KB (tree-shaken) | Via locale imports | Yes | Good — already in use; useful for complex date math |
| Luxon | 70 KB | Full | Yes | Heavy — would add significant bundle size |
| Day.js | 7 KB | Via plugins | Via plugin | Adequate — lighter alternative to date-fns |
Recommendation: Use the Intl API for display formatting (zero bundle cost) and keep date-fns for date arithmetic where needed. Remove the custom English-only formatters in libs/utils/src/lib/date.ts.
15. Effort Estimation¶
| Area | Hours | Notes |
|---|---|---|
| Database schema + migration | 2 | Add fields to User and Tenant models |
| Backend preference service + API | 8 | CRUD, resolution logic, middleware |
| Frontend i18n setup | 10 | react-i18next config, context, hooks |
| String extraction (English) | 16 | ~980 strings across pages and components |
| Formatting hooks (date, number, measurement) | 8 | Replace all hardcoded formatters |
| Settings UI for locale preferences | 4 | Timezone picker, language selector, format options |
| Dutch translation | 12 | Professional translation or in-house |
| Email template localization | 8 | Template per locale, service changes |
| ESLint rule + CI check | 4 | Prevent regression |
| Testing and QA | 8 | Unit, integration, visual |
| Total (Phase 1) | ~80h | English + Dutch |
| French translation (Phase 2) | 16 | Translation + email templates + QA |
| German translation (Phase 2) | 16 | Translation + email templates + QA |
| Translation tooling setup (Phase 2) | 4 | Tolgee or Crowdin |
16. Risks & Mitigations¶
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| String extraction misses hardcoded text | Untranslated UI elements visible to users | Medium | ESLint rule no-literal-string + CI check + manual review pass |
| German text overflow | Buttons, table headers, labels break layout | High | Design with 30% text expansion buffer; test with pseudo-localization |
| Translation quality | Incorrect or awkward translations harm trust | Medium | Native speaker review; user feedback mechanism |
| Timezone edge cases | Dates display incorrectly around DST transitions | Low | Use Intl.DateTimeFormat with IANA timezone (handles DST automatically) |
| Performance impact | Loading multiple locale bundles slows initial load | Low | Namespace lazy-loading keeps initial bundle < 50 KB; only load active language |
| Measurement conversion errors | Incorrect dimensions shown to users | Medium | Centralized conversion constants with unit tests; always store metric internally |
| Stale translations after UI changes | Changed feature has outdated translations | Medium | CI completeness check; require translation updates in PRs that change UI text |
| RTL breaks layout | Future RTL language causes layout issues | Low | Use logical CSS properties from the start; periodic RTL testing |
17. Recommendations¶
17.1 Immediate actions¶
-
Adopt the two-tier preference model — Tenant defaults + user overrides. This gives businesses control over their company-wide settings while allowing individual users to customize their experience.
-
Start with
react-i18next— It is the most mature React i18n library, has excellent TypeScript support, and aligns with the SaaS readiness research recommendation. -
Use the
IntlAPI for formatting — Zero bundle cost, handles timezones and DST automatically, and supports all target locales natively in modern browsers and Node.js. -
Store everything in metric, display in user's preference — Never convert storage units. The display layer handles metric ↔ imperial conversion.
-
Add ESLint enforcement early — The
eslint-plugin-i18nextplugin can flag hardcoded strings in JSX, preventing regression after the initial extraction.
17.2 Architecture decisions to formalize¶
| Decision | Recommended ADR |
|---|---|
| i18n library choice (react-i18next) | ADR-0XX |
| Per-user locale preference model | ADR-0XX |
| Translation workflow and tooling | ADR-0XX |
| Measurement unit storage (metric-only internal) | ADR-0XX |
17.3 What NOT to do¶
- Do not build a custom i18n framework —
react-i18nextandnestjs-i18nare battle-tested and well-maintained. - Do not store formatted dates in the database — Always store UTC; format at display time.
- Do not translate in the API response body — Return locale-neutral data (ISO dates, numbers as numbers, dimensions in mm); let the frontend format.
- Do not mix locale files with code — Keep translation JSON files separate for translator access.
- Do not postpone the ESLint rule — Adding it after extracting strings prevents new hardcoded strings from creeping in.
18. References¶
| Resource | URL |
|---|---|
| BCP 47 Language Tags | https://www.ietf.org/rfc/bcp/bcp47.txt |
| IANA Time Zone Database | https://www.iana.org/time-zones |
| ICU MessageFormat | https://unicode-org.github.io/icu/userguide/format_parse/messages/ |
| react-i18next Documentation | https://react.i18next.com/ |
| nestjs-i18n Documentation | https://nestjs-i18n.com/ |
| Intl.DateTimeFormat (MDN) | https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat |
| Intl.NumberFormat (MDN) | https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat |
| Intl.RelativeTimeFormat (MDN) | https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat |
| eslint-plugin-i18next | https://github.com/edvardchen/eslint-plugin-i18next |
| Tolgee (OSS translation platform) | https://tolgee.io/ |
| Crowdin | https://crowdin.com/ |
| Pseudo-localization technique | https://www.w3.org/International/wiki/Pseudo_Locales |
| Tailwind CSS RTL support | https://tailwindcss.com/blog/tailwindcss-v3-3#rtl-and-ltr-modifiers |
| SaaS Launch Readiness §14 | saas-launch-readiness-research.md §14 |