The CLI supports monorepos natively. Use a single i18n.json at the root with a recursive ** glob to auto-discover packages, a single root config with explicit per-package paths, or separate i18n.json files in each package combined with a matrix-strategy GitHub Action.
When to use each pattern#
| Scenario | Pattern | Config files | GitHub Action |
|---|---|---|---|
| All packages share the same layout and locales, and you want new packages picked up automatically | Recursive single config | One i18n.json at root with a ** glob | One step, default working-directory |
| All packages share source and target locales | Single config | One i18n.json at root | One step, default working-directory |
| Packages need different target locales | Per-package configs | One i18n.json per package | Matrix strategy with working-directory |
| Packages need different engines (glossary, brand voice) | Per-package configs + separate engines | One i18n.json per package, each with its own engineId | Matrix strategy with working-directory |
Pattern 1: Recursive single config#
When every package follows the same layout (e.g. apps/<name>/locales/[locale].json), use a recursive ** glob in a single root i18n.json. New packages are picked up automatically as long as they match the layout:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de", "ja"]
},
"buckets": {
"json": {
"include": ["apps/**/locales/[locale].json"]
}
}
}The CLI ignores node_modules, .git, dist, build, .next, and .turbo by default, so recursive patterns won't descend into vendored or build trees. Add your own exclude entries for anything else you want skipped.
The GitHub Action is identical to Pattern 2 below – one step, default working directory.
Use this when every package shares the same source locale, target locales, layout, and localization engine, and you don't want to edit i18n.json every time you add a package.
Pattern 2: Single config, shared locales#
One i18n.json at the repository root. Bucket paths reach into each package explicitly:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de", "ja"]
},
"buckets": {
"json": {
"include": [
"apps/web/locales/[locale].json",
"apps/dashboard/locales/[locale].json"
]
},
"markdown": {
"include": ["packages/docs/content/[locale]/*.md"]
}
}
}GitHub Action – standard single step:
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
pull-request: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Use this when every package shares the same source locale, target locales, and localization engine, but the layouts differ enough that a recursive glob would be too broad.
Pattern 3: Per-package configs, different locales#
Each package has its own i18n.json with independent locale targets:
apps/web/i18n.json:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de", "ja", "ko", "zh-Hans"]
},
"buckets": {
"json": {
"include": ["locales/[locale].json"]
}
}
}apps/marketing/i18n.json:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de"]
},
"buckets": {
"json": {
"include": ["locales/[locale].json"]
},
"markdown": {
"include": ["content/[locale]/*.md"]
}
}
}GitHub Action – matrix strategy with working-directory:
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
translate:
runs-on: ubuntu-latest
strategy:
matrix:
package: [apps/web, apps/marketing]
steps:
- uses: actions/checkout@v4
- uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
working-directory: ${{ matrix.package }}
pull-request: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Each matrix entry runs the CLI scoped to that package's i18n.json. The action translates only the files that package declares.
Pattern 4: Per-package configs, separate engines#
Same structure as Pattern 3, but each package connects to a different localization engine. This gives each package its own glossary, brand voice, and model configuration.
apps/product/i18n.json:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de", "ja"]
},
"buckets": {
"json": {
"include": ["locales/[locale].json"]
}
},
"engineId": "eng_ProductApp123"
}apps/docs/i18n.json:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr"]
},
"buckets": {
"markdown": {
"include": ["content/[locale]/*.md"]
}
},
"engineId": "eng_DocsEngine456"
}The GitHub Action YAML is identical to Pattern 3 – the engineId in each i18n.json routes translations to the correct engine automatically.
How the lockfile works#
Each i18n.json location produces its own i18n.lock file in the same directory. The lockfile tracks which source content has been translated, enabling incremental updates. In Patterns 1 and 2, there is one lockfile at the root. In Patterns 3 and 4, each package has its own lockfile.
FAQ#
Will new packages be picked up automatically?
Only with Pattern 1 (recursive **). With Patterns 3 and 4, you add a new entry to the workflow's matrix.package list when you add a package.
Can I have different locales per package?
Yes. Use Pattern 3 or 4. Each package's i18n.json declares its own locale.targets array independently.
Do I need one workflow file or multiple?
One workflow file with a matrix strategy handles all packages. Each matrix entry runs against a different working-directory, scoping the CLI to that package's config.
Can packages share a glossary but have different locales?
Yes. Point both packages to the same engineId but configure different locale.targets in each i18n.json. The engine's glossary applies to whichever locale pairs are requested.
