privacytracker’s UI is translated through Crowdin using their free OSS plan. locales/en.json is the source of truth; every other locales/<lang>.json is round-tripped through Crowdin so non-developer translators can review and edit copy in a friendly UI without touching JSON by hand.

High-level loop

One-time setup (project owner only)

1

Create the Crowdin project

Sign up at crowdin.com (free for open-source projects). Create a new project. Source language: English. Target language(s): Simplified Chinese and any others you plan to support.Project Settings → File format defaults: keep “Use Crowdin’s placeholder syntax: yes” so {count, plural, …} is validated automatically.
2

Find your project ID

Open your project in Crowdin. The URL contains the slug; Settings → API shows the numeric Project ID.
3

Generate a personal token

Account Settings → API → New Token. Scopes: Project (read+write). Save the token somewhere secure — Crowdin shows it once.
4

Add repo secrets

GitHub repo → Settings → Secrets and variables → Actions:
  • CROWDIN_PROJECT_ID — the numeric ID from step 2.
  • CROWDIN_PERSONAL_TOKEN — the token from step 3.
5

First push of en.json

Either trigger the workflow manually (Actions → Crowdin → Run workflow), or push a no-op edit to locales/en.json on main. Either way, crowdin/github-action uploads the source bundle on first run. In the Crowdin UI you should now see locales/en.json with all keys ready to translate.

Day-to-day (developer)

When you add or rename a string in code:
1

Edit en.json (and optionally other locales)

Edit locales/en.json. You can also update locales/zh.json with a placeholder you want — Crowdin will re-flag it as unapproved so the translator re-checks it, but having a value avoids leaking the English fallback into zh in the meantime.
2

Run the parity check

The script exits non-zero with a sorted per-namespace diff if any target locale is missing or has extra keys.
3

Commit + push

The Crowdin GitHub Action picks up the change to locales/en.json and uploads it the next time main advances.

Day-to-day (translator)

  1. Open the Crowdin project.
  2. Pick the file (locales/en.json) and target language (e.g. zh-CN).
  3. Translate strings; use the in-line preview, glossary, and screenshot context where available.
  4. Approve entries you’re confident about. Anything left unapproved is still pulled into the PR but flagged in the diff so developers know it’s a draft.

When translations land back

The workflow opens a PR titled i18n: new translations from Crowdin into the l10n_main branch every Monday at 08:00 UTC (and on manual dispatch). To merge:
If the PR adds a new language, also create an empty locales/<new-lang>.json placeholder if Crowdin hasn’t already added one — next-intl needs the file to exist before its dynamic import resolves. Then squash-merge.

Adding a new locale

1

Add it in Crowdin

Project Settings → Target Languages → add the language. Wait for the Crowdin workflow to upload the source again so the language appears in the project tree.
2

Update crowdin.yml mapping

crowdin.yml → languages_mapping.two_letters_code: add the row if Crowdin’s ID doesn’t match your bundle filename (e.g. pt-BR: pt-br for pt-br.json; nothing needed for plain es: es).
3

Update next-intl config

Add the new locale to the supported list in i18n.ts so the bundle gets loaded. The route tree is flat — there’s no [locale] URL segment; the active locale is selected at render time from app settings.
4

Drop a placeholder JSON

locales/<new>.json containing {} so the first build succeeds before Crowdin populates it.
5

Trigger the workflow

Manually run the Crowdin workflow (or wait for the next scheduled run) — translations flow back as a PR.

Why this layout

  • One flat JSON per locale — what next-intl expects natively. No build-time merge step, no per-page chunking that would break useTranslations('foo.bar') lookups.
  • Crowdin (vs Tolgee / Weblate / Lokalise) — Crowdin Free fits OSS, has native ICU MessageFormat validation, supports branched translation flows, and the GitHub Action is mature.
  • update_as_unapproved — when an English string changes, the paired translation isn’t silently kept. Translators see it as unapproved next time they open Crowdin.
  • Weekly pull (not on-push) — translations get a coherent batch. An on-push pull cycle would generate dozens of half-translated PRs during active development.

ICU placeholders

Don’t strip braces or rename placeholders without coordinating across both bundles — Crowdin validates them on upload and next-intl validates them at render. Brand names (privacytracker, App Store, Apple Configurator, ToS;DR, PrivacySpy) stay in English in every locale; they’re proper nouns.