---
title: "Payload CMS Localization with next-intl: Complete Guide"
slug: "payload-cms-localization-next-intl"
published: "2026-03-18"
updated: "2026-03-01"
validated: "2026-03-01"
categories:
  - "Payload"
tags:
  - "Payload CMS localization"
  - "next-intl"
  - "localized slugs"
  - "payload.find locale"
  - "generateStaticParams"
  - "fallbackLocale"
  - "getPayload"
  - "Next.js i18n"
  - "TypeScript Locale"
  - "localized content static generation"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "next-intl"
  - "next.js"
  - "typescript"
  - "payload local api"
status: "stable"
llm-purpose: "Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to next-intl"
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to Payload Local API"
llm-outputs:
  - "Completed outcome: Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…"
---

**Summary Triples**
- (Payload CMS, stores localized content, at the field level per configured locales)
- (next-intl, controls active locale for the request, via locale-prefixed routes (e.g., /en, /de, /sl))
- (Root cause, of wrong-language rendering, is failing to pass next-intl's active locale into payload.find queries)
- (Fix, requires passing the active locale to Payload, call payload.find / payload.findByID with the locale option (locale: activeLocale))
- (Localized slugs, must be generated per locale, by querying Payload with locale and building paths in generateStaticParams)
- (Static generation, should include all locales, use a per-locale generateStaticParams that queries Payload with locale to output localized routes)
- (Payload Local API, accepts a locale option, payload.find({ collection, where, locale }) to return localized fields)
- (Fallback behavior, should be handled explicitly, use a fallbackLocale or check for missing locale content and fall back to a default when necessary)

### {GOAL}
Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…

### {PREREQS}
- Access to Payload CMS
- Access to next-intl
- Access to Next.js
- Access to TypeScript
- Access to Payload Local API

### {STEPS}
1. Enable localization in Payload
2. Mark fields as localized
3. Read locale from next-intl routing
4. Pass locale to Payload queries
5. Fetch global data per locale
6. Generate localized static params
7. Type the locale in TypeScript
8. Handle drafts and status per locale

<!-- llm:goal="Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to next-intl" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Payload Local API" -->
<!-- llm:output="Completed outcome: Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…" -->

# Payload CMS Localization with next-intl: Complete Guide
> Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…
Matija Žiberna · 2026-03-18

When you're building multilingual sites for clients in different countries, you eventually run into the same problem I did: Payload CMS stores content in multiple languages, next-intl handles URL routing across those languages, but nothing tells them how to talk to each other.

I hit this on a multi-tenant agency project — three clients, each needing their site in Slovenian, German, and English. Payload's localization was storing the right content. next-intl was routing `/de`, `/sl`, and `/en` correctly. But pages were always rendering English regardless of the URL, because I was never passing the active locale to my Payload queries.

The fix is straightforward once you see it. This guide covers the complete bridge: configuring Payload for field-level localization, reading the locale from next-intl's routing, passing it to the Payload Local API, and generating static params for CMS-driven pages where slugs differ per language.

If you haven't set up next-intl routing yet, start with the [next-intl v4 setup guide](/blog/nextjs-internationalization-guide-next-intl-2025) first. And if you need the admin interface itself to speak your editor's language rather than just storing multilingual content, the [multilingual admin interface guide](/blog/payload-cms-multilingual-guide) covers that separately. This article is specifically about connecting Payload's localized content to what renders on the page.

## How the Two Systems Divide Responsibility

Before writing any code, it's worth being precise about what each tool actually does — because confusing them is exactly what causes the content/locale mismatch.

**Payload CMS localization** works at the field level. You mark individual fields with `localized: true` and Payload stores a separate value per locale in the database. When you query the API, you pass a `locale` parameter and get back content in that language. Payload has no knowledge of your URL structure.

**next-intl** works at the routing level. It reads the locale segment from the URL (`/de/services`), makes it available through the `[locale]` dynamic segment, and provides the translation API for static UI strings. next-intl has no knowledge of your CMS.

The bridge between them is a single line in every data-fetching function: take the `locale` value that next-intl resolves from the URL and pass it explicitly to `payload.find()` or `payload.findByID()`. That's the entire connection.

## Configuring Payload for Localization

Start in your Payload config. Enable localization at the top level with the same locale codes you're using in next-intl's routing configuration — consistency here prevents subtle bugs:

```typescript
// File: payload.config.ts
import { buildConfig } from 'payload'

export default buildConfig({
  // ... other config
  localization: {
    locales: [
      { label: 'English', code: 'en' },
      { label: 'Deutsch', code: 'de' },
      { label: 'Slovenščina', code: 'sl' },
    ],
    defaultLocale: 'en',
    fallback: true,
  },
})
```

The `fallback: true` setting is critical for agency work. When a client hasn't finished translating content for a locale, Payload falls back to `defaultLocale` on a field-by-field basis rather than returning empty strings. This gives you a safety net during content migration — a partially translated German page is far better than a broken one.

Now mark the fields that need localization on your collections:

```typescript
// File: src/collections/Services/index.ts
import type { CollectionConfig } from 'payload'

export const Services: CollectionConfig = {
  slug: 'services',
  fields: [
    {
      name: 'title',
      type: 'text',
      localized: true,
    },
    {
      name: 'description',
      type: 'richText',
      localized: true,
    },
    {
      name: 'slug',
      type: 'text',
      localized: true, // localized slugs for SEO — /leistungen vs /services
    },
    {
      name: 'price',
      type: 'number',
      // no localized: true — same value across all locales
    },
  ],
}
```

Localization is opt-in per field, not per collection. Prices, dates, images, and relationship fields usually stay unlocalized. Only the text content that editors actually translate gets `localized: true`. This keeps your database clean and your queries fast.

## The Core Bridge: Passing Locale to payload.find()

This is where the two systems connect. In any Next.js page under your `[locale]` segment, next-intl gives you the locale through route params. You take that value and pass it directly to the Payload Local API:

```typescript
// File: src/app/[locale]/services/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/routing'

export default async function ServicesPage({
  params,
}: {
  params: Promise<{ locale: Locale }>
}) {
  const { locale } = await params

  // Required for next-intl static rendering — must come first
  setRequestLocale(locale)

  const payload = await getPayload({ config })

  const services = await payload.find({
    collection: 'services',
    locale,           // ← the bridge: next-intl locale → Payload query
    fallbackLocale: 'en',
    where: {
      _status: { equals: 'published' },
    },
  })

  return (
    <div>
      {services.docs.map((service) => (
        <div key={service.id}>
          <h2>{service.title}</h2>
          <p>{service.description}</p>
        </div>
      ))}
    </div>
  )
}
```

The `locale` value flowing from `params` into `payload.find()` is the entire bridge. When a visitor hits `/de/services`, next-intl resolves `locale` as `'de'`, and Payload returns German content. Same page component, different query parameter, correct content every time.

Note that `fallbackLocale` in the Local API accepts any valid locale code, as well as `false`, `'none'`, `'null'`, or an array of locales for chained fallback. Setting it explicitly per-query gives you more control than relying solely on the global `fallback: true` in your config.

## Fetching Global Data Per Locale

Most agency sites have global collections — navigation, footer, site settings — that also need localization. The pattern is identical:

```typescript
// File: src/components/layout/Header.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Locale } from '@/i18n/routing'

async function getNavigation(locale: Locale) {
  const payload = await getPayload({ config })

  return payload.findGlobal({
    slug: 'navigation',
    locale,
    fallbackLocale: 'en',
    depth: 2,
  })
}

export default async function Header({ locale }: { locale: Locale }) {
  const navigation = await getNavigation(locale)

  return (
    <header>
      <nav>
        {navigation.links?.map((link) => (
          <a key={link.id} href={link.url}>
            {link.label}
          </a>
        ))}
      </nav>
    </header>
  )
}
```

Pass `locale` down from your `[locale]/layout.tsx` to any server component that fetches CMS data. The root locale layout is the right place to do this — it has access to the locale param and wraps every page in the internationalized section of your app.

```typescript
// File: src/app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing, type Locale } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import Header from '@/components/layout/Header'

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }))
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}) {
  const { locale } = await params

  if (!hasLocale(routing.locales, locale)) {
    notFound()
  }

  setRequestLocale(locale)

  return (
    <html lang={locale}>
      <body>
        <Header locale={locale as Locale} />
        {children}
      </body>
    </html>
  )
}
```

## generateStaticParams for CMS-Driven Pages with Localized Slugs

This is where things get more involved. Static pages like `/about` and `/contact` are straightforward — you know the slugs ahead of time. But CMS-driven pages like `/services/[slug]` require fetching every slug from Payload across every locale at build time.

Payload supports fetching all locales in a single query by passing `locale: 'all'`:

```typescript
// File: src/app/[locale]/services/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing, type Locale } from '@/i18n/routing'
import type { Service } from '@/payload-types'

export async function generateStaticParams() {
  const payload = await getPayload({ config })

  // locale: 'all' returns every locale's slug in one query
  // field values come back as objects keyed by locale: { en: 'services', de: 'leistungen', sl: 'storitve' }
  const services = await payload.find({
    collection: 'services',
    locale: 'all',
    limit: 1000,
    select: { slug: true },
  })

  const params: { locale: string; slug: string }[] = []

  for (const service of services.docs) {
    const slugField = service.slug as Record<string, string> | undefined

    if (!slugField) continue

    for (const locale of routing.locales) {
      const slug = slugField[locale]
      if (slug) {
        params.push({ locale, slug })
      }
    }
  }

  return params
}

export default async function ServicePage({
  params,
}: {
  params: Promise<{ locale: Locale; slug: string }>
}) {
  const { locale, slug } = await params

  setRequestLocale(locale)

  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'services',
    locale,
    fallbackLocale: 'en',
    where: {
      slug: { equals: slug },
      _status: { equals: 'published' },
    },
    limit: 1,
  })

  const service = result.docs[0]

  if (!service) {
    notFound()
  }

  return (
    <article>
      <h1>{service.title}</h1>
      <div>{/* render richText description */}</div>
    </article>
  )
}
```

When `locale: 'all'` is passed, Payload structures localized fields as objects keyed by locale rather than returning a single translated value. So a `slug` field that normally returns `'leistungen'` for German returns `{ en: 'services', de: 'leistungen', sl: 'storitve' }` when you query with `locale: 'all'`. The loop in `generateStaticParams` extracts every valid locale/slug combination from this structure.

## TypeScript: Typing the Locale Parameter

Since you're passing `locale` through multiple functions, worth typing it properly using the `Locale` type exported from your next-intl routing config. This catches mismatches at compile time rather than runtime:

```typescript
// File: src/lib/payload/services.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Locale } from '@/i18n/routing'
import type { Service } from '@/payload-types'

export async function getServices(locale: Locale): Promise<Service[]> {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'services',
    locale,
    fallbackLocale: 'en',
    where: {
      _status: { equals: 'published' },
    },
  })

  return result.docs
}

export async function getServiceBySlug(
  slug: string,
  locale: Locale,
): Promise<Service | null> {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'services',
    locale,
    fallbackLocale: 'en',
    where: {
      slug: { equals: slug },
      _status: { equals: 'published' },
    },
    limit: 1,
  })

  return result.docs[0] ?? null
}
```

Extracting your data fetching into a `/lib/payload/` layer keeps page components clean and makes the locale parameter explicit. Every function that touches the CMS takes `locale: Locale` as a parameter — there's no implicit locale reading, no global state, no surprises.

## A Note on Draft Preview with Locale

If you're using Payload's draft mode alongside localization, both parameters work on the same Local API methods. You can combine them without issues:

```typescript
const { isEnabled: isDraft } = await draftMode()

const page = await payload.findByID({
  collection: 'pages',
  id: pageId,
  locale,
  fallbackLocale: 'en',
  draft: isDraft,
})
```

One thing worth knowing: Payload supports localizing the `_status` field itself, meaning a page can be published in English but still in draft for German. This is an opt-in feature in Payload's localization config and useful for clients who publish language by language rather than all at once.

## What This Pattern Gives You

After setting this up across several agency projects, the architecture is clean in a way that matters when you're handing a site off to a client. Content editors work in Payload, switching locales in the admin panel and filling in translations field by field. The frontend never makes assumptions about which language to display — every data fetch explicitly receives its locale from the URL. Adding a new language means adding it to both the Payload config and the next-intl routing config, and the existing patterns work without modification.

The most common mistake I see in codebases that aren't working: fetching data at the layout level without locale, then wondering why nested pages show the wrong language. Keep it explicit. Pass `locale` as a parameter into every function that calls Payload, every time.

Let me know in the comments if you run into edge cases with this setup — particularly around localized slugs and static generation, which tends to have the most variation per project. Subscribe for more practical development guides.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Localization with next-intl: Complete Guide\" cover?",
      "answer": "Payload CMS localization: ensure multilingual Next.js pages render correct content by passing next-intl's locale into payload.find — learn localized…"
    }
  ]
}
```