Crowdin Integration for Dev Teams
Crowdin integration for dev teams fails most often at the boundary between your crowdin.yml file mapping and the CLI — a run that prints error: Files from configuration file were not found in your file system (or silently downloads zero translations) usually means your source/translation globs don’t match the repo layout the CLI actually sees. This page wires Crowdin into a CI/CD pipeline so source strings flow up on every merge and reviewed translations flow back down as pull requests, without a human ever touching the dashboard. It belongs to Translation Workflows & CI/CD Pipeline Sync and assumes you already extract keys into a source bundle.
Prerequisites
Concept & spec: what Crowdin actually maps
Crowdin’s CLI is a thin client over Crowdin API v2 (https://api.crowdin.com/api/v2/). Everything the CLI does — push, pull, status, string add — resolves to REST calls scoped by project → branch → file → string. Your crowdin.yml is the declarative contract that tells the CLI which local files are sources and where the translated variants land. The token authenticates as a Crowdin user; the project ID picks the project; the rest is glob matching.
Locale placeholders in the translation pattern follow Crowdin’s own token vocabulary, not BCP 47 directly: %two_letters_code% emits ISO 639-1 (fr), %locale% emits a hyphenated tag (pt-BR), and %locale_with_underscore% emits pt_BR for gettext trees. Because those tokens decide your on-disk layout, treat them as part of your locale negotiation strategy — the file the CLI writes must be the file your runtime loader reads. If you compare Crowdin against self-hosted tooling for governance reasons, the trade-offs live in Crowdin vs Weblate for self-hosted teams. This page is one branch of Translation Workflows & CI/CD Pipeline Sync.
Step-by-step implementation
1. Write the crowdin.yml configuration
Place crowdin.yml at the repo root. Keep credentials in environment variables via the _env suffix so the file is safe to commit. The files array is order-independent but every entry must resolve to at least one real file relative to base_path, or the CLI aborts.
# crowdin.yml
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
base_path: '.'
preserve_hierarchy: true
files:
- source: '/locales/en.json'
translation: '/locales/%two_letters_code%.json'
update_option: 'update_as_unapproved'
dest: '/locales/en.json'
preserve_hierarchy: true keeps the repo’s directory tree inside Crowdin so two messages.json files in different folders don’t collide.
2. Authenticate and validate before pushing
Never let the first real CLI call in CI be a mutating one. Run crowdin status first: it exercises auth, project ID, and config parsing without changing anything, turning a misconfigured token into a fast, obvious failure.
export CROWDIN_PROJECT_ID=123456
export CROWDIN_PERSONAL_TOKEN=*** # from CI secrets
crowdin status --verbose # auth + config smoke test, mutates nothing
crowdin config check # validate crowdin.yml against the schema
If status returns 401, the token is wrong or unscoped; a 404 means the project ID is wrong; “files were not found” means a source glob misses.
3. Upload sources on merge to the default branch
Upload only canonical sources, and only from the branch you treat as the source of truth. Gate the push so feature branches don’t race each other into the same Crowdin branch.
# runs only on push to the default branch
crowdin push sources \
--branch main \
--no-progress
push sources creates or updates a Crowdin branch named main, mirroring your Git default branch so source history stays aligned across systems.
4. Download approved translations back as a pull request
On a schedule (or after a translation webhook fires), pull only reviewed strings and open a PR rather than committing to the default branch directly. This keeps localization changes reviewable and revertable.
crowdin pull \
--branch main \
--export-only-approved \
--skip-untranslated-strings
git switch -c chore/i18n-sync
git add locales/
git commit -m "chore(i18n): sync approved translations from Crowdin"
# open a PR with gh / your CI's PR action
--export-only-approved guarantees only proofread strings ship; --skip-untranslated-strings prevents empty values from clobbering your fallback chain.
5. Attach string context so translators stop guessing
Untranslatable ambiguity (“Order” = noun or verb?) is the top driver of translator queries. Push context with the source — either inline in the source file, or programmatically via API v2’s Edit String endpoint after upload.
# add context to a single string via API v2
curl -X PATCH \
"https://api.crowdin.com/api/v2/projects/$CROWDIN_PROJECT_ID/strings/$STRING_ID" \
-H "Authorization: Bearer $CROWDIN_PERSONAL_TOKEN" \
-H "Content-Type: application/json" \
-d '[{"op":"replace","path":"/context","value":"Button on checkout; max 12 chars"}]'
For automated screenshot context and PR-scoped sync, see connecting Crowdin API to GitHub pull requests.
Configuration reference
| Option | Type | Description / default |
|---|---|---|
project_id / project_id_env |
string | Numeric Crowdin project ID, or the env var holding it. Prefer the _env form in committed config. |
api_token / api_token_env |
string | Personal Access Token, or the env var holding it. No default — required. |
base_path |
string | Root the source/translation globs resolve against. Default . (config file directory). |
preserve_hierarchy |
boolean | Keep the repo folder tree inside Crowdin. Default false; set true for monorepos. |
source |
glob | Local source file pattern, e.g. /locales/en.json. Must match ≥1 file or the CLI errors. |
translation |
pattern | Output path for translated files using Crowdin placeholders (%two_letters_code%, %locale%). |
update_option |
enum | update_as_unapproved or update_without_changes — how re-uploaded sources affect existing translations. Default keeps strings but resets approval. |
dest |
string | Override the file name shown inside Crowdin (decouples display name from local path). |
--export-only-approved |
flag | On pull, export only proofread strings. Off by default. |
--skip-untranslated-strings |
flag | On pull, omit strings with no translation instead of writing empty/source values. |
Framework variants
React / Next.js (JSON): point source at the bundle your loader imports (/public/locales/en/common.json or /messages/en.json for App Router). Run crowdin pull before next build so the static export includes the latest approved copy, and keep %two_letters_code% aligned with your route locale segments.
Vue / Nuxt (vue-i18n): map /locales/en.json to /locales/%two_letters_code%.json and let @nuxtjs/i18n lazy-load the pulled files. If Crowdin returns a region tag like pt-BR but your config expects pt, normalize with %two_letters_code% or add an alias so the loader doesn’t miss the file.
Angular (XLIFF): set source: '/src/locale/messages.xlf' and translation: '/src/locale/messages.%locale%.xlf'; Crowdin parses XLIFF 1.2/2.x natively. Run crowdin pull before ng build --localize so the i18n build picks up every target locale.
Node.js backend (gettext / PO): use %locale_with_underscore% so files land as locales/pt_BR/LC_MESSAGES/messages.po, matching gettext’s expected tree. Compile to .mo after pulling, in the same CI job.
Verification
Prove the round trip in CI before trusting it. The cheapest check is a dry-run status plus a guard that the pulled tree is non-empty and parses.
# 1. config + auth must pass
crowdin status --verbose
# 2. pull, then assert files exist and are valid JSON
crowdin pull --branch main --export-only-approved
test -s locales/fr.json || { echo "no French translations pulled"; exit 1; }
jq empty locales/*.json # exits non-zero on malformed JSON
A GitHub Actions step that fails the build on a broken sync:
- name: Verify Crowdin sync
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
run: |
npx @crowdin/cli status --verbose
npx @crowdin/cli pull --branch main --export-only-approved
git diff --exit-code --stat locales/ || echo "translations changed — PR will open"
Expected output from a healthy status is a per-language progress table (translated %, approved %); any non-zero exit means stop the pipeline. For a deeper gate that fails builds on untranslated keys, see GitHub Actions i18n CI gates.
Common pitfalls
- “Files were not found in your file system.” Your
sourceglob is relative tobase_path, not the repo root. Runcrowdin config checkand confirm the leading slash semantics — a leading/means “relative tobase_path”, not absolute disk. - A
422on branch push. Crowdin rejects branch names with characters it can’t slugify, or a push that conflicts with an existing branch state. The full diagnosis is in Crowdin webhook 422 on branch push. - Approval reset on every upload.
update_as_unapprovedre-uploads strings as unapproved when the source text changes — expected, but surprising. Useupdate_without_changesif identical re-uploads keep flipping approval state. - Empty values overwrite good fallbacks. Without
--skip-untranslated-strings,pullwrites empty or source strings, defeating your graceful fallback chain. - Race conditions on concurrent merges. Two PRs pushing sources to the same Crowdin branch can interleave. Serialize the push job (CI concurrency group) so one upload finishes before the next starts.
- Region vs language mismatch. Crowdin’s
pt-BRwon’t load if your runtime expectspt. Standardize the placeholder token and your loader on the same shape.
FAQ
Should I commit crowdin.yml with the token inside it?
No. Commit crowdin.yml with project_id_env and api_token_env pointing at environment variables, and inject the real Project ID and Personal Access Token from CI secrets. The config file is safe to version; the credentials never touch the repo.
How do I keep Crowdin branches in sync with Git branches?
Pass --branch <name> to every push and pull so the CLI creates and targets a Crowdin branch mirroring your Git branch. Mirror only long-lived branches (typically the default branch); deleting the Git branch does not auto-delete the Crowdin branch, so prune stale ones via API v2 or the dashboard.
Why does crowdin pull download nothing even though translations exist?
Almost always --export-only-approved with no approved strings, or a translation pattern whose placeholder doesn’t match the languages your project actually has. Run crowdin status to see approved percentages per language, then confirm the %two_letters_code%/%locale% token matches those locale codes.
Can I add translator context automatically from CI?
Yes. After push sources, list strings via API v2 and PATCH each string’s context field, or embed context as developer comments in the source file that Crowdin imports. Programmatic context plus screenshots dramatically cuts translator queries — wire it into the same job that uploads sources.
Do I need the CLI at all, or can I use the GitHub app?
The Crowdin GitHub app handles push/pull without writing CI steps, but it gives you less control over timing, gating, and branch naming. Teams that need approval gates, --skip-untranslated-strings, or custom PR bodies generally script the CLI in their own pipeline instead.
Related
- Connecting Crowdin API to GitHub pull requests — webhook payloads and bot-token scoping for PR-driven sync.
- Crowdin webhook 422 on branch push — diagnosing the most common branch-push failure.
- Crowdin vs Weblate for self-hosted teams — choosing managed SaaS versus self-hosted governance.
- GitHub Actions i18n CI gates — failing the build on untranslated or malformed keys.
- Setting up graceful fallback chains for missing strings — what to render when a pulled locale is incomplete.