JsonUI
← GuidesAdding a new languageOne strings.json feeds three different output shapes: a Swift StringManager struct, an Android strings.xml resource tree, and a reactive TypeScript StringManager singleton. There is no standalone `jui localize` command — it is all handled by `jui build`'s resource distribution step. This guide covers the real asymmetries between platforms and the five mechanical steps for adding a new locale.~12 min read
1. The bilingual shapestrings.json is a two-level JSON: the outer key is a screen namespace, the inner key is a snake_case string name, and the value is either a { en, ja, … } object or a plain string (used as fallback for every locale). The multi-language object form is what the generators look for — a plain string is a 'not yet translated' placeholder. A third form covers counted text: a plural entry, where each language holds { "plural": { "one": "{count} item", "other": "{count} items" } } with CLDR cardinal categories.
docs/screens/layouts/Resources/strings.json (excerpt)
{
"home": {
"hero_title": {
"en": "One spec. Native iOS, Android, and Web.",
"ja": "ひとつの仕様で、iOS・Android・Webを描く。"
},
"hero_cta_primary": {
"en": "Install in one line",
"ja": "ワンライナーで導入"
}
},
"cells_agent_row": {
"placeholder_response_empty": "Response will appear here."
}
}
2. How namespaces are namedNamespace names are derived automatically from the layout file path: slashes become underscores, hyphens become underscores. `home.json` → namespace `home`. `learn/hello-world.json` → `learn_hello_world`. `cells/agent_row.json` → `cells_agent_row`. Keys within a namespace are snake_case. `jui build` auto-extracts string literals from layout text / hint / placeholder / label / prompt properties and inserts them; you can also add keys by hand. `jui lint-strings` is the gate on the other side of that: it fails when a user-visible string sits in a layout as a raw literal instead of resolving through strings.json. Since jsonui-cli 1.6.15 it can also check that the declared set and the referenced set agree, via `--usage` on a run or `lint.stringsUsage: true` in `jui.config.json` — opt-in, so the plain command and `jui build` behave exactly as before. It reports three things: keys declared here that nothing references (wiring that was never finished, which every other gate passes), keys a caller asks for that are not declared (on web `getString` hands back the key name, so the raw key reaches the screen; Android's compiler already refuses a missing R.string symbol, so that face is not reported. iOS splits: StringManager accessors fail to compile too, but hand-written `NSLocalizedString(...)` / `String(localized:)` never do — Foundation returns the key string itself — so since jsonui-cli 1.6.24 those two forms are checked against strings.json and the platform string catalogs (*.strings / *.xcstrings / *.stringsdict, actually read), and only a reference found in neither is reported, with file:line. The `"key".localized()` form is counted as usage but never reported missing: the SwiftUI generator emits that spelling for sentinel values too — a visibility's "gone", for instance — so an unresolved one is usually not a key reference at all, and reporting it would bury the real findings), and dynamic key selection that does not go through a declared map. Three conventions keep the referenced set computable: build dynamic key choices from a constant map named `*_STRING_KEYS`, so the possible values sit in the closure (prefer the object form — its values are checked against the declared set too; in an array of pairs the scanner cannot tell which element is the key, so those literals count as used but are never reported missing); pass a literal — or a choice among literals, `cond ? "a" : "b"` is fine — as the first argument of `str()` / `tpl()` / `getString()` / the qualified `StringManager.plural(key, count)`, while something like `MAP[x] ?? "lit"` counts as dynamic — only the first argument is judged, so parameter objects or a count in later arguments never make a call dynamic; and park keys you are deliberately holding for later in a `LINT_KEEP_STRING_KEYS` map. The check stays deliberately unclever — it does not guess at prefixes, because a dead key hiding under a guessed exclusion would never surface again (an unqualified `plural(` is ignored for the same reason: it could be anyone's helper). (If you adopt it, start at v1.6.17: 1.6.15's scanner also read commented-out code, and until 1.6.17 trailing arguments made a literal-choice `tpl()` read as dynamic and keys referenced only through `plural()` read as unused.) Since 1.7.23 there is one place the literal rule stops being lenient, and a definition is what licenses it: when the same file defines `function str` or `function tpl` and the body is statically readable — ``getString(`prefix_${key}`)`` or `getString(key)` — the literals passed to it are checked as the composed key exactly, and a mismatch is reported as missing under that composed name. Measured on a fixture whose wrapper composes `home_` with its argument: `str('farewel')` is reported as composing `home_farewel`, which is not declared, while `str('greeting')` resolves; the same fixture is clean on 1.7.22. Moving that wrapper into another file and importing it is the only edit needed to turn the strictness back off — imported wrappers, unreadable bodies and contradictory definitions keep the broad matching, because the definition is what makes the composition knowable, and a guess would be worse than the old leniency. Accessors that are methods rather than free functions are untouched: this site's own are, and its `--usage` numbers are identical on 1.7.20 and 1.7.23.
namespace derivation
layout file → namespace
─────────────────────────────────────────────────
home.json → home
learn/hello-world.json → learn_hello_world
cells/agent_row.json → cells_agent_row
guides/localization.json → guides_localization
3. What each platform emitsBuild time produces three different files from the same source — this is the most frequently misunderstood part of JsonUI localisation. Do not assume Android has a StringManager.kt; it does not.• iOS: ResourceManager/StringManager.swift (nested struct, snake_case → camelCase functions) + Localizable.strings per locale. Access: StringManager.Home.heroTitle().• Android: res/values/strings.xml + res/values-ja/strings.xml (no StringManager.kt — kjui intentionally disables it, the native R.string API is used instead). Access: context.getString(R.string.home_hero_title).• Web: src/generated/StringManager.ts (singleton with a camelCase proxy over the language tables). Access: StringManager.currentLanguage.homeHeroTitle.
StringManager.ts (excerpt)
// Web — src/generated/StringManager.ts (@generated)
const strings = {
en: { home_hero_title: "One spec. Native iOS, Android, and Web." },
ja: { home_hero_title: "ひとつの仕様で、iOS・Android・Webを描く。" },
};
// usage
$s.homeHeroTitle;
strings.xml (excerpt)
<!-- Android — res/values-ja/strings.xml (@generated) -->
<resources>
<string name="home_hero_title">ひとつの仕様で、iOS・Android・Webを描く。</string>
</resources>
4. Runtime language switchingReactive language switching is Web-only. `StringManager.setLanguage(locale)` mutates the singleton and React re-renders consuming components — the generated string hook subscribes through useSyncExternalStore, so this is React behavior, not a Next.js feature. On iOS and Android, StringManager has no setLanguage API — the device locale is the source of truth, picked up via Settings > General > Language on iOS and Settings > System > Languages on Android. In-app toggles on mobile require hand-written code (Locale.preferredLanguages on iOS, AppCompatDelegate.setApplicationLocales() on Android).
Web language toggle (hand-written)
"use client";
import { StringManager } from "@/generated/StringManager";
import { useRouter } from "next/navigation";
 
