- Payload CMS Localization with next-intl: Complete Guide
Payload CMS Localization with next-intl: Complete Guide
Connect Payload CMS localization to next-intl routing; pass locale to payload.find and support localized slugs

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryWhen 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 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 covers that separately. This article is specifically about connecting Payload's localized content to what renders on the page.
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.
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:
// 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:
// 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.
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:
// 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.
Most agency sites have global collections — navigation, footer, site settings — that also need localization. The pattern is identical:
// 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.
// 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>
)
}
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':
// 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.
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:
// 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.
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:
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.
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
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.