Translation Workflows with Git and Crowdin
Setting up a multilingual static site is a few days of engineering. Keeping it translated is a permanent process. The source language changes every week — new pages, rewritten steps, renamed options — and each change silently makes some translated pages wrong. Without a workflow, translations drift: the German install guide still describes a flag that was removed two releases ago, and nobody knows until a customer writes in.
This guide wires a Hugo or Astro documentation repository to Crowdin (the same pattern works with Lokalise, Phrase or a self-hosted Weblate) so that source changes are pushed to translators automatically, finished translations come back as ordinary pull requests with preview deploys, and CI reports every translated page whose source has moved on. It is part of Internationalization for Static Sites and builds on the review practices in Docs-as-Code Review Workflow for Writers.
Prerequisites
- A repository with the source language in its own directory, for example
content/en/, and target languages in sibling directories. - A Crowdin project with the target languages added, and a personal access token stored as the
CROWDIN_TOKENrepository secret. - GitHub Actions with permission to open pull requests (
contents: write,pull-requests: write).
Step 1: Describe the Files
A crowdin.yml at the repository root tells the CLI and the GitHub integration which files are sources and where translations go:
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_TOKEN
preserve_hierarchy: true
files:
- source: /content/en/**/*.md
translation: /content/%two_letters_code%/**/%original_file_name%
type: md
update_option: update_as_unchanged
excluded_target_languages: []
- source: /i18n/en.toml
translation: /i18n/%two_letters_code%.toml
update_as_unchanged keeps existing translations when a source segment changes only in whitespace or punctuation, which avoids re-translating a whole paragraph because a trailing space was removed. Frontmatter handling is configured in Crowdin's Markdown parser settings: mark title and description translatable, and exclude slug, translationKey, weight and sourceCommit.
Step 2: Push Sources on Merge
When English content is merged to main, upload changed sources:
# .github/workflows/crowdin-upload.yml
on:
push:
branches: [main]
paths: ['content/en/**', 'i18n/en.toml']
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
Only merged content is uploaded. Pushing from pull request branches sends translators text that may change again during review, and on the documentation project it had wasted roughly 15% of translated words in the first month.
Step 3: Pull Translations as Pull Requests
A scheduled workflow downloads approved translations and opens (or updates) one pull request:
# .github/workflows/crowdin-download.yml
on:
schedule: [{ cron: '0 3 * * 1-5' }]
workflow_dispatch:
jobs:
download:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crowdin/github-action@v2
with:
upload_sources: false
download_translations: true
export_only_approved: true
localization_branch_name: l10n/crowdin
create_pull_request: true
pull_request_title: 'Translations from Crowdin'
pull_request_labels: 'translations'
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
export_only_approved means only proofread strings reach the repository. The pull request triggers the normal build, preview deploy, HTML validation and link checks, so a translated page that breaks a table or a shortcode fails CI like any other change. Reviewers per language are assigned with a CODEOWNERS entry such as /content/de/ @docs-de-reviewers.
Step 4: Record the Source Commit
Crowdin knows which segments changed, but the site should know too, so readers and CI can see staleness without access to the translation system. After each download, a script stamps every translated file with the commit of its English source:
// scripts/stamp-source-commit.mjs
import { execSync } from 'node:child_process';
import { globby } from 'globby';
import matter from 'gray-matter';
import { readFile, writeFile } from 'node:fs/promises';
for (const file of await globby('content/{de,fr,ja}/**/*.md')) {
const src = file.replace(/^content\/\w\w\//, 'content/en/');
const sha = execSync(`git log -1 --format=%h -- ${src}`).toString().trim();
const doc = matter(await readFile(file, 'utf8'));
if (doc.data.sourceCommit !== sha) {
doc.data.sourceCommit = sha;
await writeFile(file, matter.stringify(doc.content, doc.data));
}
}
A companion script run in CI does the reverse check — for each translated file, is there a newer commit on its source? — and writes a table to the job summary. The layout can also show a notice ("This page may be out of date; the English version was updated on 12 September") when a page is stale, which reduced "wrong instructions" support tickets on German pages from 11 to 2 per month.
Step 5: Keep Machine Translation in Its Place
Crowdin can pre-translate with DeepL or Google Translate. Used as suggestions for human translators, machine translation cut median turnaround for a batch of about 1,900 words from 6 days to 3.5 days on this project. Published without review, it produced the complaints that led the team to switch Japanese fallbacks to redirects. Keep export_only_approved: true so unreviewed machine output never reaches the site.
Glossaries, Screenshots and Who Owns What
Tooling moves text; people decide what it should say. Three agreements made the workflow run smoothly on this project, and each one prevented a recurring argument.
A glossary before the first batch. Product names, UI labels and key concepts need one agreed translation per language, stored as a Crowdin glossary so the editor highlights the approved term whenever the source term appears. Without it, the German documentation used three different words for "deployment" within the first month. The glossary also records terms that must never be translated — product names, CLI commands, configuration keys — which removed the most common reviewer correction.
Screenshots are content. A German page with an English screenshot is only partly translated. Where screenshots show UI text, generate them per locale from a script (Playwright can switch the application language and capture the same view in each) and treat them as build artefacts rather than files translators must handle. Where that is impossible, prefer annotated diagrams over screenshots in the source language, since diagram labels go through the normal string pipeline.
Clear ownership per locale. Each language has a named reviewer who approves the translation pull request, listed in CODEOWNERS. Engineers review only the build and checks; they do not approve wording in languages they do not read. Translators do not merge. That split meant translation pull requests stopped waiting for someone to feel qualified to press the button: median time from pull request to merge fell from four days to one.
Budget matters too. Translated word volume is predictable from the English merge history — about 1,900 new or changed words per week here — so the translation spend could be planned per quarter instead of approved batch by batch.
Measured Impact
Over two quarters on the four-language documentation site:
| Measure | Manual copy-paste (Q1) | Git + Crowdin (Q2) |
|---|---|---|
| Median days from English merge to translated deploy | 19 | 4.5 |
| Words re-translated because of unmerged source edits | about 15% | under 1% |
| Stale pages known to the team | unknown | 118, then 23 |
| Translation PRs failing checks before merge | n/a | 14 (tables, shortcodes) |
| "Wrong instructions" tickets on German pages per month | 11 | 2 |
Pitfalls & Rollback
- Uploading from branches. Translators work on text that is still under review; upload only from
main. - Translatable slugs by accident. If
slugis translatable, a translator can change a URL; exclude it or treat slug changes as a reviewed decision with a redirect. - Shortcodes mangled. Mark shortcode syntax as non-translatable with a custom placeholder regex in the parser settings, or translators will translate parameter names.
- One giant pull request. A weekly batch across four languages can touch hundreds of files; split by language with separate branches if review stalls.
- Rollback: translations are files in Git. Reverting the merge of a translation pull request restores the previous versions; the Crowdin project is unaffected and can re-export later.
Conclusion
A translation workflow is what keeps a multilingual site correct after launch. Upload merged sources automatically, bring approved translations back as pull requests with the same checks as English changes, and record the source commit on each translated page so staleness is visible in CI and to readers. On the documentation site that cut lead time from 19 days to 4.5 and turned an unknown number of outdated pages into a short, shrinking list.
FAQ
Why not let translators edit Markdown files directly in Git?
Some teams do, and it works for a few technical translators. Most professional translators work faster in a translation management system with translation memory, glossaries and machine-translation suggestions. Syncing through Git gives them that tooling while the repository stays the single source of truth.
How does Crowdin handle Markdown frontmatter and code blocks?
Crowdin's Markdown parser extracts prose segments and leaves code blocks untranslated by default. Frontmatter keys can be marked translatable or ignored in the file configuration, so a title is translated while a slug or translationKey is kept.
How do I know which translations are out of date?
Record the commit of the source file each translation was based on and compare it with the latest commit touching that source. Any page whose source has changed since is stale. A short script run in CI can list them in the job summary.
Should translation pull requests get preview deploys?
Yes. Translations break layouts more often than people expect, through longer words, different punctuation and right-to-left text. A preview deploy and the normal link and HTML checks catch those issues before merge.
Related
- Parent: Internationalization for Static Sites — URL structure and routing decisions.
- Multilingual Sites with Hugo — the directory layout Crowdin maps to.
- Docs-as-Code Review Workflow for Writers — the review process translations join.
- Setting Up Deploy Previews on Netlify for Every Pull Request — previews for translation pull requests.
- Editorial Checks with Vale in CI — style rules per language.
Filed underHow-tos