Skip to content

Reference

Export Formats

The langctl CLI reads and writes 5 file formats: flat JSON, nested JSON, Android strings.xml, iOS Localizable.strings and Flutter ARB. This reference covers each format, its default path, and how keys and placeholders are converted.

Supported Formats

Format idAliasesPlatformDefault pathPlaceholders
json (default)flat-json, i18n-json, i18nextWeb (i18next, vue-i18n, ngx-translate), React Native, Node.jslocales/{lang}.json{{param}}
nested-jsonjson-nestedWeb frameworks that expect nested objectslocales/{lang}.json{{param}}
androidandroid-xml, xmlAndroid (Kotlin, Java)res/{android}/strings.xml%1$s
iosios-strings, stringsiOS / macOS (Swift, Objective-C){lang}.lproj/Localizable.strings%1$@
arbflutter, flutter-arbFlutterlib/l10n/app_{lang_}.arb{param}

Pick the format once with langctl init (it’s stored in langctl.json), or pass it to a command with -f/--format. To list the formats from the terminal:

terminal
langctl formats

Tip

langctl stores placeholders as {{name}} and converts them for each format. Write {{username}} once, and it becomes %1$s for Android, %1$@ for iOS, and {username} for Flutter.

React Native apps use the json format — there is no separate React Native format.

Path Templates

The output path (output in langctl.json, or -o) is a template with one file per language:

VariableExample (pt-BR)Typical use
{lang}pt-BRJSON files, iOS .lproj folders
{lang_}pt_BRFlutter ARB files
{android}values-pt-rBR (values for the project’s default language)Android resource folders

Paths are relative to langctl.json. Writing several languages to one fixed file name (no variable) is an error rather than silently overwriting it.

Flat JSON (json)

The default format, and the one most web libraries load directly.

Format Specification

  • File extension: .json
  • Encoding: UTF-8
  • Structure: Flat object, keys as written in langctl ("home.title")
  • Parameters: {{param}}, unchanged
  • Plurals: i18next-style _one / _other suffixes are ordinary keys (notifications.message_one)

Example Output

locales/en.json
{
"auth.login": "Log In",
"auth.signup": "Sign Up",
"home.subtitle": "Get started with your projects",
"home.title": "Dashboard",
"home.welcome": "Welcome, {{username}}!",
"notifications.message_one": "You have {{count}} new message",
"notifications.message_other": "You have {{count}} new messages"
}

Usage

tsx
import { useTranslation } from 'react-i18next';

function WelcomeMessage() {
const { t } = useTranslation();
return (
  <div>
    <h1>{t('home.welcome', { username: 'John' })}</h1>
    <p>{t('home.subtitle')}</p>
  </div>
);
}
html
<div>
<h1>{{ 'home.welcome' | translate: {username: 'John'} }}</h1>
<p>{{ 'home.subtitle' | translate }}</p>
</div>

Pull

terminal
langctl pull -f json -o "src/locales/{lang}.json"

Nested JSON (nested-json)

Hierarchical JSON: each dot in a key becomes a level of nesting. Use it with libraries that resolve dotted keys as paths, such as vue-i18n or svelte-i18n.

  • File extension: .json
  • Encoding: UTF-8
  • Structure: Nested objects split on .
  • Parameters: {{param}}, unchanged
locales/en.json
{
"auth": {
  "login": "Log In",
  "signup": "Sign Up"
},
"home": {
  "subtitle": "Get started with your projects",
  "title": "Dashboard",
  "welcome": "Welcome, {{username}}!"
}
}
vue
<template>
<div>
  <h1>{{ $t('home.welcome', { username: 'John' }) }}</h1>
  <p>{{ $t('home.subtitle') }}</p>
</div>
</template>
terminal
langctl pull -f nested-json -o "src/locales/{lang}.json"

Warning

A key can’t be both a string and a parent in nested JSON (for example home and home.title). The pull fails and lists the conflicting keys instead of silently dropping one; rename a key or use the flat json format.

