Documentation
Localize.dev turns hardcoded copy in JSX and TSX codebases into t() calls, writes the locale dictionaries, and can open the pull request for you. Translation runs on the hosted Localize.dev model, so there's nothing to set up beyond an API key.
Quickstart
You need Node 18+ and an app written in JSX or TSX: React, Next.js, React Native, Electron and similar. It doesn't have to use i18next yet; the first run sets it up. Create a free API key in the dashboard, then:
npm install --save-dev localize-dev
# Save your API key on this machine
npx localize-dev login
# See what would change, without writing anything
npx localize-dev run --dry-run -l es,fr
# Rewrite components and write locales/*.json
npx localize-dev run -l es,fr
# Only if the run added i18next to package.json
npm installWant to look before signing up? npx localize-dev scan lists every string it would extract, and --offline generates keys without translating. Neither needs a key or uses your allowance. A later online run fills in the missing translations without touching existing keys.
Supported codebases
The scanner reads .tsx and .jsx files, so it works wherever your UI is written in JSX and rendered through a component that can call useTranslation():
- Web: React, Next.js (App Router server and client components, and the Pages Router), Remix, Vite, Gatsby and React islands in Astro
- Mobile: React Native and Expo. Add
accessibilityLabeltoattributesso screen-reader labels are translated too. - Desktop: Electron and Tauri apps with a React renderer
- Component libraries: design systems and shared UI packages
Dictionaries are written for i18next (react-i18next, next-i18next) with i18next or ICU placeholders. Vue and Svelte single-file components are on the roadmap.
CLI commands
- localize-dev init
- Writes a localize.config.json with every default spelled out.
- localize-dev login [--api-key ldv_live_…]
- Saves your API key for this machine. Without
--api-keyit prompts, or reads the key from stdin. - localize-dev whoami
- Shows the workspace, plan and usage for the current key.
- localize-dev logout
- Removes the saved key from this machine.
- localize-dev scan [-d src] [-o extracted_strings.json]
- Lists every string that would be extracted, plus the ones skipped and why. Changes nothing; no key needed.
- localize-dev run [--dry-run] [--offline] [-l es,fr] [-d src] [--api-key …]
- The full pass: extract, name keys, translate, rewrite files, update dictionaries, and set up i18next if the app has none.
- localize-dev pr [--base main] [--repo owner/name] [--branch name] [--token …]
- Runs the pass and opens (or updates) a pull request on
localize-dev/<base>instead of writing locally. UsesGITHUB_TOKENwhen--tokenis omitted.
i18n setup
Rewritten components call useTranslation(), so the app needs i18next running. When it isn't, the run (and the Action's pull request) adds what's missing:
i18nextandreact-i18nextinpackage.json. The Action also updates your lockfile (npm, pnpm, Yarn or Bun); after a local run, run your install.src/i18n.ts(or.js), which loads every locale file and picks the language from a saved choice, then the browser, then your source locale.- An
import './i18n'in your entry file: the script inindex.html, elsesrc/main.*orsrc/index.*. resolveJsonModuleintsconfig.json, so the locale files can be imported.src/language-switcher.tsx, exporting<LanguageSwitcher />anduseLanguage()with this app's defaults, andlocalize-devas a dependency. Render the switcher where people pick a language, such as your header; it isn't placed for you. Options.
If any file already initializes i18next, none of this is added and your setup is used as is. The switcher saves the choice in localStorage and restores it on the next visit; to switch from your own code, call i18n.changeLanguage('es') and store lang the same way. Projects set up by an earlier version get the switcher on their next run. In the Next.js Pages Router the import goes in pages/_app.
Next.js App Router
Files under app/ without 'use client' are server components, which can't use hooks. They get const { t } = await getTranslation() instead (the component becomes async if it wasn't), from a generated i18n.server module that picks the language from a lang cookie, then the browser's Accept-Language. Client components keep useTranslation(). The root layout is wrapped in a generated <I18nProvider> that hands client components the same language, and <html lang> follows it. The generated switcher sets the lang cookie and refreshes, so server components re-render in the new language.
Language switcher
<LanguageSwitcher /> is a menu of the languages your app bundles (your source language and the target languages you configured), each with its flag and its own name. It works with the keyboard (arrows, Home/End, Enter, Escape, typing a name) and screen readers, remembers the choice, and sets <html lang> and dir for right-to-left languages.
import { LanguageSwitcher, useLanguage } from './language-switcher';
<LanguageSwitcher /> // flag + native name
<LanguageSwitcher compact /> // just the flag
<LanguageSwitcher nameStyle="native-english" /> // Español · Spanish
<LanguageSwitcher languages={['en', 'es']} /> // choose and order the list
<LanguageSwitcher regions={{ en: 'GB', pt: 'BR' }} /> // which country's flag
<LanguageSwitcher renderFlag={(l) => <img src={`/flags/${l.code}.svg`} alt="" />} />
<LanguageSwitcher unstyled className="…" buttonClassName="…" menuClassName="…" optionClassName="…" />
// Anywhere else: the current language and the list
const { language, info, languages, setLanguage, isRTL } = useLanguage();
info.flag // "🇪🇸"
info.nativeName // "Español"Other props: showFlag, align (start/end), side (top/bottom), label, disabled, renderButton, renderOption, persist (localStorage, cookie or none), storageKey and onChange. Built-in styles read CSS variables, so a theme or dark mode is a few lines: --ldv-bg, --ldv-fg, --ldv-border, --ldv-hover, --ldv-muted, --ldv-accent, --ldv-radius, --ldv-shadow. Selected and highlighted options carry data-selected and data-active for your own CSS. Outside React, getLanguageInfo('pt-BR') returns the same names, flag and direction.
In the Next.js App Router the generated file saves the choice in the lang cookie and refreshes, so server components follow. Emoji flags show as two letters on Windows; pass renderFlag for images there.
API keys
Keys look like ldv_live_… and belong to a workspace; every key in a workspace shares its monthly allowance. The CLI looks for a key in this order:
- the
--api-keyflag - the
LOCALIZE_API_KEYenvironment variable (use this in CI) - the key saved by
localize-dev login, in~/.config/localize-dev/credentials.json(readable only by you)
Only a hash of each key is stored on our side, so a secret is shown once, when you create it. Revoke a key from the dashboard and it stops working immediately.
Configuration
Everything is optional. Put overrides in localize.config.json at the project root; CLI flags win over the file.
| Field | Default | Description |
|---|---|---|
| srcDir | "src" | Directory to scan. |
| include / ignore | ["**/*.{tsx,jsx}"] / tests, stories, .d.ts | Globs relative to srcDir. |
| sourceLocale | "en" | Language of the hardcoded copy. |
| targetLocales | ["es", "fr"] | Languages to translate into. |
| localesPath | "locales/{locale}.json" | Dictionary location, e.g. public/locales/{locale}/common.json. |
| attributes | placeholder, alt, title, aria-label, label, tooltip | JSX attributes treated as UI copy. Add accessibilityLabel for React Native. |
| importSource | "react-i18next" | Where useTranslation is imported from, e.g. next-i18next. |
| interpolation | "i18next" | i18next writes {{name}}; icu writes {name}. |
GitHub Action
The Action runs the same pass in CI and opens a pull request against the branch that triggered it. Re-runs update the same branch and PR. Before the first run, store your API key in the repository:
- 1
Copy your API key
It starts withldv_live_and is shown only once. Lost it? Create a new one on the API keys page. - 2
Open the repository’s Actions secrets
On GitHub, open your repository and go to Settings → Secrets and variables → Actions, then click New repository secret. You need admin access to the repository to see Settings. - 3
Add the secret
Name:LOCALIZEDEV_API_KEYSecret: paste your key, with no spaces or line breaks. Then click Add secret. GitHub hides the value from then on; to change it, update the secret. - 4
Allow the Action to open pull requests
In Settings → Actions → General, under Workflow permissions, tick Allow GitHub Actions to create and approve pull requests and click Save. Without it the run translates everything but fails when opening the pull request. If the option is greyed out, an organization owner has to allow it first under the organization’s Settings → Actions → General.
Then commit the workflow:
name: Localize
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
workflow_dispatch: # "Run workflow" button in the Actions tab
permissions:
contents: write # push the localize-dev/<branch> branch
pull-requests: write # open or update the localization PR
# One run per branch at a time; a newer push supersedes the running one.
concurrency:
group: localize-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
localize:
runs-on: ubuntu-latest
timeout-minutes: 60
# Skip Localize.dev's own branches, and fork PRs (their token is read-only).
if: >-
!startsWith(github.head_ref || github.ref_name, 'localize-dev/') &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# On PRs, scan the branch itself rather than the merge commit.
ref: ${{ github.head_ref || github.ref_name }}
- name: Localize
uses: ufortstephen/autolocalize-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
api_key: ${{ secrets.LOCALIZEDEV_API_KEY }}
target_locales: 'es,fr,de'
src_dir: 'src'
# locales_path: 'public/locales/{locale}/common.json' # default: locales/{locale}.jsonOther inputs: src_dir, locales_path, base_branch, api_url, offline (no key needed). Outputs: pr_url, strings_count.
Each run reports back when it finishes: the project's page in the dashboard lists recent runs with their pull request, strings replaced, translations per language, and the error if one failed.
Glossary
Each project has a glossary on its dashboard page: terms that must never be translated (brand and product names), preferred translations for your vocabulary, and a style note such as "informal; use du in German". It applies to every request from the project's GitHub Action and CLI runs. Translations that change a never-translate term are rejected; preferred translations and the style note guide the model.
Plans & limits
| Plan | Strings / month | Languages per run |
|---|---|---|
| Free | 500 | 3 |
| Pro | 20,000 | 10 |
| Team | 150,000 | 50 |
A string counts once however many languages it is translated into. Allowances run in 30-day periods: from the day your workspace was created, or from your billing date on a paid plan. The dashboard shows when yours resets. If a run would go over the allowance or the language limit, it stops before writing anything, explains why and exits with code 2, so CI fails instead of committing half a translation.
Plurals & rich text
A quantity ternary such as {n} {n === 1 ? 'file' : 'files'} left becomes one message, rewritten to t('files.left', { count: n }). Each language gets exactly the forms it needs (Spanish three, Polish four, Arabic six), written as files.left_one, files.left_few… for i18next or one ICU plural message, and i18next picks the right one at runtime.
Text around simple inline elements, like Read the <a href="/docs">docs</a> first, becomes one message (Read the <a>docs</a> first) rendered with <Trans>, so translators can move the link. The element keeps its props. Translations that lose or rename a tag are rejected.
What gets translated
- JSX text, with text and simple expressions merged into one message:
Hi {user.name}!→Hi {name}! - Attributes in the allow-list (placeholder, alt, title, aria-label, label, tooltip; configurable)
- String and template literals rendered in JSX, including both branches of
a ? 'X' : 'Y'and the right side of&&
Never translated: URLs and paths, colors, UUIDs, CSS units, emails, file names, snake_case or kebab-case identifiers, symbol-only text, and anything inside <code>, <pre>, <Trans>, translate="no" or data-i18n-ignore.
Every translation is checked before it is written: one that adds, drops or renames a {placeholder} is rejected. Existing translations in your dictionaries are never overwritten.
Privacy & data
- Only the extracted strings are sent to the API, with their file path and component as context. Whole files are never sent.
- Scans and offline runs never leave your machine.
- The CLI rewrites files locally; the GitHub Action commits from your own runner with your own token.
- API keys are stored as hashes; the secret is shown only once.
Limitations
- Rich text needs simple inline elements: text inside an element that itself contains elements, or that has its own translatable attributes, is still extracted in pieces.
- Plurals are recognised from
n === 1 ? … : …ternaries with two string branches. - Class components are skipped and reported; they can't call hooks.
- Vue and Svelte templates, and sync with translation management systems, are on the roadmap.