Internationalization
Ship a multilingual Payload site — declare your locales, mark installed block text localized, and query a locale from the front end.
A multilingual Payload site needs two things to agree, and either one alone does nothing:
Locales, in the config
localization in payload.config.ts is what makes a locale exist. Without it, a
localized field is ignored outright.
Localized fields, in the blocks
localized: true is what makes a field store one value per locale. Without it, every
locale reads the same copy.
payload-components localize sets both, for the blocks you already installed:
npx payload-components localize --locales en,zhWhat that command changes
Declares the locales. It inserts a localization block into your
buildConfig({ ... }) call, labelled in each language:
export default buildConfig({
localization: {
defaultLocale: 'en',
fallback: true,
locales: [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '简体中文' },
],
},
// your config, untouched
})Applies the installed blocks' semantic field policies. It copies
src/blocks/shared/localizeFields.ts and wraps each installed block config's field
list in it — the same transform add --localized applies:
export const HeroBasic: Block = {
fields: localizeFields([...heroFields /* variant-specific fields */]),
}Records the choice in .payload-components/state.json, so update re-applies the
wrapper instead of reinstalling a config without it, and diff reads clean.
Every step is idempotent, so re-run it after installing more blocks. With the locales already declared, drop the flag and it only wraps what is new:
npx payload-components add pricing-cards
npx payload-components localizeChoosing locales
| Flag | Effect |
|---|---|
--locales <codes> | Comma-separated language tags — en,zh, en,zh-TW,ja, en,pt-BR,es-MX |
--default-locale <code> | The locale Payload treats as canonical; defaults to the first --locales |
--no-fallback | Write fallback: false, so an untranslated locale renders empty |
--dry-run | Print the whole plan and change nothing |
--force | Replace an existing localization block, and wrap locally edited configs |
Codes come with a native label where one is known — zh is written as 简体中文, not
"Chinese (Simplified)", because that is what an editor scans the locale switcher for.
Right-to-left scripts get rtl: true. A code with no catalog label uses the code itself
and the command tells you, so you can name it yourself in one edit:
npx payload-components localize --locales en,gsw
# no catalog label for gsw — the code is used as the label; edit it in src/payload.config.tsWhat gets marked localized
Every shipped text, textarea, and richText field declares an explicit
custom.payloadComponents.localization policy. localizeFields marks only fields whose
policy is localized, and recurses through the containers those fields live in. Storage
type is not treated as meaning: a heading and a URL may both be text, but the URL stays
global. Because the wrap sits outside the shared family base's spread, one call covers
shared and variant fields together.
Three deliberate limits:
- Containers are never marked. Payload rejects a localized field nested inside a localized parent, so localizing only leaves keeps every combination valid.
- A field that already declares
localizedis left exactly as you wrote it. - Fields marked
globaland unmarked fields stay single-value. The shipped policies keep URLs, form actions, prices, metrics, contact values, and identifiers global. Fields from consumer helpers are unmarked by default, which is deliberately conservative. select,upload,relationship,number, andcheckboxfields also stay single-value. Per-locale media or targets are a content-modelling decision — set those by hand.
Installing a block already localized
A block installed after the locales are declared can be wrapped in the same step:
npx payload-components add hero-basic --localizedA whole template takes the same flag, so a multi-block site does not need a second pass:
npx payload-components add-template saas-launch --localizedThose are the per-install forms of the same transform. localize is the project-wide form,
and it is the only one that touches your Payload config.
Reading a locale from the front end
Payload returns one locale per query. Pass the code:
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'pages',
locale: 'zh',
where: { slug: { equals: params.slug } },
})With fallback: true (the default this command writes), a field with no Chinese value
falls back to the default locale rather than rendering blank — so a half-translated page
still renders. locale: 'all' returns every locale at once, which is what you want for a
language switcher that has to know which translations exist.
Right-to-left languages
Arabic, Hebrew, Persian, and Urdu are written right to left, and localize marks them
rtl: true in the config it writes:
{ code: 'ar', label: 'العربية', rtl: true }That flag mirrors the admin. The front end needs one more thing from you, because no
block can know the active locale: set dir in your root layout.
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
export default async function RootLayout({ children, params }) {
const { locale } = await params
return (
<html lang={locale} dir={RTL_LOCALES.has(locale) ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
)
}Every installed block then mirrors on its own. They express reading-order geometry with
CSS logical properties — ps-*/pe-*, border-s/border-e, rounded-s-*,
text-start/text-end — which follow dir automatically. Their physical counterparts
(pl-*, border-l, text-left) do not, which is why a block written with those would
render left-aligned inside a mirrored page. tests/int/visual-standards.int.spec.ts
fails the build if one creeps back in.
Purely geometric positioning is left physical on purpose. left-1/2 paired with
-translate-x-1/2 centres an element; the logical start-1/2 would not, because the
transform does not mirror with it.
Nothing in the install can set dir for you — it lives in your layout, above every
block. Without it the page stays left-to-right and the mirroring never applies.
Translating the admin UI
Content locales and the admin interface language are separate settings. The locales above
decide what an editor can write; i18n decides what language the admin chrome is in:
pnpm add @payloadcms/translationsimport { en } from '@payloadcms/translations/languages/en'
import { zh } from '@payloadcms/translations/languages/zh'
export default buildConfig({
i18n: {
fallbackLanguage: 'en',
supportedLanguages: { en, zh },
},
})localize does not install the package or write this config — which admin languages to ship
is your call. Check @payloadcms/translations/languages/ for the codes Payload ships; a
content locale with no admin translation is fine, the chrome just stays in the fallback
language.
Adopting it on a populated database
Turning on localization changes how Payload stores the affected fields. Payload does not
backfill existing values. Back up the database and explicitly migrate them into the default
locale before applying the schema change, and run --dry-run first.
Localized installs created before the semantic policy was introduced used field-type
inference, so some operational text values may already have per-locale storage. Updates
refuse to change that schema silently. Migrate those values to their global field first,
then opt in:
npx payload-components update --accept-localization-policy-changeThat flag changes installed source and records policy version semantic-v1; it never
opens or migrates your database. The update replaces the known legacy shared helper and
reconciles component files even when their manifest version is unchanged, then regenerates
types and the admin import map. Because every localized block imports the same helper,
all remaining legacy owners must be updated together. A targeted update that leaves another
legacy owner behind is refused before files change.
A helper with local edits or unknown contents is preserved, including with --force.
Reconcile your edits with the current localizeFields.ts source before retrying; older
installers did not record helper ownership, so the CLI cannot safely infer your intent.
payload-components doctor reports both halves once you have them, and names whichever
one is missing:
[ok] localization: 2 locales — en (English), zh (简体中文), default en
[warn] localization: hero-basic marks its text localized, but src/payload.config.ts
declares no locales — run "payload-components localize --locales en,zh"After localize or separate schema edits, run npx payload generate:types so
@/payload-types reflects the localized fields. update already runs type and import-map
generation. Restart the dev server afterwards — the locale selector appears in the
document toolbar in the admin.