Android XML (android)

Native Android string resources for Kotlin and Java applications.

  • File extension: .xml
  • Encoding: UTF-8
  • Structure: <string> elements in a <resources> root; key descriptions are written as XML comments
  • Key names: converted to valid resource names (home.welcome → home_welcome)
  • Parameters: positional %1$s, %2$s, …
app/src/main/res/values/strings.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="auth_login">Log In</string>
  <string name="home_title">Dashboard</string>
  <!-- Welcome message on home screen -->
  <string name="home_welcome">Welcome, %1$s!</string>
  <string name="profile_info">%1$s has %2$s followers</string>
  <string name="upload_progress">%1$s%% done</string>
</resources>

Key Names

Any character that isn’t a letter, digit or underscore becomes _, and names starting with a digit get a leading _:

  • home.welcome → home_welcome
  • auth.login.title → auth_login_title

If two keys map to the same name (for example home.title and home_title), the pull fails and names both keys.

kotlin
// Simple string
val title = getString(R.string.home_title)

// With parameter
val welcome = getString(R.string.home_welcome, username)

// With multiple parameters
val info = getString(R.string.profile_info, name, followerCount.toString())
java
// Simple string
String title = getString(R.string.home_title);

// With parameter
String welcome = getString(R.string.home_welcome, username);

With the default res/{android}/strings.xml path, each language goes to its own resource folder:

output
app/src/main/res/
├── values/             # Default language (e.g. English)
│   └── strings.xml
├── values-es/          # Spanish
│   └── strings.xml
├── values-pt-rBR/      # Portuguese (Brazil)
│   └── strings.xml
└── values-de/          # German
  └── strings.xml
terminal
langctl pull -f android -o "app/src/main/res/{android}/strings.xml"

Escaping

langctl escapes text the way Android expects:

CharacterWritten asExample
' (apostrophe)\'It\'s working
" (quote)\"He said \"Hi\"
<&lt;x &lt; 5
>&gt;x &gt; 3
&&amp;Tom &amp; Jerry
newline\nMulti-line text
% (in strings with placeholders)%%%1$s%% done

Note

langctl handles escaping when it writes and unescapes when it reads (push/import). You don’t need to escape characters in the dashboard or CLI.

iOS Strings (ios)

Native iOS and macOS localization format for Swift and Objective-C.

  • File extension: .strings
  • Encoding: UTF-8
  • Structure: "key" = "value"; pairs; key descriptions are written as /* comments */
  • Key names: unchanged ("home.welcome")
  • Parameters: positional %1$@, %2$@, …
en.lproj/Localizable.strings
"auth.login" = "Log In";
"home.title" = "Dashboard";
/* Welcome message on home screen */
"home.welcome" = "Welcome, %1$@!";
"profile.info" = "%1$@ has %2$@ followers";
swift
// Simple string
let title = NSLocalizedString("home.title", comment: "")

// With parameter
let welcome = String(format: NSLocalizedString("home.welcome", comment: ""), username)

// With multiple parameters
let info = String(format: NSLocalizedString("profile.info", comment: ""), name, String(followerCount))
objc
// Simple string
NSString *title = NSLocalizedString(@"home.title", @"");

// With parameter
NSString *welcome = [NSString stringWithFormat:
  NSLocalizedString(@"home.welcome", @""), username];
output
MyApp/
├── en.lproj/
│   └── Localizable.strings
├── es.lproj/
│   └── Localizable.strings
├── pt-BR.lproj/
│   └── Localizable.strings
└── de.lproj/
  └── Localizable.strings
terminal
langctl pull -f ios -o "MyApp/{lang}.lproj/Localizable.strings"

Flutter ARB (arb)

Application Resource Bundle format for Flutter and Dart applications.

  • File extension: .arb
  • Encoding: UTF-8
  • Structure: JSON with @@locale and @key metadata (description and placeholders)
  • Key names: converted to Dart identifiers (home.welcome → homeWelcome)
  • Parameters: {param}, declared in placeholders
  • No timestamps: there’s no @@last_modified, so pulls only change files when translations change
