Bridger Tower LogoBridger Tower Logo
Bridger Tower

Design Generalist

Setting Up Localization with Payload

Are you building a multilingual website with Payload CMS and Next.js? This guide will walk you through creating a clean localization setup where English pages use clean URLs (like /about) while other languages use prefixed routes (like /ja/about).

The Localization Challenge

Building multilingual websites involves two key challenges:

  • Managing translated content efficiently
  • Creating a logical URL structure for different languages

Fortunately, Payload CMS 3 and Next.js 15 offer powerful localization features that work seamlessly together. Let's see how to implement them.

Configuring Payload CMS for Multiple Languages

First, you'll need to update your payload.config.ts file to support your desired languages:

import { buildConfig } from "payload";
export default buildConfig({
localization: {
locales: ["en", "ja"],
defaultLocale: "en",
},
// Your collections with localized fields
collections: [
{
slug: "pages",
fields: [
{
name: "slug",
type: "text",
required: true,
unique: true,
// Note: NOT localized to keep URLs consistent
},
{
name: "title",
type: "text",
localized: true,
},
{
name: "content",
type: "richText",
localized: true,
},
],
},
],
});

Key Points:

  • Locales array: List all languages you want to support
  • Default locale: Set English as the default
  • Field-level localization: Mark fields like title and content as localized: true
  • Keep slugs non-localized: This ensures consistent URLs across languages

Setting Up Next.js for International Routing

With Next.js App Router, internationalization is handled through middleware and dynamic route parameters. First, create a middleware file to handle language detection and routing:

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
const LOCALES = ["en", "ja"];
const DEFAULT_LOCALE = "en";
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// @ts-ignore locales are readonly
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
const locale = match(languages, LOCALES, DEFAULT_LOCALE);
return locale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const pathnameIsMissingLocale = LOCALES.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
// For default locale (en), keep URLs clean without prefix
if (locale === DEFAULT_LOCALE) {
return NextResponse.rewrite(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url
)
);
}
// For other locales, redirect to add the locale prefix
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url
)
);
}
}
export const config = {
matcher: [
// Skip all internal paths (_next)
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};

Then, structure your app directory to handle localized routes:

app/
[lang]/
layout.tsx
page.tsx
about/
page.tsx
blog/
page.tsx

Your root layout can now access the current locale:

// app/[lang]/layout.tsx
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ lang: string }>;
}>) {
const { lang } = await params;
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}

Fetching Localized Content

Using Payload CMS

In your pages, you can access the current language through the params prop:

// app/[lang]/page.tsx
import { getPayload } from "payload";
import config from "@payload-config";
export default async function Page({
params,
}: Readonly<{
params: Promise<{ lang: string }>;
}>) {
const lang = (await params).lang;
const payload = await getPayload({ config });
const {
docs: [data],
} = await payload.find({
collection: "pages",
where: { slug: "learn" },
locale: lang,
});
return (
<div>
<h1>{data.title}</h1>
<div>{data.content}</div>
</div>
);
}

Dictionary-based UI Translations

For UI elements that aren't managed by Payload CMS, you can use a dictionary-based approach:

// app/dictionaries/en.json
{
"common": {
"readMore": "Read More",
"back": "Back to Home"
}
}
// app/dictionaries/ja.json
{
"common": {
"readMore": "続きを読む",
"back": "ホームに戻る"
}
}

Create a utility function to load the translations:

// app/lib/dictionary.ts
import "server-only";
const dictionaries = {
en: () => import("../dictionaries/en.json").then((module) => module.default),
ja: () => import("../dictionaries/ja.json").then((module) => module.default),
};
export const getDictionary = async (locale: "en" | "ja") =>
dictionaries[locale]();

Use the dictionary in your components:

// app/[lang]/components/LocalizedUI.tsx
import { getDictionary } from "@/lib/dictionary";
export default async function LocalizedUI({
params,
}: Readonly<{
params: Promise<{ lang: "en" | "ja" }>;
}>) {
const lang = (await params).lang;
const dict = await getDictionary(lang);
return (
<div>
<a href="#">{dict.common.readMore}</a>
<a href="/">{dict.common.back}</a>
</div>
);
}

Static Generation

To generate static routes for all supported languages, use generateStaticParams in your root layout:

// app/[lang]/layout.tsx
export async function generateStaticParams() {
return [{ lang: "en" }, { lang: "ja" }];
}
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ lang: "en" | "ja" }>;
}>) {
return (
<html lang={(await params).lang}>
<body>{children}</body>
</html>
);
}

This will pre-generate pages for both English and Japanese at build time, improving performance.

Language Switching

Create a language switcher component that works with the App Router:

// components/LanguageSwitcher.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function LanguageSwitcher() {
const pathname = usePathname();
// Remove the current locale from the pathname
const pathnameWithoutLocale = pathname.replace(/^\/(?:en|ja)/, "") || "/";
return (
<div>
<Link href={pathnameWithoutLocale}>English</Link>
<Link href={`/ja${pathnameWithoutLocale}`}>日本語</Link>
</div>
);
}

Pro Tips for Better Localization

Automatic Fallbacks

Payload CMS has a handy feature: if a translation is missing for a specific field, it automatically falls back to the default language. This means you can gradually translate your site without worrying about missing content.

Language Switching

The language switcher component shown above uses usePathname from next/navigation to get the current path and construct links to the same page in different languages. Since the App Router doesn't have a built-in locale prop on Link, you manually construct the localized URLs.

Testing Your Setup

After implementation, test thoroughly:

  • Create content in different languages in Payload
  • Visit your routes (both default and localized)
  • Check that the correct content appears
  • Test language switching

Common Pitfalls to Avoid

  • Localizing slugs: Keep slugs non-localized to maintain URL consistency
  • Missing translations: Ensure critical content is translated in all languages
  • Hard-coded text: Move all UI text to localized fields

That's It!

Setting up localization with Payload CMS and Next.js gives you a powerful, flexible system for managing multilingual content. The clean URL structure (with English as the default route and language prefixes for others) provides an excellent user experience while keeping your content management straightforward.

By leveraging Payload's field-level localization and Next.js's routing capabilities, you can create a truly global website without the complexity that typically comes with multilingual setups.


Need more help? Check the official documentation for Payload CMS Localization and Next.js Internationalization.