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,zh

What that command changes

Declares the locales. It inserts a localization block into your buildConfig({ ... }) call, labelled in each language:

src/payload.config.ts
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:

src/blocks/HeroBasic/config.ts
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 localize

Choosing locales

FlagEffect
--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-fallbackWrite fallback: false, so an untranslated locale renders empty
--dry-runPrint the whole plan and change nothing
--forceReplace 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.ts

What 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 localized is left exactly as you wrote it.
  • Fields marked global and 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, and checkbox fields 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 --localized

A whole template takes the same flag, so a multi-block site does not need a second pass:

npx payload-components add-template saas-launch --localized

Those 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:

src/app/[locale]/[slug]/page.tsx
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.

src/app/(frontend)/[locale]/layout.tsx
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/translations
src/payload.config.ts
import { 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-change

That 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.