lib/l10n/app_en.arb
{
"@@locale": "en",
"authLogin": "Log In",
"homeTitle": "Dashboard",
"homeWelcome": "Welcome, {username}!",
"@homeWelcome": {
  "description": "Welcome message on home screen",
  "placeholders": {
    "username": {
      "type": "String"
    }
  }
}
}

langctl converts keys to camelCase:

  • home.welcome → homeWelcome
  • auth.login.title → authLoginTitle

As with Android, two keys that map to the same id make the pull fail with both names.

dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
  final l10n = AppLocalizations.of(context)!;

  return Column(
    children: [
      Text(l10n.homeWelcome('John')),
      Text(l10n.homeTitle),
    ],
  );
}
}

Add to pubspec.yaml:

yaml
flutter:
generate: true

dependencies:
flutter_localizations:
  sdk: flutter
intl: any

Create l10n.yaml:

yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
terminal
langctl pull -f arb    # → lib/l10n/app_en.arb, lib/l10n/app_pt_BR.arb, …

Parameter Conversion

langctl stores placeholders as {{name}}:

output
"Welcome, {{username}}! You have {{count}} items."
FormatOutput
json / nested-jsonWelcome, {{username}}! You have {{count}} items.
androidWelcome, %1$s! You have %2$s items.
iosWelcome, %1$@! You have %2$@ items.
arbWelcome, {username}! You have {count} items.

Tip

Positions follow the order in which each placeholder first appears. A placeholder used twice gets the same position every time ({{name}} … {{name}} → %1$s … %1$s), and a literal % is escaped as %% where needed.

Reading Files (push and import)

langctl push and langctl import read every format pull writes, and convert placeholders back to {{name}}:

  • .json — flat or nested JSON (nested objects are flattened to dotted keys)
  • .xml — Android strings.xml
  • .strings — iOS Localizable.strings
  • .arb — Flutter ARB (simple {name} placeholders; ICU blocks such as plurals are kept as-is)

The format is detected from the file extension; pass -f to override it.

Filtering by Module

Pull or export only specific modules:

terminal
langctl export my-project -l en -f json --module auth -o ./locales/auth-en.json

langctl pull --module home -o "src/locales/home/{lang}.json"

Choosing a Format

FormatBest ForProsCons
jsonWeb apps, React NativeMost popular, works with i18next out of the boxNo structure
nested-jsonvue-i18n, svelte-i18nOrganized structureA key can’t also be a parent
androidNative AndroidNative integrationAndroid-only
iosNative iOSNative integrationiOS-only
arbFlutter appsFull Flutter gen-l10n supportFlutter-only
output
- React / Next.js / Angular / React Native -> json
- Vue / Svelte -> nested-json
- Android Native -> android
- iOS Native -> ios
- Flutter -> arb

Best Practices

Preview changes before writing files:

terminal
langctl pull --dry-run    # show what would change
langctl pull              # write the files
git diff

Commit pulled files to version control — output is sorted and has no timestamps, so diffs only show real translation changes:

terminal
git add langctl.json src/locales/ app/src/main/res/values*/strings.xml

Check parameter syntax matches your format:

output
t('message', { username: 'John' })

getString(R.string.message, username)

String(format: NSLocalizedString("message", comment: ""), username)

Ensure UTF-8 encoding:

terminal
file -I locales/en.json
iconv -f ISO-8859-1 -t UTF-8 input.json > output.json

Plurals Not Working

langctl stores one string per key and language and doesn’t generate plural resources. Different platforms handle plurals differently:

  • i18next: Use _one, _other suffixes (separate keys in langctl)
  • Android: Use the <plurals> element (maintained outside langctl)
  • iOS: Use a .stringsdict file (maintained outside langctl)
  • Flutter: Use ICU message format in ARB - langctl keeps {count, plural, …} blocks as they are

Next Steps