Skip to content

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

  1. Executive Summary
  2. Current State Audit
  3. Requirements
  4. Data Model — Per-User Preferences
  5. Frontend i18n Architecture
  6. Backend i18n Architecture
  7. Locale Dimensions
  8. Translation Workflow
  9. Email & Notification Localization
  10. API Localization
  11. Accessibility & RTL Considerations
  12. Testing Strategy
  13. Migration Plan
  14. Library Evaluation
  15. Effort Estimation
  16. Risks & Mitigations
  17. Recommendations
  18. 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-US or en-BE locales. 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-i18next for the frontend and nestjs-i18n or custom Intl-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)

uml diagram

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

uml diagram

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

Email 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-start instead of margin-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

  1. 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.

  2. Start with react-i18next — It is the most mature React i18n library, has excellent TypeScript support, and aligns with the SaaS readiness research recommendation.

  3. Use the Intl API for formatting — Zero bundle cost, handles timezones and DST automatically, and supports all target locales natively in modern browsers and Node.js.

  4. Store everything in metric, display in user's preference — Never convert storage units. The display layer handles metric ↔ imperial conversion.

  5. Add ESLint enforcement early — The eslint-plugin-i18next plugin 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 frameworkreact-i18next and nestjs-i18n are 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