Connect your Payload CMS to a localization engine, pick the collections and globals to cover, and Lingo.dev translates their localized fields into your target locales and writes them back to Payload under each locale.
Works with Payload 3 projects that have localization enabled and use the Lexical rich text editor. The older Slate editor is not supported. Localized text, textarea, and richText fields are translated. Everything else in the document is left as it is.
The Payload integration is enabled per organization. If you don't see it under Settings -> Integrations, reach out and we'll turn it on for you.
Before You Start#
You need three things:
- Payload 3 with localization configured. Make sure your source and target locales match the locales in your Payload config.
- A service user with an API key. Set
useAPIKey: trueon your auth collection (usuallyusers), create a user for Lingo.dev, and generate an API key for it in the Payload admin. The user needs read and update access to every collection and global you want translated. - A localization engine. Its glossary, brand voice, and rules shape the translations.
Locale codes must match your Payload config
The locales you pick in Lingo.dev must match the codes under localization.locales exactly. If Payload has en and de, pick English and German, not English (United States): en-US and en are different locales. Use your Payload defaultLocale as the source locale, because that is the locale the plugin watches for changes.
Connect Your Payload Instance#
Open the integration
Go to Settings -> Integrations and click Connect under Payload CMS.
Enter your instance details
| Field | What to enter |
|---|---|
| Connection name | A label such as Production or Staging |
| Payload Base URL | The root URL of your instance, e.g. https://cms.example.com. HTTPS only |
| Auth Collection Slug | The collection your service API key belongs to, usually users |
| API Key | The service user's API key |
| Custom headers | Optional. Sent on every request to your instance |
Lingo.dev verifies the key against your instance before moving on.
Choose what to translate
| Setting | What it does |
|---|---|
| Collections and Globals | Tick the ones to translate. A row marked No read + update stays disabled until the service user gets access to it |
| Source Locale | The locale your editors write in. Use your Payload defaultLocale |
| Target Locales | The locales to translate into |
| Engine | The localization engine that translates this content |
| Translate draft saves | Off translates only published changes. On translates draft saves too and keeps the translations as drafts |
Install the plugin
The last step shows your webhook URL. Copy it now. It is shown only once. Store it as LINGO_WEBHOOK_URL in your Payload environment, then install the plugin and add it to your config:
pnpm add @lingo.dev/payloadcmsimport { buildConfig } from "payload";
import { lingo } from "@lingo.dev/payloadcms";
export default buildConfig({
// ...your collections, globals, and localization config
plugins: [
lingo({
webhookUrl: process.env.LINGO_WEBHOOK_URL,
}),
],
});Redeploy Payload. From now on, every change published in the source locale is sent to Lingo.dev for translation.
What the plugin does
It adds a GET /api/lingo/schema endpoint that tells Lingo.dev which of your fields are localized text, and a hook that pings Lingo.dev when a document or global changes. Scope, locales, and engine live in the dashboard, so you can change them without redeploying. Leave out webhookUrl to skip the hook and start every translation from the dashboard.
Choose What Gets Translated#
The connection page has three tabs: Collections, Globals, and Runs.
Scope is set per collection and per global. Every document in a selected collection is covered. To change scope, locales, engine, or the draft setting, click Edit configuration in the page header. Changes apply to the next run without a redeploy.
Inside a document, your Payload field config decides what is translated:
| Field | Translated |
|---|---|
text, textarea, and richText fields marked localized: true | Yes |
The same field types inside a localized group, array, blocks, or tabs | Yes |
| Blocks and inline blocks embedded in rich text | Yes, their text fields by the same rules |
select, radio, checkbox, number, date, relationship, upload, json, code, email, point | No |
| Fields that are not localized and have no localized parent | No |
id, blockType, blockName | No |
Rich text is translated as a Lexical tree. Formatting, links, uploads, and block structure are preserved, and only the text inside is replaced. A sentence broken up by bold text or a link is translated as one sentence.
To bring a field into scope, mark it localized: true in Payload and redeploy. The next run picks it up.
Sync and Retranslate#
Automatic runs. With webhookUrl set in the plugin, every save of a document or global in the source locale notifies Lingo.dev. Saves made within a short time window are debounced into one run. Saves in other locales, draft saves (unless Translate draft saves is on), and content outside your scope are ignored.
Manual runs. Every collection, global, and document row has two buttons:
| Button | What it does | When to use it |
|---|---|---|
| Sync | Translates only what changed since the last run | Backfilling content after connecting, or retrying after a failure |
| Retranslate | Translates everything in the row again, from scratch | After changing your engine's glossary, brand voice, or rules |
Open a collection to reach its documents and sync one at a time. Both tabs show when each item was last synced.
Nothing is translated on connect. To translate what you already have, click Sync on each collection and global. Adding a target locale later works the same way: the next Sync fills it.
Only one run is in flight per connection. Further requests queue and start in order. While a row is covered by a queued or running run, its buttons read Syncing....
Retranslate overwrites hand edits
Retranslate regenerates every translated field in its scope, including translations your team edited by hand in Payload. Sync only regenerates fields whose source text changed, so hand edits elsewhere survive.
Watch a Run#
The Runs tab lists every run with its status, trigger (Webhook or Manual, sync or retranslate), start time, and duration. A queued or running run can be cancelled from the list.
Open a run to see the stage it is in (reading from Payload, translating, writing back), overall progress, progress per target locale, and the documents, collections, and globals it covers. Each item links to the Payload admin.
| Status | Meaning |
|---|---|
| Queued | Waiting for the run ahead of it |
| Running | In progress |
| Completed | Every translation was written back |
| Up to date | Nothing in scope changed since the last run. Not a failure |
| Failed | The run stopped. The reason is shown at the top of the run detail |
| Cancelled | Stopped by someone on your team |
A failed run keeps what it already wrote. The error message lists the documents that were not written, and the next Sync retries them. A document an editor saved mid-run is skipped and picked up by the next run.
Where Translations Land#
Each translation is written to the same document or global under its target locale, Payload's own localization model. Only translated fields are written. Every other field stays untouched. Write-backs run as the service user and do not trigger a new run.
Existing translations are kept. On the first sync of a document, whatever a target locale already holds stays in place and only the missing fields are translated. A field still holding its Payload default value counts as missing. Use Retranslate to replace existing translations.
Drafts vs Published#
With Translate draft saves off, the default, only published changes start a run, and translations are published as soon as they are written. Payload publishes the whole document, so any unpublished draft edits on it go live with the translation.
With it on, draft saves start runs too. Lingo.dev reads the latest draft of the source and writes each translation as a draft. Nothing changes for your readers until someone publishes the translation in Payload. Use this while evaluating translation quality or when translations go through review.
Managing the Connection#
Rotate the webhook URL#
Open the menu in the connection page header and choose Regenerate webhook URL. The old URL stops working immediately. Update LINGO_WEBHOOK_URL and redeploy. Editing the connection keeps the URL.
Disconnect#
Disconnect from Settings -> Integrations -> Payload CMS. This removes the connection, its run history, and the record of what has been translated. Translations already written stay in Payload. Remove LINGO_WEBHOOK_URL or the plugin from your config afterwards.
Reconnecting means a new webhook URL
A new connection gets a new webhook URL, so update LINGO_WEBHOOK_URL and redeploy before automatic runs work again. The first sync re-reads every document in scope, keeps the translations Payload already has, and fills the gaps.
Limits#
| Limit | Detail |
|---|---|
| Payload version | Payload 3 with localization configured |
| Field types | text, textarea, and richText (Lexical only) marked localized |
| Scope | Whole collections and globals. No per-field selection |
| Connections | Several per organization, one per Payload instance |
| Concurrent runs | One per connection |
| Base URL | HTTPS only |
Troubleshooting#
Connecting fails with "Payload rejected the API key". Check the key, the auth collection slug, and that useAPIKey is enabled on that collection.
A collection or global shows "No read + update". Grant the service user read and update access in that collection's access config, then reopen the configuration.
The first run fails with "The Lingo plugin isn't installed". Add @lingo.dev/payloadcms to your Payload config's plugins and redeploy. Connecting works without the plugin; syncing does not.
Publishing in Payload does not start a run. Check that LINGO_WEBHOOK_URL is set, localization is configured, the collection or global is in scope, the save was in the source locale, and it was a publish rather than a draft.
A field is not translated. It has no localized: true on it or on a parent, or it is not a text, textarea, or richText field.
The connection shows "Couldn't reach this Payload instance". Check that the instance is up, the key is still valid, and any gateway headers still work. Update the connection in Settings -> Integrations.
