Bridger Logo

Bridger Tower / Designer and Software Engineer

Setting Up Localization with Payload

Learn how to set up localization with Payload 3 and Next.js 15

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:

typescript
1import { buildConfig } from "payload/config";
2
3export default buildConfig({
4 localization: {
5 locales: ["en", "ja"],
6 defaultLocale: "en",
7 },
8 // Your collections with localized fields
9 collections: [
10 {
11 slug: "pages",
12 fields: [
13 {
14 name: "slug",
15 type: "text",
16 required: true,
17 unique: true,
18 // Note: NOT localized to keep URLs consistent
19 },
20 {
21 name: "title",
22 type: "text",
23 localized: true,
24 },
25 {
26 name: "content",
27 type: "richText",
28 localized: true,
29 },
30 ],
31 },
32 ],
33});

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:

typescript
1// middleware.ts
2import { NextResponse } from "next/server";
3import type { NextRequest } from "next/server";
4import { match } from "@formatjs/intl-localematcher";
5import Negotiator from "negotiator";
6
7const LOCALES = ["en", "ja"];
8const DEFAULT_LOCALE = "en";
9
10function getLocale(request: NextRequest): string {
11 const negotiatorHeaders: Record<string, string> = {};
12 request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
13
14 // @ts-ignore locales are readonly
15 const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
16 const locale = match(languages, LOCALES, DEFAULT_LOCALE);
17 return locale;
18}
19
20export function middleware(request: NextRequest) {
21 const pathname = request.nextUrl.pathname;
22 const pathnameIsMissingLocale = LOCALES.every(
23 (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
24 );
25
26 // Redirect if there is no locale
27 if (pathnameIsMissingLocale) {
28 const locale = getLocale(request);
29
30 // For default locale (en), keep URLs clean without prefix
31 if (locale === DEFAULT_LOCALE) {
32 return NextResponse.rewrite(
33 new URL(
34 `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
35 request.url
36 )
37 );
38 }
39
40 // For other locales, redirect to add the locale prefix
41 return NextResponse.redirect(
42 new URL(
43 `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
44 request.url
45 )
46 );
47 }
48}
49
50export const config = {
51 matcher: [
52 // Skip all internal paths (_next)
53 "/((?!api|_next/static|_next/image|favicon.ico).*)",
54 ],
55};

Then, structure your app directory to handle localized routes:

text
1app/
2 [lang]/
3 layout.tsx
4 page.tsx
5 about/
6 page.tsx
7 blog/
8 page.tsx

Your root layout can now access the current locale:

typescript
1// app/[lang]/layout.tsx
2export default function RootLayout({
3 children,
4 params: { lang },
5}: {
6 children: React.ReactNode;
7 params: { lang: string };
8}) {
9 return (
10 <html lang={lang}>
11 <body>{children}</body>
12 </html>
13 );
14}

Fetching Localized Content

Using Payload CMS

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

tsx
1// app/[lang]/page.tsx
2import { getPayload } from "payload";
3
4export default async function Page({
5 params,
6}: Readonly<{
7 params: Promise<{ lang: string }>;
8}>) {
9 const lang = (await params).lang;
10 const payload = await getPayload();
11 const {
12 docs: [data],
13 } = await payload.find({
14 collection: "pages",
15 where: { slug: "learn" },
16 locale: lang,
17 });
18
19 return (
20 <div>
21 <h1>{data.title}</h1>
22 <div>{data.content}</div>
23 </div>
24 );
25}

Dictionary-based UI Translations

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

typescript
1// app/dictionaries/en.json
2{
3 "common": {
4 "readMore": "Read More",
5 "back": "Back to Home"
6 }
7}
8
9// app/dictionaries/ja.json
10{
11 "common": {
12 "readMore": "続きを読む",
13 "back": "ホームに戻る"
14 }
15}

Create a utility function to load the translations:

typescript
1// app/lib/dictionary.ts
2import "server-only";
3
4const dictionaries = {
5 en: () => import("../dictionaries/en.json").then((module) => module.default),
6 ja: () => import("../dictionaries/ja.json").then((module) => module.default),
7};
8
9export const getDictionary = async (locale: "en" | "ja") =>
10 dictionaries[locale]();

Use the dictionary in your components:

typescript
1// app/[lang]/components/LocalizedUI.tsx
2import { getDictionary } from "@/lib/dictionary";
3
4export default async function LocalizedUI({
5 params,
6}: Readonly<{
7 params: Promise<{ lang: "en" | "ja" }>;
8}>) {
9 const lang = (await params).lang;
10 const dict = await getDictionary(lang);
11
12 return (
13 <div>
14 <a href="#">{dict.common.readMore}</a>
15 <a href="/">{dict.common.back}</a>
16 </div>
17 );
18}

Static Generation

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

typescript
1// app/[lang]/layout.tsx
2export async function generateStaticParams() {
3 return [{ lang: "en" }, { lang: "ja" }];
4}
5
6export default async function RootLayout({
7 children,
8 params,
9}: Readonly<{
10 children: React.ReactNode;
11 params: Promise<{ lang: "en" | "ja" }>;
12}>) {
13 return (
14 <html lang={(await params).lang}>
15 <body>{children}</body>
16 </html>
17 );
18}

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:

tsx
1// components/LanguageSwitcher.tsx
2"use client";
3
4import Link from "next/link";
5import { usePathname } from "next/navigation";
6
7export function LanguageSwitcher() {
8 const pathname = usePathname();
9
10 // Remove the current locale from the pathname
11 const pathnameWithoutLocale = pathname.replace(/^\/(?:en|ja)/, "") || "/";
12
13 return (
14 <div>
15 <Link href={pathnameWithoutLocale} locale={false}>
16 English
17 </Link>
18 <Link href={`/ja${pathnameWithoutLocale}`} locale={false}>
19 日本語
20 </Link>
21 </div>
22 );
23}

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

To add a language switcher, you can use Next.js's Link component with the locale prop:

tsx
1import Link from "next/link";
2import { useRouter } from "next/router";
3
4export function LanguageSwitcher() {
5 const router = useRouter();
6
7 return (
8 <div>
9 <Link href={router.asPath} locale="en">
10 English
11 </Link>
12 <Link href={router.asPath} locale="ja">
13 日本語
14 </Link>
15 </div>
16 );
17}

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.

© Bridger Tower, 2025