export function LanguageToggle() {
const router = useRouter();
return (
<button onClick={() => {
const next = StringManager.language === "ja" ? "en" : "ja";
StringManager.setLanguage(next);
router.refresh();
}}>
{StringManager.language === "ja" ? "EN" : "JA"}
</button>
);
}
5. Adding a new locale (five steps)All mechanical, no schema updates. Pick a BCP-47 code (fr, de, zh-Hans, ko, …) and:① Add the locale to each object in strings.json. Plain-string keys fall through to their existing value at runtime, so you only add the third key where translation actually exists.
① strings.json
{
"home": {
"hero_title": {
"en": "One spec.",
"ja": "ひとつの仕様で。",
"ko": "하나의 사양."
}
}
}
② Add the code to languages[] in the platform configs (jui.config.json / sjui.config.json / kjui.config.json / rjui.config.json) so the generators know to produce the locale's output files.
② jui.config.json
{ "languages": ["en", "ja", "ko"], "default_language": "en" }
③ Run jui build. iOS gets a new ko.lproj/Localizable.strings, Android gets res/values-ko/strings.xml, Web's StringManager.ts gets a ko branch in its strings object.
③ shell
jui build
# → ja.lproj/ and ko.lproj/ appear for iOS
# → res/values-ja/ and res/values-ko/ for Android
# → StringManager.ts gets a `ko` branch
④ iOS only: open Xcode → Project > Info > Localizations and add the language there once. Android and Web pick up the new files automatically, no IDE action needed.⑤ Web only: expose a language toggle that calls StringManager.setLanguage('ko'). On iOS and Android this would happen via OS-level language change; the app will refresh on next launch.
6. Limits and asymmetriesA few things JsonUI localisation does not do today — know them up front so you don't spend time looking for them.• Plurals: supported via CLDR-cardinal plural entries ({ "plural": { "one": …, "other": … } }, `{count}` placeholder). Each platform compiles to its native engine — .stringsdict (iOS), R.plurals (Android), Intl.PluralRules (web). Plural keys are VM-only: resolve them in the ViewModel (StringManager accessor with count / getQuantityString / StringManager.plural) and bind the result; referencing one from a layout attribute is a build error. A special count=0 wording stays a ViewModel branch, not a plural category. The old separate-keys idiom (item_singular / items_plural) still works and needs no migration.• Interpolation: iOS uses %@, Android uses %s — the build auto-converts between them, and positional specifiers (%1$@ / %1$s) are supported. Web strings stay literal for these specifiers (no printf engine); the only substitution the web runtime performs is `{count}` inside plural entries, resolved via Intl.PluralRules.• Pseudo-localisation: no tool for auto-wrapping keys with brackets to surface missing translations. Reviewers are expected to catch missing locales in code review.• Images: `alt` text IS localizable — a registered strings.json key in `alt` resolves through StringManager just like `text` / `hint` (decorative images: `"alt": ""`). Image sources are NOT: `src` / `url` literals never go through the string table — registering an image name as a string key is the classic way to break a picture — and a bare-name `src` triggers a build warning telling you to use `srcName` instead.
Keep reading
Writing your first specStrings are part of the spec contract./guides/writing-your-first-spec
Data binding as contractHow text bindings flow through StringManager./concepts/data-binding