# How to Implement Multi-Language Support in Retool

- Tool: Retool
- Difficulty: Intermediate
- Time required: 30-40 min
- Compatibility: Retool Cloud and Self-hosted
- Last updated: March 2026

## TL;DR

Retool has no built-in i18n framework, so implement multi-language support using a JavaScript object of translation strings, a language selector dropdown, and Temporary State to track the current language. Use {{ translations[currentLang.value]['button.save'] ?? translations.en['button.save'] }} to reference translated text with English fallback. Store translations in a Transformer for easy editing.

## Building a Translation Layer in Retool Without a Framework

Retool does not have a built-in i18n (internationalization) framework — there is no gettext(), no react-i18next, and no built-in locale-switching. To support multiple languages, you need to implement a translation layer manually.

The recommended pattern: store all translatable UI strings in a JavaScript object (organized by language code and string key), read the user's selected language from a Temporary State variable, and reference translations using {{ translations[currentLang.value]['key'] }} in component text properties.

This is distinct from Retool's built-in Component Localization feature (which only translates component-level UI strings like calendar month names in 7 languages) — this tutorial covers translating your application's actual business content.

## Before you start

- A Retool app with static UI text (labels, button text, headings, error messages) that needs translation
- List of target languages with their BCP 47 codes (e.g., 'en', 'es', 'fr', 'de')
- Basic understanding of JavaScript objects and Retool Temporary State

## Step-by-step guide

### 1. Create the translation Transformer

Add a new Transformer in the Code panel named 'translations'. This Transformer contains the full translation dictionary as a JavaScript object keyed by language code, then by string key. Place all translatable UI strings here. The Transformer must return the object. Because it has no dependencies, it evaluates once and the value is available throughout the app as {{ translations.value }}.

```
// Transformer: translations
// Returns the full translation dictionary
// Add new languages by adding a new top-level key

return {
  en: {
    'nav.dashboard': 'Dashboard',
    'nav.orders': 'Orders',
    'nav.customers': 'Customers',
    'button.save': 'Save Changes',
    'button.cancel': 'Cancel',
    'button.delete': 'Delete',
    'table.noData': 'No records found',
    'form.required': 'This field is required',
    'status.active': 'Active',
    'status.pending': 'Pending',
    'status.cancelled': 'Cancelled',
  },
  es: {
    'nav.dashboard': 'Panel',
    'nav.orders': 'Pedidos',
    'nav.customers': 'Clientes',
    'button.save': 'Guardar cambios',
    'button.cancel': 'Cancelar',
    'button.delete': 'Eliminar',
    'table.noData': 'No se encontraron registros',
    'form.required': 'Este campo es obligatorio',
    'status.active': 'Activo',
    'status.pending': 'Pendiente',
    'status.cancelled': 'Cancelado',
  },
  fr: {
    'nav.dashboard': 'Tableau de bord',
    'nav.orders': 'Commandes',
    'nav.customers': 'Clients',
    'button.save': 'Enregistrer',
    'button.cancel': 'Annuler',
    'button.delete': 'Supprimer',
    'table.noData': 'Aucun enregistrement trouvé',
    'form.required': 'Ce champ est obligatoire',
    'status.active': 'Actif',
    'status.pending': 'En attente',
    'status.cancelled': 'Annulé',
  },
};
```

**Expected result:** Transformer 'translations' returns a multi-language dictionary object available as {{ translations.value }}.

### 2. Create a Temporary State for the current language

Add a Temporary State variable named 'currentLang' with type String and default value 'en'. This variable stores the user's selected language code. All translation references in the app will read from this variable. You can also initialize it from the browser locale on page load using a JS Query: await currentLang.setValue(navigator.language.split('-')[0]) — this sets the language to the user's browser language if a translation exists.

```
// JS Query: initLanguage (run on page load)
// Set initial language from browser locale if supported

const browserLang = navigator.language.split('-')[0]; // 'en', 'es', 'fr'
const supportedLangs = Object.keys(translations.value);

if (supportedLangs.includes(browserLang)) {
  await currentLang.setValue(browserLang);
} else {
  await currentLang.setValue('en'); // Default to English
}
// Note: setValue is async — do not read currentLang.value immediately
```

**Expected result:** currentLang is initialized to the user's browser language or 'en' as default.

### 3. Add a language selector dropdown

Add a Select component named 'languageSelector' to your app's header or navigation area. Set its options to a static array of language options: [{ label: '🇺🇸 English', value: 'en' }, { label: '🇪🇸 Español', value: 'es' }, { label: '🇫🇷 Français', value: 'fr' }]. Add an event handler on 'On change' → JS Query that runs: await currentLang.setValue(languageSelector.value). Now when the user switches the dropdown, all translation references throughout the app update instantly.

```
// languageSelector options (static list):
[
  { label: '🇺🇸 English', value: 'en' },
  { label: '🇪🇸 Español', value: 'es' },
  { label: '🇫🇷 Français', value: 'fr' }
]

// On change event handler (JS Query):
await currentLang.setValue(languageSelector.value);

// languageSelector Default value:
{{ currentLang.value }}
```

