A Payload CMS 3 plugin for managing UI translations with automatic string collection and full static generation support
- Features
- Installation
- Quick Start
- Common Use Cases
- Configuration
- API Reference
- Development Tools
- TypeScript Support
- Performance
- Translation Updates & Caching
- Examples
- License
- Support
- β¨ Automatic Field Generation - CLI scanner finds all t()calls and generates field definitions
- π Dual Interpolation - Supports both ICU MessageFormat and sprintf-style variables
- π Familiar-Style - Familiar t('key', 'Context')API for easy adoption (if you used WPML or Polylang in the past)
- π― Type-Safe - Full TypeScript support with autocomplete
- β‘ Zero Runtime Overhead - All translations fetched at build time
- π SSG Compatible - Works with Next.js static generation
- π¦ Tiny Bundle - ~2KB gzipped
- π Missing Translation Detection - Automatically logs missing translations in dev
npm install payload-translations
# or
pnpm add payload-translations
# or
yarn add payload-translations// payload.config.ts
import { buildConfig } from 'payload'
import { translationsPlugin } from 'payload-translations'
export default buildConfig({
  // ... your config
  plugins: [
    translationsPlugin({
      // Define your translation fields (required)
      customFields: [
        {
          label: 'Navigation',
          fields: [
            { name: 'home', type: 'text', localized: true, required: true },
            { name: 'about', type: 'text', localized: true, required: true },
            { name: 'contact', type: 'text', localized: true, required: true },
          ],
        },
        {
          label: 'Authentication',
          fields: [
            { name: 'loginButton', type: 'text', localized: true, required: true },
            { name: 'logoutButton', type: 'text', localized: true, required: true },
            { name: 'forgotPassword', type: 'text', localized: true, required: true },
          ],
        },
      ],
    }),
  ],
})// app/[locale]/layout.tsx
import { TranslationsProvider } from 'payload-translations/react'
import { getTranslations } from 'payload-translations/server'
export default async function Layout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}) {
  const { locale } = await params
  const translations = await getTranslations(locale)
  return (
    <TranslationsProvider translations={translations} locale={locale}>
      {children}
    </TranslationsProvider>
  )
}Client Components:
'use client'
import { useTranslations } from 'payload-translations/react'
export function MyComponent() {
  const { translations, t, formatDate } = useTranslations()
  return (
    <div>
      <h1>{translations.home}</h1>
      <button>{t('Click me', 'MyComponent')}</button>
      <time>{formatDate(new Date(), 'long')}</time>
    </div>
  )
}Server Components (WPML-style - same as client!):
import { getTranslations } from 'payload-translations/server'
import config from '@/payload.config'
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params
  const { t } = await getTranslations(locale, config)
  return (
    <div>
      <h1>{t('Welcome to our site', 'HomePage')}</h1>
      <button>{t('Get Started', 'HomePage')}</button>
    </div>
  )
}Or use direct access if you prefer:
const { translations } = await getTranslations(locale, config)
return <h1>{translations.home}</h1> // Type-safeGo to /admin/globals/translations in your Payload admin panel and fill in translations for all locales.
ICU MessageFormat (Object):
// In your CMS, add field 'welcomeMessage' with value:
// "Welcome back, {name}!"
const { t } = useTranslations()
<h1>{t('welcomeMessage', { name: user.name })}</h1>
// Output: "Welcome back, John!"Sprintf Style (Array):
// In your CMS, add field 'welcomeMessage' with value:
// "Welcome back, %s!"
const { t } = useTranslations()
<h1>{t('welcomeMessage', [user.name])}</h1>
// Output: "Welcome back, John!"ICU MessageFormat (with automatic locale rules):
// In your CMS, add field 'cartItems' with value:
// "{count, plural, zero {No items} one {# item} other {# items}}"
const { t } = useTranslations()
<p>{t('cartItems', { count: 0 })}</p>  // "No items"
<p>{t('cartItems', { count: 1 })}</p>  // "1 item"
<p>{t('cartItems', { count: 5 })}</p>  // "5 items"Sprintf Style (simpler but manual):
// Store both singular and plural in CMS, choose manually
const { t } = useTranslations()
const count = 5
<p>{t(count === 1 ? 'item' : 'items', [count])}</p>  // "5 items"ICU MessageFormat:
// In your CMS, add field 'notification' with value:
// "{user} liked your {type}"
const { t } = useTranslations()
<p>{t('notification', { user: 'Sarah', type: 'post' })}</p>
// Output: "Sarah liked your post"Sprintf Style:
// In your CMS, add field 'notification' with value:
// "%s liked your %s"
const { t } = useTranslations()
<p>{t('notification', ['Sarah', 'post'])}</p>
// Output: "Sarah liked your post"The plugin is fully generic - you define all translation fields for your project:
translationsPlugin({
  customFields: [
    {
      label: 'Navigation',
      fields: [
        { name: 'home', type: 'text', localized: true, required: true },
        { name: 'about', type: 'text', localized: true, required: true },
      ],
    },
    {
      label: 'Forms',
      fields: [
        { name: 'submit', type: 'text', localized: true, required: true },
        { name: 'cancel', type: 'text', localized: true, required: true },
      ],
    },
  ],
})Organize fields into tabs for better admin UX.
{
  // Whether to enable the translations global (default: true)
  enabled?: boolean
  // The slug for the translations global (default: 'translations')
  slug?: string
  // Translation field tabs (required)
  // Each tab groups related translation fields
  customFields: Array<{
    label: string        // Tab label in admin
    fields: Field[]      // Payload field definitions
  }>
}Example:
translationsPlugin({
  customFields: [
    {
      label: 'UI Components',
      fields: [
        { name: 'loading', type: 'text', localized: true, required: true },
        { name: 'error', type: 'text', localized: true, required: true },
        { name: 'success', type: 'text', localized: true, required: true },
      ],
    },
  ],
})Server-side function to fetch translations for a specific locale.
import { getTranslations } from 'payload-translations/server'
import config from '@/payload.config'
const { t, translations, formatDate, formatNumber, formatCurrency, locale } =
  await getTranslations('en', config)
