Translations & Localization
Sell the platform to clinics worldwide — each clinic operates in its own language.
Business Model
RestartiX is a white-label platform sold to clinics internationally. Each clinic (organization) operates in a single language chosen at setup. The platform's internal language is English (code, API field names, database columns), but all user-facing content is in the clinic's language.
This is not a multilingual-clinic model (where one clinic supports multiple languages). It's a multi-market model: a Romanian clinic runs entirely in Romanian, a Spanish clinic runs entirely in Spanish.
What this means in practice
- Org-created content (services, forms, custom fields, automations, PDF templates) — created directly in the clinic's language. No translation layer needed.
- Global/platform-curated content (exercises, exercise taxonomy, treatment plan templates) — maintained in English as canonical, with translations provided by the platform team per supported language.
- Frontend UI (buttons, labels, status names, error messages) — handled by frontend i18n libraries (
next-intl), independent of the database. Can be built in parallel later.
Database Architecture
Organization language
Every organization has a language_code that determines the language for all platform-curated content served to that org.
ALTER TABLE organizations ADD COLUMN language_code TEXT NOT NULL DEFAULT 'en';
-- ISO 639-1: 'ro', 'en', 'es', 'fr', 'de', 'pt', etc.JSONB translations on global content
Global/platform-curated tables get a translations JSONB column. The canonical columns (name, description, etc.) remain in English. Translations are stored as a language-keyed object.
ALTER TABLE exercises ADD COLUMN translations JSONB NOT NULL DEFAULT '{}';Example data:
-- exercises row (global, organization_id IS NULL)
{
"id": 1,
"name": "Shoulder External Rotation", // canonical (English)
"description": "Strengthens the rotator cuff...",
"video_url": "https://cdn.example.com/shoulder-rotation-en.mp4",
"translations": {
"ro": {
"name": "Rotație Externă Umăr",
"description": "Întărește musculatura coifului rotatorilor...",
"video_url": "https://cdn.example.com/shoulder-rotation-ro.mp4"
},
"es": {
"name": "Rotación Externa de Hombro",
"description": "Fortalece el manguito rotador...",
"video_url": "https://cdn.example.com/shoulder-rotation-es.mp4"
}
}
}Query pattern — resolve translation at read time using the org's language, fallback to canonical:
SELECT
e.id,
COALESCE(e.translations->$1->>'name', e.name) AS name,
COALESCE(e.translations->$1->>'description', e.description) AS description,
COALESCE(e.translations->$1->>'video_url', e.video_url) AS video_url,
e.difficulty,
e.estimated_duration_seconds
FROM exercises e
WHERE e.organization_id IS NULL -- global exercises
ORDER BY e.name;
-- $1 = org.language_code (e.g., 'ro')Any field not present in the translation object falls back to the canonical English column. This includes non-text fields like video_url — a localized video with spoken instructions in the clinic's language.
Tables requiring translations JSONB
Only global/platform-curated content needs translations. Everything org-scoped is created directly in the org's language.
| Table | Translatable fields | Typical volume |
|---|---|---|
exercises | name, description, video_url, video_thumbnail_url | ~10,000 |
exercise_instructions | title, content, image_url | ~3–5 per exercise |
exercise_contraindications | condition_name, description | ~1–3 per exercise |
exercise_categories | name | ~20–50 |
exercise_body_regions | name | ~20–30 |
exercise_equipment | name | ~15–25 |
treatment_plans (scope = 'global') | name, description | ~100–500 |
treatment_plan_sessions (for global plans) | name, description | ~3–10 per plan |
What does NOT need translations in the database
| Content | Reason |
|---|---|
| Services | Org-scoped — created in org's language |
| Specialties | Org-scoped — created in org's language |
| Custom fields (labels, options) | Org-scoped — created in org's language |
| Form templates | Org-scoped — created in org's language |
| Automations | Org-scoped — created in org's language |
| PDF templates | Org-scoped — created in org's language |
| Appointments | Runtime data — inherits from service/specialist names |
| Patient profiles | Portable profile fields (name, DOB) are language-neutral |
Status enums (booked, completed) | Code values — display labels handled by frontend i18n |
API Behavior
The Core API resolves translations transparently. When an org queries global content, the API uses the org's language_code to extract the right translation from the JSONB column.
GET /v1/exercises
Authorization: Bearer <token>
X-Organization-Id: 42 (language_code = 'ro')
Response:
{
"data": [
{
"id": 1,
"name": "Rotație Externă Umăr",
"description": "Întărește musculatura...",
"video_url": "https://cdn.example.com/shoulder-rotation-ro.mp4",
"difficulty": "moderate",
"is_global": true
},
{
"id": 502,
"name": "Exercițiu Custom Clinică",
"description": "...",
"is_global": false
}
]
}- Global exercises (
is_global: true) — name/description resolved fromtranslationsJSONB - Org exercises (
is_global: false) — returned as-is, already in org's language - No
Accept-Languageheader needed — the org's language is the source of truth
Cloning behavior
When a clinic clones a global exercise to customize it, the clone is created with the translated text baked into the canonical columns:
INSERT INTO exercises (organization_id, name, description, video_url, cloned_from_id, ...)
SELECT
$org_id,
COALESCE(e.translations->$lang->>'name', e.name),
COALESCE(e.translations->$lang->>'description', e.description),
COALESCE(e.translations->$lang->>'video_url', e.video_url),
e.id,
...
FROM exercises e WHERE e.id = $exercise_id;The clone is now org-scoped, in the org's language, fully independent. No ongoing translation dependency.
Translation Management
Who translates
- Platform team — provides translations for all supported languages when entering a new market
- Clinics — can override by cloning and editing (but don't manage translations directly)
Platform admin workflow
- Platform team creates/updates global exercises in English (canonical columns)
- Platform team adds translations to the
translationsJSONB for each supported language - New clinics immediately see content in their language
- Existing clinics see updates to global content automatically (unless they cloned and customized)
Adding a new language
- Add language to supported languages list
- Translate all global content (exercises, taxonomy, treatment plan templates)
- Update
translationsJSONB on affected rows - New orgs can now be created with that
language_code
Checking translation coverage
-- Find global exercises missing Romanian translations
SELECT id, name
FROM exercises
WHERE organization_id IS NULL
AND (translations->'ro'->>'name') IS NULL;Frontend Localization (Future)
Frontend i18n is independent of the database translation layer and can be built in parallel. This section will be expanded when frontend localization work begins.
Planned approach
- Library:
next-intlfor both Clinic app and Patient Portal - Routing:
app/[locale]/...segment-based routing - Message files: JSON per language (
messages/en.json,messages/ro.json) - Scope: UI chrome (buttons, labels, navigation, status display names, error messages, date/time/number formatting)
- Locale source: Derived from
organization.language_code— not user-selectable
What frontend i18n covers
- Static UI strings (button text, menu items, page titles, empty states)
- Status enum display names (
booked→ "Programat" in Romanian) - Date/time formatting (24h vs 12h, DD/MM/YYYY vs MM/DD/YYYY)
- Number/currency formatting (1.234,56 vs 1,234.56)
- Relative time ("3 days ago" → "acum 3 zile")
- Error messages from API (mapped to localized strings on the client)
What frontend i18n does NOT cover
- Dynamic content (exercise names, service names, form labels) — these come from the API already in the right language
- Patient-entered data (names, notes, form responses) — language-neutral