Summary
Devlane's UI is English-only and every label is hardcoded in JSX. There is already a Language dropdown in Settings under Account, Preferences, in the "Language & Time" block (apps/web/src/pages/SettingsPage.tsx around line 1394), but it is a dead control: it writes to a local language state (useState('en') at around line 444) and nothing ever reads it. This issue is about setting up real internationalization (i18n) so the whole interface can be translated, the existing dropdown actually switches languages, and adding a new language later is close to "translate one file."
There is no i18n library in the project yet (apps/web/package.json has none).
Definition of done: by the time this is finished, no user-facing screen in the web app should contain a hardcoded string anymore. Every label, button, placeholder, empty state, toast, menu item, and modal reads its text through t(), and two complete languages are in place and switchable from Settings: English (en) as the source and Azerbaijani (az) as a full translation with no missing keys.
Scope
All of this ships as a single PR: the i18n setup, the full migration of hardcoded strings across the web app, the working language switcher, and both complete locale files (en and az). The work can be organized internally into small commits to stay reviewable, but it lands as one PR that leaves the app fully translatable with no English strings left hardcoded.
Approach
Use react-i18next on top of i18next. It is the standard for React and fits how Devlane already handles client preferences. The setup should mirror apps/web/src/contexts/ThemeContext.tsx: read the saved choice from localStorage, apply it, and persist on change. Theme uses the key devlane-theme, so language can follow the same idea.
What needs to happen
1. Add the dependencies
In apps/web:
npm i i18next react-i18next i18next-browser-languagedetector
npm i -D i18next-parser
The language detector picks the starting language from localStorage first, then the browser. i18next-parser is a dev tool we use to find and collect translatable strings (more on that below).
2. Create the i18n setup
Add apps/web/src/i18n/index.ts that initializes i18next once:
- register
initReactI18next and the language detector
fallbackLng: 'en'
- detection order
['localStorage', 'navigator'] and cache to localStorage (use a key like devlane-language to stay consistent with devlane-theme)
interpolation.escapeValue: false because React already escapes
- load the resource files (see next step)
Import this file once at the entry point, apps/web/src/main.tsx, before <App /> renders, so the language is ready on first paint. No provider is strictly required, but if we want it explicit we can wrap App with I18nextProvider next to the existing ThemeProvider in apps/web/src/App.tsx.
3. Set up the locale files
Create a folder per language with namespaced JSON:
apps/web/src/i18n/locales/
en/translation.json
az/translation.json
English is the source of truth. Keys should be grouped by area so they stay findable, for example:
{
"settings.preferences.language.title": "Language",
"settings.preferences.language.help": "Choose the language used in the user interface.",
"workItem.new": "New work item"
}
Keep a short convention note in the repo (a README in the i18n folder is enough) so everyone names keys the same way.
4. Move hardcoded strings onto t()
This is the bulk of the work, not the setup. In components:
const { t } = useTranslation();
<h1>{t('settings.title')}</h1>
Use interpolation for values (t('workItem.count', { count }), which also gives us plural rules for free) and the <Trans> component for text that contains links or bold.
To avoid hunting for strings by hand, run i18next-parser across the web app. It scans the code for t(...) and <Trans> usage and writes the missing keys into the locale files, so the workflow is: wrap a string in t(), run the parser, fill in the English value. Add a script to apps/web/package.json, for example:
"i18n:extract": "i18next-parser --config i18next-parser.config.cjs"
with a config that points at src/**/*.{ts,tsx} and outputs to src/i18n/locales/$LOCALE/$NAMESPACE.json. Run it regularly while migrating so the English file stays complete. Organize this into small commits page by page (start with the shared components in apps/web/src/components and the settings pages), but keep it all in the one PR so the app is never left half-translated on main.
5. Wire the existing language switcher
The dropdown in Settings, Preferences already exists, it just needs to do something. On change it should call i18n.changeLanguage(lng). The detector's localStorage cache persists the choice automatically, so a reload keeps the language. Remove the dead language local state once the dropdown is driven by i18next.
Optionally, and to match how timezone already works, we can also store the choice on the user so it follows them across devices. The user record already carries user_timezone (apps/web/src/api/types.ts, UserApiResponse), so we would add a language field the same way and save it through userService.updateMe. If we do that, localStorage is the fast local default and the user record is the source of truth after login. This backend piece is optional and can be a follow-up, the localStorage-only version is enough to close this issue.
6. Add the Azerbaijani translation
Azerbaijani (az) is the second required language and the proof that the setup actually works end to end: copy en/translation.json to az/translation.json, translate the values, register az, and confirm the switcher flips the entire UI. After this, adding language number three is just one more JSON file plus one more entry in the switcher list, with no component changes.
7. The parts that are easy to forget
- Dates and numbers: format them with
Intl.DateTimeFormat and Intl.NumberFormat keyed to the active language. Devlane already uses Intl for timezones, so follow that.
- Right to left: if we ever add a language like Arabic, set
dir="rtl" on the html element when that language is active. Not needed for English or Azerbaijani, but the switcher is the right place to handle it later.
- Bundle size: lazy load locale files so we do not ship every translation in the main bundle. i18next supports loading a language on demand.
- Server messages: API error text is still English. If we want those translated too, that is a separate, larger effort (the client can map known error codes to keys). Out of scope here, worth a follow-up issue.
8. Keep it from regressing
Once the screens are converted, add a lint or CI check that flags new hardcoded user-facing strings so we do not slowly drift back to English-only. eslint-plugin-i18next or a simple custom rule works.
Acceptance criteria
react-i18next is set up and initialized once at app start
- No hardcoded user-facing strings remain anywhere under
apps/web/src. Every screen, shared component, modal, menu, empty state, and toast reads its text through t()
- Both locale files are complete and stay in sync:
en (source of truth) and az (full translation), with no missing or untranslated keys in either
- The Language dropdown in Settings, Preferences actually changes the language and the choice survives a reload
- Adding a new language needs only a new JSON file and a new switcher entry, with no component edits
npm run i18n:extract is documented and produces the locale files
- Dates and numbers render using the active locale
- Running
i18next-parser reports no untracked hardcoded strings left in the codebase
Notes
Everything above is delivered in one PR. Organize the string migration into small, reviewable commits inside that PR (start with apps/web/src/components and the settings pages, since those set the key-naming pattern everyone else copies), so reviewers can follow it without the app ever landing half-translated on main.
Summary
Devlane's UI is English-only and every label is hardcoded in JSX. There is already a Language dropdown in Settings under Account, Preferences, in the "Language & Time" block (
apps/web/src/pages/SettingsPage.tsxaround line 1394), but it is a dead control: it writes to a locallanguagestate (useState('en')at around line 444) and nothing ever reads it. This issue is about setting up real internationalization (i18n) so the whole interface can be translated, the existing dropdown actually switches languages, and adding a new language later is close to "translate one file."There is no i18n library in the project yet (
apps/web/package.jsonhas none).Definition of done: by the time this is finished, no user-facing screen in the web app should contain a hardcoded string anymore. Every label, button, placeholder, empty state, toast, menu item, and modal reads its text through
t(), and two complete languages are in place and switchable from Settings: English (en) as the source and Azerbaijani (az) as a full translation with no missing keys.Scope
All of this ships as a single PR: the i18n setup, the full migration of hardcoded strings across the web app, the working language switcher, and both complete locale files (
enandaz). The work can be organized internally into small commits to stay reviewable, but it lands as one PR that leaves the app fully translatable with no English strings left hardcoded.Approach
Use
react-i18nexton top ofi18next. It is the standard for React and fits how Devlane already handles client preferences. The setup should mirrorapps/web/src/contexts/ThemeContext.tsx: read the saved choice fromlocalStorage, apply it, and persist on change. Theme uses the keydevlane-theme, so language can follow the same idea.What needs to happen
1. Add the dependencies
In
apps/web:The language detector picks the starting language from
localStoragefirst, then the browser.i18next-parseris a dev tool we use to find and collect translatable strings (more on that below).2. Create the i18n setup
Add
apps/web/src/i18n/index.tsthat initializes i18next once:initReactI18nextand the language detectorfallbackLng: 'en'['localStorage', 'navigator']and cache tolocalStorage(use a key likedevlane-languageto stay consistent withdevlane-theme)interpolation.escapeValue: falsebecause React already escapesImport this file once at the entry point,
apps/web/src/main.tsx, before<App />renders, so the language is ready on first paint. No provider is strictly required, but if we want it explicit we can wrapAppwithI18nextProvidernext to the existingThemeProviderinapps/web/src/App.tsx.3. Set up the locale files
Create a folder per language with namespaced JSON:
English is the source of truth. Keys should be grouped by area so they stay findable, for example:
{ "settings.preferences.language.title": "Language", "settings.preferences.language.help": "Choose the language used in the user interface.", "workItem.new": "New work item" }Keep a short convention note in the repo (a
READMEin thei18nfolder is enough) so everyone names keys the same way.4. Move hardcoded strings onto
t()This is the bulk of the work, not the setup. In components:
Use interpolation for values (
t('workItem.count', { count }), which also gives us plural rules for free) and the<Trans>component for text that contains links or bold.To avoid hunting for strings by hand, run
i18next-parseracross the web app. It scans the code fort(...)and<Trans>usage and writes the missing keys into the locale files, so the workflow is: wrap a string int(), run the parser, fill in the English value. Add a script toapps/web/package.json, for example:with a config that points at
src/**/*.{ts,tsx}and outputs tosrc/i18n/locales/$LOCALE/$NAMESPACE.json. Run it regularly while migrating so the English file stays complete. Organize this into small commits page by page (start with the shared components inapps/web/src/componentsand the settings pages), but keep it all in the one PR so the app is never left half-translated on main.5. Wire the existing language switcher
The dropdown in Settings, Preferences already exists, it just needs to do something. On change it should call
i18n.changeLanguage(lng). The detector'slocalStoragecache persists the choice automatically, so a reload keeps the language. Remove the deadlanguagelocal state once the dropdown is driven by i18next.Optionally, and to match how timezone already works, we can also store the choice on the user so it follows them across devices. The user record already carries
user_timezone(apps/web/src/api/types.ts,UserApiResponse), so we would add alanguagefield the same way and save it throughuserService.updateMe. If we do that,localStorageis the fast local default and the user record is the source of truth after login. This backend piece is optional and can be a follow-up, the localStorage-only version is enough to close this issue.6. Add the Azerbaijani translation
Azerbaijani (
az) is the second required language and the proof that the setup actually works end to end: copyen/translation.jsontoaz/translation.json, translate the values, registeraz, and confirm the switcher flips the entire UI. After this, adding language number three is just one more JSON file plus one more entry in the switcher list, with no component changes.7. The parts that are easy to forget
Intl.DateTimeFormatandIntl.NumberFormatkeyed to the active language. Devlane already usesIntlfor timezones, so follow that.dir="rtl"on thehtmlelement when that language is active. Not needed for English or Azerbaijani, but the switcher is the right place to handle it later.8. Keep it from regressing
Once the screens are converted, add a lint or CI check that flags new hardcoded user-facing strings so we do not slowly drift back to English-only.
eslint-plugin-i18nextor a simple custom rule works.Acceptance criteria
react-i18nextis set up and initialized once at app startapps/web/src. Every screen, shared component, modal, menu, empty state, and toast reads its text throught()en(source of truth) andaz(full translation), with no missing or untranslated keys in eithernpm run i18n:extractis documented and produces the locale filesi18next-parserreports no untracked hardcoded strings left in the codebaseNotes
Everything above is delivered in one PR. Organize the string migration into small, reviewable commits inside that PR (start with
apps/web/src/componentsand the settings pages, since those set the key-naming pattern everyone else copies), so reviewers can follow it without the app ever landing half-translated on main.