---
name: experience-ui-bundle-localize
description: "MUST activate to localize / internationalize a uiBundles/*/src/ React project: extract hardcoded user-facing strings into Custom Labels, wire i18next over the Platform SDK GraphQL backend, add a language to a localized bundle, or troubleshoot label rendering across locales. Triggers: user-facing string literals in .tsx/.jsx, a CustomLabels.labels-meta.xml, a src/i18n/ directory or label-manifest.ts, t(…) calls, or requests to 'translate / localize / internationalize / support another language.' Scope: authenticated UI Bundles only (B2E CustomApplication-bound, or in-core accessCheck-bound apps). DO NOT TRIGGER when: the bundle is a site (B2C/B2B) app bound to a DigitalExperienceConfig (site localization not yet supported), building app shell/UI or styling, reading/writing/refreshing records (use experience-ui-bundle-salesforce-data-access), generating a new bundle (use experience-ui-bundle-frontend-generate), deploying (use experience-ui-bundle-deploy), or authoring translations in Translation Workbench."
metadata:
version: "1.0"
minApiVersion: "68.0"
relatedSkills:
- "experience-ui-bundle-deploy"
- "experience-ui-bundle-frontend-generate"
- "experience-ui-bundle-salesforce-data-access"
cliTools:
- tool: ["jq"]
semver: ">=1.6"
- tool: ["npm"]
semver: ">=7.0.0"
- tool: ["sf"]
semver: ">=2.0.0"
---
# Localize a React UI Bundle
Walk a developer through localizing a React UI Bundle: detect hardcoded user-facing strings, extract them into Salesforce Custom Labels, wire up i18next over the Platform SDK GraphQL backend, and verify labels render across locales.
This file is the **workflow + guardrail spine**. Depth lives in linked docs:
- **[references/i18n-setup.md](references/i18n-setup.md)**: the two files you write: the i18next init and the label manifest
- **[references/label-xml.md](references/label-xml.md)**: Custom Labels and translation metadata XML shapes; the `namespace:Key` rules
- **[references/interpolation.md](references/interpolation.md)**: positional `{0}/{1}` placeholder interpolation in labels
- **[references/verifying.md](references/verifying.md)**: serve URL, locale flip, and verifying labels render
- **[references/gotchas.md](references/gotchas.md)**: the three silent-fail traps: unregistered manifest keys, API-version bake-in, stale label cache
## The one-paragraph mental model
A React UI Bundle can't use `@salesforce/label/*` the way LWC does, those imports resolve at compile time inside the platform's compiler, which your standalone React bundle doesn't go through. Instead, your app **fetches labels at runtime** through the Salesforce GraphQL UI API and hands them to **i18next** (a standard React i18n library) to render. The Platform SDK provides the runtime plumbing for this, a detector that reads the user's language, a backend that fetches labels over GraphQL, and a context fetch. You write two thin files: a short init that wires the SDK pieces into i18next, and a manifest listing which labels your app uses. The rest is authoring the labels themselves as Salesforce Custom Labels metadata.
```typescript
import { useTranslation } from "react-i18next";
function WelcomeBanner() {
const { t } = useTranslation("c"); // "c" = custom label namespace
return
{t("Welcome_Text")}
; // renders "Welcome" or "Bienvenido" per user's language
}
```
---
## Step 0: Route the task
| The task is… | Go to |
|---|---|
| Bundle doesn't exist yet | **experience-ui-bundle-frontend-generate** skill |
| Deploying the app with its labels | **experience-ui-bundle-deploy** skill |
| Localizing an existing bundle | **Workflow below** |
---
## Preconditions: verify before editing
| # | Requirement | Verify | If missing |
|---|---|---|---|
| 1 | It's a `uiBundles/*/src/` React project | Project structure matches | Not a UI Bundle → route to the correct skill |
| 2 | `@salesforce/platform-sdk` installed (≥11.42.1) | `package.json` in the UI bundle dir | Tell user to install it; cannot proceed |
| 3 | You can identify where the app mounts | Read the entry file (usually `src/index.tsx`) | No clear mount point → ask user to point it out |
| 4 | Target org actually supports API v68.0+ (runtime label GraphQL for UI Bundles ships in Release 264) | Run the runtime org-release check below | Org's max API version is below v68.0 (Release 262 or older) → cannot proceed; retarget a Release 264+ org or upgrade the org |
| 5 | The bundle is an authenticated app (B2E, or an in-core internal app), not a public site | Run the authenticated-app detection below | Bundle is a site (B2C/B2B) app → localization is **not yet supported for site bundles**; stop and tell the user B2C support is planned for when B2C localization is ready |
**Runtime org-release check (precondition 4).** The `platform.labels` GraphQL path that resolves labels at runtime for UI Bundles ships in Salesforce Release 264 (API v68.0 or higher). A `sourceApiVersion` in `sfdx-project.json` records what you declared, not what the org supports, so a newer CLI pointed at an older org can pass a static file check and then fail at runtime. Query the org's actual maximum API version before wiring anything:
```bash
bash /scripts/check-org-api-version.sh
```
Exit `0` → the org supports v68.0+, proceed. Exit `1` → the org is too old or unreachable; do not write i18n wiring or labels, report the version mismatch to the user and stop. (`sf api request rest` inside the script keeps authentication at the CLI transport layer, so no access token enters context.)
**Authenticated-app detection (precondition 5).** The bundle's type decides whether localization is supported, and it's decided by deterministic file and string checks. Pass the full path to the bundle dir; the script derives the metadata root from it, so the current directory does not matter:
```bash
bash /scripts/detect-bundle-type.sh / dir>
```
Act on the exit code: `0` → authenticated app (in-core internal or B2E), proceed; `1` → site (B2C/B2B), localization is **not yet supported for site bundles**, stop and tell the user (B2C support is planned for when B2C localization is ready); `2` → unbound or cannot auto-detect, **ask the user to confirm** the bundle is an authenticated app (B2E or in-core internal) and stop if they cannot.
If a precondition isn't met, stop: report the specific block to the user and record a plan item to return once it's resolved. Do not edit the bundle, in particular, never add i18n wiring or TODO markers to a site (B2C/B2B) bundle that precondition 5 gated off as unsupported.
---
## Workflow: the five steps
Each step has a **checkable completion criterion** and a **confirm-before-continue** pause.
### Step 1: Detect
**Goal:** Scan `.tsx`/`.jsx` files for user-facing hardcoded strings.
**What to scan:**
- String literals inside JSX tags: `
Welcome
` → candidate
- String props shown to users: `placeholder="Enter name"` → candidate
- User-facing accessible text: `aria-label`, `aria-describedby`, `alt` → candidate (a screen-reader user hears these, so they must localize too)
**What to skip:**
- Import statements
- Object keys / property names
- `data-*` attributes (machine-readable)
- Test IDs (`data-testid`, `id` attributes)
- Text already wrapped in `t()` calls
- Console logs, error messages thrown to developers (not user-facing)
- Class names, file paths, technical constants
**Action:**
1. Scan the `src/` directory for `.tsx` and `.jsx` files
2. Extract candidates, showing file path + line number for each
3. Show the list to the developer
**Completion criterion:**
Developer confirms the list (or edits it to remove false positives).
**Pause:** "I found N user-facing strings across M components. Here's the list: [show file:line + string]. Look right? [confirm / edit the list / skip some]"
---
### Step 2: Extract
**Goal:** For each confirmed string, add a Custom Label and replace the JSX literal with a `t()` call.
**Action for each string:**
1. **Propose a key name**, format: `_` (e.g., `"Welcome"` → `Welcome_Text`, `"Save"` → `Save_Button`, `"Failed to save"` → `Save_Failed_Message`). Follow naming: PascalCase words, underscores between parts, descriptive enough to be unique.
2. **Add the label** to `force-app/main/default/labels/CustomLabels.labels-meta.xml`:
```xml
Welcome_Texten_USfalseWelcome banner headingWelcome
```
(Full XML structure: [references/label-xml.md](references/label-xml.md))
3. **Replace the string** in the component with `{t("Key")}`:
```tsx
// Before:
Welcome
// After:
{t("Welcome_Text")}
```
4. **Add the import** if not present: `import { useTranslation } from "react-i18next";` and `const { t } = useTranslation("c");` at the top of the component function.
**Completion criterion:**
Every confirmed string has both a `CustomLabels` entry and a `t()` call in its original location.
**Pause:** "For each string I'll add a Custom Label and replace the JSX with t(). Here are the proposed keys: [show string → namespace:Key mapping]. Apply these edits? [y / review each]"
---
### Step 3: Register
**Goal:** Add each key to the label manifest so i18next knows to fetch it.
**Action:**
1. Add each key to the manifest array in `src/i18n/label-manifest.ts`:
```typescript
export const labelManifest = [
"c:Welcome_Text",
"c:Save_Button",
"c:Save_Failed_Message",
];
```
If the file doesn't exist yet, Step 4 scaffolds it; the completion check below reports its absence, so don't test for the file by hand.
**Completion criterion:**
Run `check-manifest-registered.sh` from the UI bundle dir (it scans `src/` relative to the current directory) and report any errors it returns. It owns the deterministic inspection: it cross-checks every `t("Key")` call site against the manifest and treats a missing `label-manifest.ts` (when `t()` calls exist) as a failure. A key that's called but not registered renders as its own literal name at runtime with no error, the silent-fail trap this guards.
```bash
cd / dir> # scripts scan src/ relative to here
bash /scripts/check-manifest-registered.sh
```
Branch on the exit code: `0`, every key is registered (or there are no `t()` calls to gate), proceed. `1`, the manifest is missing or the listed keys aren't in it; scaffold or add them (Step 4 scaffolds the file) and re-run. `64`, usage error, the source dir doesn't exist (wrong cwd or bad argument); this is **not** a "keys missing" result, do not scaffold or register, fix the path and re-run.
**Pause:** "Added N entries to label-manifest.ts. check-manifest-registered.sh passed: [confirm]."
---
### Step 4: Wire
**Goal:** Ensure the i18next init exists; scaffold it if the app has no i18n yet.
**Check:**
Run `check-i18n-wired.sh` from the UI bundle dir (it scans `src/` relative to the current directory) and report what it returns. The script owns the whole deterministic inspection: it looks for an init file defining `initI18n()` and a boot-time call to it, and when those exist it also reports whether the label manifest is imported and actually passed into the backend config. Do not re-derive any of this by reading files yourself.
```bash
cd / dir> # scripts scan src/ relative to here
bash /scripts/check-i18n-wired.sh
```
Branch on the **exit code** (the printed message names the specific file/symbol for your report, but the decision is the code):
- Exit `0` → fully wired, the manifest is passed into the backend; go to "If i18n already exists" below and just add new keys.
- Exit `1` → no `initI18n()` exists; scaffold the whole setup via "If no i18n setup exists yet".
- Exit `2` → the init already exists but isn't called at boot; do **not** re-scaffold or overwrite it. Add only the boot-time `initI18n()` call in the entry file (step 4 of "If no i18n setup exists yet"), then re-run.
- Exit `3` → wired at boot but the script **could not confirm** the manifest is passed into the backend. It scans the whole `src` tree, but this last check is a textual heuristic: the manifest may be wired through a variable, spread, or helper the script can't see, so treat exit 3 as "verify before editing," not "definitely broken." Open the file the message names and confirm the manifest really isn't in `backendOptions`. Only if it genuinely dangles, do what the message names: if the manifest is imported but unused, pass it into the existing `backendOptions` without clobbering it; if there's no `backendOptions`/`SalesforceBackend` config at all, add that backend block to the existing init (see [references/i18n-setup.md](references/i18n-setup.md)). Never re-scaffold the init file or duplicate wiring that already works.
- Exit `64` → usage error: the source dir doesn't exist (wrong cwd or bad argument). This is **not** a "no init" result; do not scaffold. Fix the path (run from the UI bundle dir, or pass its `src` path) and re-run.
**If no i18n setup exists yet:**
1. Install dependencies (tell the user to run):
```bash
npm install i18next react-i18next i18next-chained-backend i18next-localstorage-backend
```
2. Create `src/i18n/index.ts` with the init wiring (full code: [references/i18n-setup.md](references/i18n-setup.md))
3. Create `src/i18n/label-manifest.ts` with an empty array (Step 3 will populate it)
4. Call `initI18n()` once at boot in the entry file (before mounting the app):
```typescript
import { initI18n } from "./i18n";
initI18n().then(() => {
// mount app
});
```
**If i18n already exists:**
Act on the message `check-i18n-wired.sh` already printed (above): if it reports the manifest wired, just add new keys to it; if it reports a reconcile is needed, do exactly what its message names (import the manifest and/or pass it into the backend config) without clobbering existing wiring.
**Completion criterion:**
`initI18n()` exists and is called once at boot; the manifest is wired into the backend.
**Pause:** "i18next setup [exists / created]. initI18n() is called at boot: [confirm]."
---
### Step 5: Verify
**Goal:** Guide the developer to verify labels render in a second language.
**Action:**
1. **Activate a second language** (if not already active), tell the user: "In your org, go to Setup → Translation Workbench → Translation Settings → add a language (e.g., Spanish)."
2. **Author a translation**, scaffold an empty translation file for the language:
```xml
Welcome_Text
```
(Full structure: [references/label-xml.md](references/label-xml.md))
Tell the user to either:
- Edit the XML file by hand (for a small number of labels), or
- Use Translation Workbench (Setup → Translate → Custom Label → pick language → enter translations), then retrieve with `sf project retrieve start --metadata Translations:es`.
3. **Build and deploy**, tell the user:
```bash
sf config set target-org= # API version bakes in; point at the deploy target first
npm run build
sf project deploy start --source-dir force-app --target-org
```
4. **Open the app** at the `/lwr/application/ai/-` URL on the `lightning.force.com` domain (redirects to the app host).
5. **Change the user's Language** (not Locale), Setup → My Settings → Language & Time Zone → Language → pick the translated language → Save.
6. **Reload the app**, labels should flip to the translated language.
**If it doesn't render:**
Check the three gotchas in [references/gotchas.md](references/gotchas.md):
- Unregistered manifest key (Step 3 missed a label)
- API-version mismatch (built against a different org)
- Stale localStorage cache (clear `i18next_res_*` keys in DevTools)
**Completion criterion:**
Labels render in ≥2 locales, or the blocking gotcha is identified.
**Pause:** "To verify: activate a second language in Translation Workbench, author a translation (I can scaffold the XML), build/deploy, and reload. Want me to scaffold the translation file for [language]? [y / I'll do it manually]"
---
## Edge cases: handle gracefully
- **Already-localized code**: detect existing `t()` usage / a populated manifest; offer to *add to* the setup rather than re-scaffold everything.
- **No strings found**: report cleanly and stop; do not invent work.
- **App has no i18n setup yet**: Step 4 scaffolds the two files first before Step 3 can register anything.
- **Partial setup** (manifest exists but init missing, or vice-versa), reconcile what's present; never clobber existing wiring.
---
## Guardrails: never regress these
1. **Never machine-translate into deployable metadata.** Scaffold empty translation files and guide the developer to author translations (by hand or via Translation Workbench). Do not call any MT API and paste the result into `translation-meta.xml`; unreviewed machine translations are a quality liability.
2. **Never register a key that has no label.** Manifest entry count must equal label count (Step 3 criterion). An unregistered key renders as its own literal name with no console warning. It's the most common localization bug.
3. **Never clobber existing i18n wiring.** If Step 4 finds an existing `initI18n()`, reconcile (add the manifest import if missing) rather than replace the whole file.
4. **Every file must be customer-safe.** No `webapps`, core-only paths, or internal infrastructure references anywhere. Write as if for an external customer in an SFDX project.
---
## Commands & layout
```text
/ ← SFDX project root
└── force-app/main/default/
├── labels/CustomLabels.labels-meta.xml ← English base labels
├── translations/.translation-meta.xml ← one per translated language
└── uiBundles//
├── package.json
└── src/
├── i18n/
│ ├── index.ts ← init wiring (you write this once)
│ └── label-manifest.ts ← list of labels to fetch (you maintain this)
└── components/ ← components call t()
```
| Command | Run from | Purpose |
|---|---|---|
| `npm install i18next react-i18next i18next-chained-backend i18next-localstorage-backend` | UI bundle dir | Install i18n dependencies (Step 4) |
| `npm run build` | UI bundle dir | Build the app (API version bakes in, set target-org first) |
| `sf project deploy start --source-dir force-app` | Project root | Deploy the app + labels + translations |
| `sf project retrieve start --metadata Translations:` | Project root | Pull translations authored in Translation Workbench |
---
## Pre-flight checklist: completion criteria for the whole run
- [ ] Every confirmed string has both a `CustomLabels` entry and a `t()` call
- [ ] `label-manifest.ts` entry count == label count (no unregistered keys)
- [ ] `initI18n()` present and called once at boot
- [ ] Labels render in ≥2 locales (or the blocking gotcha is named)
- [ ] No hand-written machine translations landed in `*-meta.xml` (only scaffold-and-guide)