AI Automation
AI Translation in Payload: The Publish-Hook Pattern We Ship (and the Locale We Refuse to Automate)
Most Payload teams wire Claude into localized fields, ship a flood of machine-translated content, then quietly roll it back. Here is the narrower hook shape we ship instead — locked drafts, editor sign-off, token math, and the one locale we never let a model touch.

*Bulk machine-translating an archive on day one is the cleanest way we know to lose editorial trust by week six.*
Every Payload project past four locales eventually hits the same conversation. The Head of Content wants AI translation wired into the publish flow. The CTO wants to know what it costs in tokens and what it breaks in editorial trust. The CFO wants to kill the Lokalise or Crowdin line item. All three are right, and all three are usually about to make the same mistake: bulk-translate the archive on day one, push it live across every locale, and discover six weeks later that the German legal page now says something a lawyer would not sign.
We have shipped this integration on enough Payload + Next.js builds now to have a default shape. It is narrower than what most teams reach for. Claude drafts into a locked pending field, never into the published locale. An editor reviews the diff inside the Payload admin and clicks accept. One locale — usually the regulated market or the one where the senior editor lives — never sees a model at all. The token bill lands somewhere between $40 and $180 a month for a 30–50 article-per-month publication across eight locales, which is well under what the SaaS line item it replaces costs.
This is the hook shape, the prompt, the editor UX, and the boundary we draw on every build. It is also the failure modes that pushed us to the current pattern.
The pattern we keep seeing fail
The seductive version of this build is one weekend long. Loop over every article, call Claude on each localized field, write the result straight into the locale's published value, ship. We have watched three teams do this in the last 18 months. Two rolled it back inside two months. The third is still live but the editorial team no longer trusts any locale they cannot read, which means a senior editor now reviews every article in every locale anyway — they just do it after publication, in panic mode, with the public URL already indexed.
The failures cluster. Glossary drift — the product is called "refill" in the source and the model alternates between "recharge", "refill", and "cartridge" across articles. Slavic pluralization that the model handles 90% of the time, which sounds great until you remember the 10% is grammatically wrong in a way every native speaker will spot. And the worst one: the source article gets edited after translation, the locales drift silently, and nobody notices for a month.
Where AI translation actually pays back on a Payload stack
The breakeven is locale count, not article count. At two locales, a freelance translator on retainer is faster, cheaper, and produces copy you can put on a billboard. At four, the coordination overhead of human-only translation starts to dominate — the editor spends more time chasing translators than writing. At six to eight, the economics flip hard. This is the band where the Payload + Claude pattern earns its keep: enough locales that human-only does not scale, few enough articles that human review per locale is still tractable.
2–3 locales · keep it human · the savings do not cover the review overhead
4–5 locales · hybrid, AI drafts for blog/marketing, humans for product and legal
6–12 locales · full AI-draft pipeline with editor sign-off per locale
12+ locales · same pipeline plus per-locale glossaries and a translation memory layer
The locked-draft model
Payload's localized fields are the obvious place to put translated content, and you should use them — but not as the write target for the model. We add a sibling group, `pendingTranslations`, that mirrors the localized fields and holds model output until an editor accepts. The published locale is updated only by a human action in the admin. This is the entire trust mechanism.
import type { CollectionConfig } from 'payload'
export const Articles: CollectionConfig = {
slug: 'articles',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'body', type: 'richText', localized: true },
{ name: 'seoDescription', type: 'textarea', localized: true },
{
name: 'pendingTranslations',
type: 'json',
admin: {
components: {
Field: '@/components/payload/PendingTranslationsField',
},
description: 'Model-drafted translations awaiting editor review.',
},
},
{
name: 'translationStatus',
type: 'select',
hasMany: true,
options: ['de:pending', 'de:accepted', 'fr:pending', 'fr:accepted', 'hr:locked'],
defaultValue: [],
},
],
}Two things to notice. `pendingTranslations` is a single `json` field, not a localized field — it holds drafts for every target locale in one structured blob, keyed by locale code. And `translationStatus` carries a `:locked` suffix for any locale we explicitly never automate. The hook checks this list before queueing anything.
The hook shape
The translation job fires from an `afterChange` hook on the source locale only. We do not run translations inline — the hook enqueues a job and returns. On a publication with 8 locales and Claude Sonnet at ~12s per locale for a 1,500-word article, inline would block the editor's save for 90+ seconds. Queued, the editor sees "translations drafting" in the admin and gets back to writing.
import type { CollectionAfterChangeHook } from 'payload'
import { enqueueTranslationJob } from '@/lib/translation/queue'
const SOURCE_LOCALE = 'en'
const TARGET_LOCALES = ['de', 'fr', 'es', 'it', 'nl', 'pl', 'hr'] as const
export const draftTranslationsOnPublish: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
if (req.locale !== SOURCE_LOCALE) return doc
if (operation === 'create' && doc._status !== 'published') return doc
const sourceChanged =
doc.title !== previousDoc?.title ||
JSON.stringify(doc.body) !== JSON.stringify(previousDoc?.body)
if (!sourceChanged) return doc
const locked = new Set(
(doc.translationStatus ?? [])
.filter((s: string) => s.endsWith(':locked'))
.map((s: string) => s.split(':')[0]),
)
const targets = TARGET_LOCALES.filter((l) => !locked.has(l))
await enqueueTranslationJob({
articleId: doc.id,
sourceLocale: SOURCE_LOCALE,
targets,
payload: { title: doc.title, body: doc.body, seoDescription: doc.seoDescription },
})
return doc
}The job worker calls Claude per target locale, writes the structured result back through the Payload Local API, and updates `translationStatus` to `<locale>:pending`. The editor sees a new badge in the admin row. Nothing is published.
Why we force JSON output, not freeform
Claude can translate a Lexical document as freeform markdown and you can parse it back. Do not do this. We have spent enough hours debugging mismatched heading levels and dropped link nodes to be religious about it. Use structured outputs with a schema that mirrors the field shape, and validate the response with Zod before it touches Payload.
import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod'
const TranslationSchema = z.object({
title: z.string().min(1),
seoDescription: z.string().max(160),
bodyBlocks: z.array(
z.object({
type: z.enum(['paragraph', 'heading', 'list']),
text: z.string().optional(),
level: z.number().int().min(2).max(4).optional(),
items: z.array(z.string()).optional(),
}),
),
glossaryHits: z.array(z.object({ source: z.string(), used: z.string() })),
})
export async function translateArticle(input: {
source: { title: string; bodyBlocks: unknown; seoDescription: string }
targetLocale: string
glossary: Record<string, string>
brandVoice: string
}) {
const client = new Anthropic()
const res = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: `You translate editorial content into ${input.targetLocale}.
Brand voice: ${input.brandVoice}.
Glossary (always use the target term verbatim): ${JSON.stringify(input.glossary)}.
Return ONLY JSON matching the provided schema. Preserve block structure exactly.`,
messages: [{ role: 'user', content: JSON.stringify(input.source) }],
})
const text = res.content[0].type === 'text' ? res.content[0].text : ''
return TranslationSchema.parse(JSON.parse(text))
}The `glossaryHits` field is the cheap trick that solves glossary drift. We ask the model to report which glossary terms it used and how, then we cross-check against the source. If a term in the source was not hit, we either re-prompt or flag it for editor attention. This single field has caught more drift in production than any post-hoc QA pass we tried.
Token economics on a real shape
Here is the math we walk clients through. A typical editorial article is ~1,500 words of body plus title and SEO description — call it 2,000 input tokens and 2,400 output tokens per locale (output is longer than input in most target languages, especially German and Polish). At 40 articles per month across 8 target locales, that is 320 translation calls.
Claude Sonnet 4.5 at current pricing (~$3/M input, $15/M output) · roughly $0.04–0.06 per locale per article · monthly bill in the $15–25 range for 40×8
Claude Haiku for non-critical locales · roughly $0.005–0.01 per locale per article · monthly bill in the $3–6 range
Hybrid (Sonnet for tier-1 locales, Haiku for tier-2) · most common shape we ship · monthly bill in the $8–15 range
Add ~$30–50/month for review tooling (queue, retries, embeddings for glossary lookup) on a small Vercel + Postgres footprint
Compare to a Lokalise or Crowdin seat plan with API access at the volume above — typically $120–400/month depending on seats and source words. The AI pipeline pays for itself on month one. The cost we do not put on the invoice is editor review time, and that is the real number the Head of Content needs to defend — usually 6–10 minutes per article per locale for accept/edit/reject.
Editor UX inside the Payload admin
We ship a custom field component for `pendingTranslations` that renders a per-locale card with three things: a diff between the current published locale (if any) and the model draft, a confidence indicator pulled from `glossaryHits` coverage, and two buttons — accept (writes through Local API into the localized field, flips status to `:accepted`) and reject (clears the pending entry, logs the reason). No third button. No "edit and accept" inline — if the editor wants to edit, they accept and then edit the localized field directly. We tried the inline edit flow on two builds and both editors ended up rewriting from scratch in the real field anyway.
The accept handler, in full
import type { PayloadHandler } from 'payload'
export const acceptTranslation: PayloadHandler = async (req) => {
const { articleId, locale } = req.routeParams as { articleId: string; locale: string }
const article = await req.payload.findByID({
collection: 'articles',
id: articleId,
locale: 'en',
depth: 0,
})
const pending = (article.pendingTranslations as Record<string, any>)?.[locale]
if (!pending) return Response.json({ error: 'no-pending' }, { status: 404 })
await req.payload.update({
collection: 'articles',
id: articleId,
locale,
data: {
title: pending.title,
body: pending.bodyBlocks,
seoDescription: pending.seoDescription,
},
})
const nextPending = { ...(article.pendingTranslations as object) }
delete (nextPending as any)[locale]
const nextStatus = (article.translationStatus ?? [])
.filter((s: string) => !s.startsWith(`${locale}:`))
.concat(`${locale}:accepted`)
await req.payload.update({
collection: 'articles',
id: articleId,
locale: 'en',
data: { pendingTranslations: nextPending, translationStatus: nextStatus },
})
return Response.json({ ok: true })
}Two updates, one transaction in effect — first the locale write, then the cleanup on the source locale. We hit this on an early build where the cleanup ran before the locale write committed and a refresh showed the locale empty: the fix is the ordering above, and on the Postgres side we wrap both calls in a transaction via `req.payload.db.beginTransaction()` when running on the Postgres adapter.
The locale we refuse to automate
On every build, we identify at least one locale that never sees a model. It is not always obvious which one. The candidates:
The regulated locale · usually the one with consumer protection law on the line (pharma copy in DE, financial product copy in FR, anything in a market with active enforcement)
The senior editor's native locale · they will rewrite the model output anyway, so save the tokens and let them write fresh
The legal pages, regardless of locale · Terms, Privacy, and refund policy are never model-drafted, even in markets we otherwise automate fully
Tiny locales with high-trust audiences · a 5,000-reader newsletter in Croatian is not where you experiment
Failure modes from production
Three things break in the first three months on every build. Worth naming them so you can wire the guard before you hit the bug.
Source edited after translation accepted. Editor publishes the source, all 7 locales draft, editor accepts them. Two weeks later a typo fix lands on the source. The locales are now stale and nobody knows. Fix: store a `sourceHash` on each accepted locale, compare on every source `afterChange`, and surface a "stale" badge in the admin. Do not auto-redraft — that re-introduces the trust problem. Let the editor decide.
Pluralization in Slavic locales. Polish and Croatian have grammatical forms that depend on the count noun. Claude Sonnet handles them well most of the time, Haiku does not. We default Slavic locales to Sonnet even when the rest of the matrix is Haiku.
Lexical block drift. If your source body uses custom Lexical nodes (callout, code, embedded blocks), the model will occasionally collapse them into plain paragraphs. The schema validation catches this — make sure your Zod schema includes the custom node types and rejects unknown ones, then the worker either re-prompts with a sharper instruction or drops the locale into a "needs human" queue.
What this replaces on the invoice
Honest version: it does not fully replace Lokalise or Crowdin if you have a translation memory built up over years and a roster of freelance translators with logins. It replaces the API tier and the machine translation add-on. It does not replace human review — it just moves human review into Payload, where the editor already lives, and out of a second SaaS tab.
What goes away: the per-seat license, the source-word billing, the round-trip of exporting to a TMS and re-importing. What stays: an editor on every locale you care about, a glossary maintained by humans, and a senior reviewer on the locales where the stakes are highest. The integration is a tool, not a headcount replacement.
A 2-week rollout plan
Days 1–3 · add `pendingTranslations` and `translationStatus` fields, ship the migration, no hook yet · editors keep working in their current flow
Days 4–6 · wire the `afterChange` hook and the worker, target ONE locale only, write to `pendingTranslations` only · no admin UI yet, inspect output in the JSON field
Days 7–9 · ship the custom field component with diff, confidence, accept/reject · still one locale, still no auto-publish
Days 10–12 · expand to remaining locales except the locked one · run a parallel review week where editors accept/reject and we log time-per-article
Days 13–14 · ship the `sourceHash` stale-detection, the glossary management collection, and the locked-locale enforcement · go live
Three checks before you let any locale auto-publish (we strongly recommend you do not, but if you must): the locale has had 30+ articles reviewed with a rejection rate under 10%, the glossary for that locale has been edited by a native speaker, and there is a rollback path that flips the locale back to pending-only with one toggle.
If you are evaluating Payload for a multi-locale editorial setup and want to see the patterns we default to — See how we ship Payload editorial platforms — we keep the receipts there.
If you are mid-build on a Payload + Claude translation flow or staring at a Lokalise renewal and wondering if this shape fits — Tell us what you are wiring up — send us the locale list and the article volume, we will tell you where the breakeven lands.
On every Payload build past four locales we now ship this pattern on week one — locked drafts, editor sign-off, one locale untouched by the model, glossary hits cross-checked on every call. It is not the fastest pipeline you can build. It is the one that survives the conversation with the editor six months in, which is the only test that matters.
// After the call
Questions operators ask next
Does this hook pattern work with Payload's Local API writes, or only when editors save in the admin?
Both. The `afterChange` hook fires on any write that goes through Payload's collection update path, including Local API calls. The only thing to watch is the `req.locale` check at the top — if you call Local API without specifying a locale, it defaults to the config default, which usually is what you want but is worth asserting in the hook explicitly.
How does the worker handle Claude API failures and retries without double-drafting a locale?
The job queue (we use a simple Postgres-backed queue or BullMQ depending on the project) keys jobs by `articleId:locale:sourceHash`. If a retry fires after a partial success, the hash check short-circuits and we do not redraft a locale that already has a pending entry for the same source version. Failed locales after 3 retries land in a `needs-human` queue surfaced in the admin.
Can we use this with Payload's draft system so translations draft against the latest unpublished source?
Yes, and we recommend it on editorial-heavy builds. Set the hook to fire on `_status === 'draft'` changes too and tag pending entries with a `sourceVersion` from Payload's draft/version system. Editors then see drafts of translations of drafts — which sounds confusing but is exactly what a multi-locale editorial team needs to ship a coordinated launch.
What does year-one TCO look like compared to keeping Lokalise or Crowdin?
For a publication at 30–50 articles/month across 6–10 locales, the AI pipeline runs roughly $200–400/year in token costs plus 2–3 weeks of build time up front. Lokalise/Crowdin API tiers at similar volume run $1,500–4,800/year before per-word machine translation add-ons. The build pays back in month two or three on most shapes we have shipped.
How do you handle a glossary that needs to differ per locale (e.g. "refill" maps to different terms in DE vs FR)?
We ship a `glossaries` collection in Payload with one entry per source term and a localized `target` field. The worker loads the glossary for the target locale only and injects it into the system prompt. Editors maintain it in the admin like any other content, which keeps the linguistic decisions where they belong — with the people who speak the language.
Will this pattern survive on Payload 3.x with the App Router and Server Components?
Yes — the hook signature and Local API surface are stable in Payload 3. The only thing to adjust is that the custom field component must be a client component (`'use client'`) because of the diff/accept interactivity, and the accept handler runs as a Payload custom endpoint, not a Next.js Route Handler, so it gets the authenticated `req.payload` instance for free.
Pull quote
Claude writes into a pending field, never into the published locale. The editor still owns the publish button — that is the whole trick.