// WPML-style (recommended)
<button>{t('Submit', 'LoginForm')}</button>
// Direct access (type-safe)
<h1>{translations.home}</h1>Returns:
- t(key, context?)- WPML-style translation function
- translations- Raw translations object
- formatDate()- Locale-aware date formatting
- formatNumber()- Locale-aware number formatting
- formatCurrency()- Locale-aware currency formatting
- locale- Current locale string
React hook for accessing translations in client components.
const {
  translations, // Translation object
  locale, // Current locale
  t, // WPML-style helper function
  formatDate, // Locale-aware date formatting
  formatNumber, // Locale-aware number formatting
  formatCurrency, // Locale-aware currency formatting
} = useTranslations()WPML-style translation helper with dual interpolation support - use whichever style you prefer:
Modern, explicit approach with named placeholders:
const { t } = useTranslations()
// Simple variables
<p>{t('Welcome {name}', 'HomePage', { name: 'John' })}</p>
// Output: "Welcome John"
// Without context
<p>{t('Hello {username}', { username: 'Alice' })}</p>
// Output: "Hello Alice"
// Pluralization with automatic locale rules
<p>{t('{count, plural, one {# item} other {# items}}', 'Cart', { count: 1 })}</p>
// Output: "1 item"
<p>{t('{count, plural, one {# item} other {# items}}', 'Cart', { count: 5 })}</p>
// Output: "5 items"
// Complex pluralization
<p>{t('You have {count, plural, zero {no messages} one {# message} other {# messages}}', { count: 0 })}</p>
// Output: "You have no messages"Familiar WordPress-style positional arguments:
// Simple string substitution
<p>{t('Welcome %s', 'HomePage', ['John'])}</p>
// Output: "Welcome John"
// Without context
<p>{t('Hello %s', ['Alice'])}</p>
// Output: "Hello Alice"
// Multiple values
<p>{t('Hello %s, you have %d new messages', ['John', 5])}</p>
// Output: "Hello John, you have 5 new messages"
// Number formatting
<p>{t('Total: %d items at $%f each', [42, 19.99])}</p>
// Output: "Total: 42 items at $19.99 each"Format Specifiers:
- %s- String
- %d/- %i- Integer (rounds down)
- %f/- %u- Float/Number
The plugin automatically detects which style you're using:
- Pass an object { name: 'John' }β ICU MessageFormat
- Pass an array ['John']β Sprintf style
No configuration needed - just use whichever style feels natural!
- Simple variables: {variableName}
- Pluralization: {count, plural, zero {...} one {...} other {...}}
- Automatic plural rules: Uses Intl.PluralRulesfor locale-aware pluralization
Missing translations are logged in dev console:
π Missing Translations Detected
Copy-paste these fields into your translationFields array:
  {
    name: 'welcome',
    type: 'text',
    label: 'Welcome {name}',
    localized: true,
    // Used in: HomePage
  }
