localize.dev

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 install

Want 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 accessibilityLabel to attributes so 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-key it 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. Uses GITHUB_TOKEN when --token is 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:

  • i18next and react-i18next in package.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 in index.html, else src/main.* or src/index.*.
  • resolveJsonModule in tsconfig.json, so the locale files can be imported.
  • src/language-switcher.tsx, exporting <LanguageSwitcher /> and useLanguage() with this app's defaults, and localize-dev as 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.

Header.tsx
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:

  1. the --api-key flag
  2. the LOCALIZE_API_KEY environment variable (use this in CI)
  3. 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.

FieldDefaultDescription
srcDir"src"Directory to scan.
include / ignore["**/*.{tsx,jsx}"] / tests, stories, .d.tsGlobs 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.
attributesplaceholder, alt, title, aria-label, label, tooltipJSX 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. 1

    Copy your API key

    It starts with ldv_live_ and is shown only once. Lost it? Create a new one on the API keys page.
  2. 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. 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. 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:

.github/workflows/localize.yml
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}.json

Other 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

PlanStrings / monthLanguages per run
Free5003
Pro20,00010
Team150,00050

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.