**Expected result:** Language dropdown updates currentLang when changed. All translated text in the app updates immediately.

### 4. Reference translations in component text properties

For each static UI string in your app, replace the hard-coded text with a translation reference. Use the pattern: {{ translations.value[currentLang.value]['key'] ?? translations.value.en['key'] }}. The ?? operator provides an English fallback if the key doesn't exist in the selected language. Apply this to button Text properties, heading Text components, table empty state messages, and form labels.

```
// Button Text property:
{{ translations.value[currentLang.value]['button.save'] ?? translations.value.en['button.save'] }}

// Table 'No data' message:
{{ translations.value[currentLang.value]['table.noData'] ?? translations.value.en['table.noData'] }}

// Form validation error:
{{ translations.value[currentLang.value]['form.required'] ?? 'Required' }}

// Create a helper shorthand via a Transformer (optional):
// Transformer: t
// return (key) => translations.value[currentLang.value]?.[key]
//              ?? translations.value.en?.[key]
//              ?? key;
// Usage: {{ t.value('button.save') }}
```

**Expected result:** UI labels display in the selected language. Missing translations fall back to English.

### 5. Handle database-stored translatable content

For content stored in the database (product descriptions, category names, error messages stored in a messages table), translation works differently. Two approaches: (1) Store translations in the database in a separate table (e.g., products_i18n with language_code, field_name, translated_text columns) and JOIN in your query based on {{ currentLang.value }}. (2) Store all translations in JSON columns: product.name_json = {"en": "Widget", "es": "Componente"}. In transformers, access as {{ row.name_json[currentLang.value] ?? row.name_json.en }}.

```
-- Approach 1: Separate translations table JOIN
SELECT
  p.id,
  COALESCE(pt.name, p.name_en) AS name,
  COALESCE(pt.description, p.description_en) AS description,
  p.price
FROM products p
LEFT JOIN product_translations pt
  ON pt.product_id = p.id
  AND pt.language_code = {{ currentLang.value }}
WHERE p.is_active = true;
```

**Expected result:** Database-stored content displays in the selected language, falling back to English when translation is missing.

## Complete code example

File: `Transformer: t (translation helper)`

```javascript
// Transformer: t (translation helper)
// Provides a convenient translate function
// Usage: {{ t.value('button.save') }}

const lang = currentLang.value || 'en';
const dict = translations.value || {};
const current = dict[lang] || {};
const fallback = dict.en || {};

// Return a function that looks up a key with fallback
return (key) => current[key] ?? fallback[key] ?? key;

// The last ?? key means: if key not found in any language,
// display the key itself (helps identify missing translations during dev)
```

## Common mistakes

- **Using translations.value[currentLang.value] without the English fallback, leaving empty text when a key is missing** — undefined Fix: Always add ?? translations.value.en['key'] or use the helper Transformer that provides automatic fallback. Missing translation keys show empty strings in production, which looks broken.
- **Updating currentLang.setValue() and immediately reading currentLang.value expecting the new language** — undefined Fix: setValue() is async. The Temporary State value does not update synchronously. Since translation references use {{ currentLang.value }} reactively, they will update automatically on the next render cycle.
- **Confusing Retool's built-in Component Localization (7-language component UI strings) with full app translation** — undefined Fix: Component Localization only translates component-level strings (date picker month names, etc.). For translating your app's actual content (button labels, headings, error messages), you need the manual translation layer described in this tutorial.

## Best practices

- Use dot-notation keys organized by feature (nav.dashboard, button.save, form.required) for easy maintenance
- Always provide an English fallback using the ?? operator to prevent empty UI on missing translations
- Keep the translations Transformer as the single source of truth — never hard-code strings in component properties
- For database content, use a LEFT JOIN with a translations table rather than storing JSON blobs per column
- Add a development tool: a Transformer that lists all keys used in the app vs keys in translations, to find gaps
- Test each language by switching the selector — don't rely on review of the translation dictionary alone

## Frequently asked questions

### Does Retool support right-to-left (RTL) languages like Arabic or Hebrew?

Retool's layout system does not have built-in RTL support. You can apply CSS to flip the layout direction using Custom CSS in the app settings: add dir='rtl' to the body element via Preloaded CSS or target specific containers. This is a workaround, not native support, and may not work perfectly for all components.

### Can translation strings be stored in a database instead of a Transformer?

Yes — you can store all translations in a translations database table (keys: language_code, string_key, value) and load them via a SQL query into a Transformer that converts the result rows into the nested object format. This allows non-developers to edit translations via a Retool app, but adds a database round-trip on page load.

### Will language selection persist when the user refreshes the Retool page?

Temporary State variables are reset on page refresh. To persist language selection across sessions, store the selected language in localStorage via a JS Query on change: window.localStorage.setItem('retool_lang', languageSelector.value). Read it back in the initLanguage JS Query that runs on page load: const saved = window.localStorage.getItem('retool_lang').

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-implement-multi-language-support-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-implement-multi-language-support-in-retool
