By the strings.dev team — maintainers of native .xcloc and strings.xml localization tooling for iOS and Android.
Continuous localization, indie-sized
"Continuous localization" usually arrives wrapped in enterprise language — seat licenses, a dedicated localization manager, a platform onboarding call. You need none of that. For a solo developer or a two-person team building a mobile app, it's a much smaller idea: when you push a commit that adds or changes a string, the translations catch up on their own — no manual export, no import step, no "we'll localize it next sprint."
The whole thing runs on two primitives you already have: a git repository and a CI runner. strings.dev gives you a per-project REST key and a per-project CLI; you wire them into a hook or a GitHub Actions step, and the translate-and-merge loop becomes part of your pipeline. No new seats, no org setup — it fits inside the two-project ceiling and works on the same native files you already track.
The two pieces: a per-project key and a CLI
Every strings.dev project has its own API key (prj_live_…). That scoping is deliberate — the key you drop into a CI secret only ever touches one project's strings, so a leaked token can't reach anything else. Store it as an encrypted secret, never in the repo:
# GitHub → Settings → Secrets and variables → Actions
STRINGS_PROJECT_KEY=prj_live_…
The CLI reads that key from the environment and talks to your project: it uploads changed source strings, triggers translation with your app-description context and per-string notes attached, then writes the finished native files back into your working tree. Exact command names and the install line live in the CLI docs — the pattern that matters is push source → pull translations → commit the result, identical whether it runs on your laptop or a CI box.
Re-translate only what changed
This is cheap to run on every push because strings.dev works from per-string state. Apple's String Catalog (.xcstrings) already tracks a translation state per string, and the service treats already-translated, unchanged strings as done. A push that adds three keys sends three strings — not your whole catalog. Runs stay fast, and the free tier's unlimited word translations don't get burned re-doing settled work.
Scope the trigger so unrelated commits don't spin up a job:
on:
push:
paths:
- '**/*.xcstrings'
- '**/*.xcloc/**'
- '**/res/values/strings.xml'
A pre-push git hook
If you'd rather catch strings before they leave your machine, a pre-push hook is the lightest option — plain git, no extra infrastructure. It runs the CLI and blocks the push if new strings fail to sync:
#!/bin/sh
# .git/hooks/pre-push (chmod +x to enable)
export STRINGS_PROJECT_KEY="$(cat ~/.strings-key)"
# Sync translations for changed strings before the push lands.
# (command names per docs.strings.dev)
strings push && strings pull || {
echo "Translation sync failed — resolve before pushing."
exit 1
}
git add -A ':(glob)**/*.xcstrings' ':(glob)**/values*/strings.xml'
git diff --cached --quiet || git commit -m "chore(l10n): sync translations"
Hooks live outside version control, so for a shared repo prefer CI. For a solo project, a hook keeps every push translation-complete with zero external moving parts.
The GitHub Actions workflow
The durable home for continuous localization is CI, where it behaves the same for everyone who pushes. This localization GitHub Action fires on pushes that touch localization files, runs the strings.dev CLI, and commits the returned translations straight back to the branch:
name: Localize
on:
push:
paths:
- '**/*.xcstrings'
- '**/res/values/strings.xml'
permissions:
contents: write # allow committing translations back
jobs:
localize:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the strings.dev CLI
run: npm install -g strings-cli # exact install line: docs.strings.dev
- name: Push source and pull translations
env:
STRINGS_PROJECT_KEY: ${{ secrets.STRINGS_PROJECT_KEY }}
run: |
strings push # upload changed source strings
strings pull # write native translated files back
- name: Commit translations
run: |
git config user.name "strings-bot"
git config user.email "[email protected]"
git add -A
git diff --cached --quiet || git commit -m "chore(l10n): sync translations"
git push
Two things keep this honest: the strings-cli name and the push/pull verbs are illustrative — confirm the current literals in the docs — and the contents: write permission is what lets the job commit back. If you'd rather a human eyeball translations first, run it on a branch and open a PR instead of pushing to the default branch.
Hand the whole loop to an agent
If your automation is agent-driven, the same per-project key powers the strings-mcp-server MCP package plus a generated AI-skill prompt, so an assistant (Claude, Cursor, Copilot) can run push-translate-pull itself rather than you scripting each step. That path has its own walkthrough — see AI localization — but it uses the exact same project and key as the CLI, so you can start with a workflow and graduate to an agent without re-plumbing anything.
Where the free tier stops
Be clear-eyed about limits. Indie is free and covers 1 project and 1 language, with unlimited word translations and app + brand localization — enough to run this entire CI loop end to end for your first locale. The moment you want more than one language, a second project, or the QA & analytics dashboard that flags failed translations for retry so a broken string never reaches a store build, that's Indie Plus — $20/mo, or $10/mo billed annually. No enterprise tier exists, and nothing here needs one.
One scope note: this loop localizes in-app native strings only. App Store Connect and Google Play listing metadata are a separate task handled in the store consoles, not something the CLI touches. For the format-and-plurals foundation this automation sits on, see the mobile app localization guide; for locale qualifiers, the iOS + Android locale reference.