Simply copy the logged output and paste it into your translationFields array in your config!
Locale-aware date formatting:
const { formatDate } = useTranslations()
formatDate(new Date(), 'full') // "Monday, January 15, 2025"
formatDate(new Date(), 'long') // "January 15, 2025"
formatDate(new Date(), 'medium') // "Jan 15, 2025"
formatDate(new Date(), 'short') // "1/15/25"Locale-aware number formatting:
const { formatNumber } = useTranslations()
formatNumber(1234.56) // "1,234.56" (en) or "1.234,56" (nl)
formatNumber(0.1234, { style: 'percent' }) // "12.34%"Locale-aware currency formatting:
const { formatCurrency } = useTranslations()
formatCurrency(99.99, 'EUR') // "β¬99.99" (en) or "β¬ 99,99" (nl)
formatCurrency(99.99, 'USD') // "$99.99"The plugin includes a CLI scanner that finds all t() calls in your codebase and generates the field definitions for you:
npx payload-translations scan [pattern]Default pattern: src/**/*.{ts,tsx,js,jsx}
Example output:
π Scanning for translation calls...
π Found 14 unique translation calls:
  LoginForm: 1 translations
  HomePage: 2 translations
  Footer: 4 translations
π Copy these field definitions to your translation config:
  {
    type: 'collapsible',
    label: 'LoginForm',
    admin: { initCollapsed: true },
    fields: [
      {
        name: 'submit',
        type: 'text',
        label: 'Submit',
        localized: true,
      },
    ],
  },
Simply copy-paste the output into your translationFields array!
Usage examples:
# Scan and display fields (copy-paste required)
npx payload-translations scan
# Automatically append to your translation fields file
npx payload-translations scan --write
# Specify a custom file to append to
npx payload-translations scan --write src/my-translations.ts
# Scan specific directory and auto-write
npx payload-translations scan "components/**/*.tsx" --writeHow it works:
- Scans your code for t('key')andt('key', 'Context')calls
- Groups translations by context (component name)
- Converts keys to camelCase field names
- Generates ready-to-use Payload field definitions
- With --write: Automatically appends new fields to your file
- Automatically organizes fields into collapsible groups
Auto-detection of translation files:
When using --write without specifying a file, the CLI looks for:
- src/translations/fields.ts
- src/translations/fields.js
- src/translations/config.ts
- src/translations/config.js
- translations/fields.ts
- translations/fields.js
pnpm testThe plugin is fully typed. Your IDE will autocomplete translation keys and catch typos:
const { translations } = useTranslations()
translations.home // β
 Valid
translations.homer // β TypeScript errorTo generate types for custom fields:
pnpm payload generate:types- β‘ Zero runtime overhead - All translations fetched at build time
- π Fully static - Works with Next.js static generation
- π¦ Small bundle - ~2KB gzipped
- π― No hydration issues - Server and client stay in sync
By default, translations are fetched when pages are rendered. In production with Next.js static generation:
- Translations are fetched at build time
- Results are cached in the static HTML
- Changes in Payload admin require revalidation to appear
The plugin automatically revalidates all pages when translations change. This is enabled by default and requires zero configuration:
translationsPlugin({
  revalidateOnChange: true, // β Default! No setup needed
  customFields: [
    /* ... */
  ],
})How it works automatically:
When you update translations in the Payload admin, the plugin:
- Detects the change via an internal afterChangehook
- Calls revalidatePath('/', 'layout')to revalidate all pages
- Next.js regenerates pages with the new translations
- Changes appear immediately - no rebuild or app code changes required!
β¨ You don't need to add any hooks or code to your app - it just works!
If you prefer manual control or aren't using Next.js:
translationsPlugin({
  revalidateOnChange: false, // Disable auto-revalidation
  customFields: [
    /* ... */
  ],
})Time-Based ISR:
// In your page/layout
export const revalidate = 3600 // Revalidate every hourManual On-Demand Revalidation:
// Create a webhook endpoint
import { revalidatePath } from 'next/cache'
export async function POST() {
  revalidatePath('/', 'layout')
  return Response.json({ revalidated: true })
}Dynamic Rendering (always fresh):
// Force dynamic rendering for a specific page
export const dynamic = 'force-dynamic'See the README.md for comprehensive examples and advanced usage patterns.
MIT