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:
1import { buildConfig } from "payload/config";23export default buildConfig({4 localization: {5 locales: ["en", "ja"],6 defaultLocale: "en",7 },8 // Your collections with localized fields9 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 consistent19 },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
andcontent
aslocalized: 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:
1// middleware.ts2import { NextResponse } from "next/server";3import type { NextRequest } from "next/server";4import { match } from "@formatjs/intl-localematcher";5import Negotiator from "negotiator";67const LOCALES = ["en", "ja"];8const DEFAULT_LOCALE = "en";910function getLocale(request: NextRequest): string {11 const negotiatorHeaders: Record<string, string> = {};12 request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));1314 // @ts-ignore locales are readonly15 const languages = new Negotiator({ headers: negotiatorHeaders }).languages();16 const locale = match(languages, LOCALES, DEFAULT_LOCALE);17 return locale;18}1920export function middleware(request: NextRequest) {21 const pathname = request.nextUrl.pathname;22 const pathnameIsMissingLocale = LOCALES.every(23 (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`24 );2526 // Redirect if there is no locale27 if (pathnameIsMissingLocale) {28 const locale = getLocale(request);2930 // For default locale (en), keep URLs clean without prefix31 if (locale === DEFAULT_LOCALE) {32 return NextResponse.rewrite(33 new URL(34 `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,35 request.url36 )37 );38 }3940 // For other locales, redirect to add the locale prefix41 return NextResponse.redirect(42 new URL(43 `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,44 request.url45 )46 );47 }48}4950export 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:
1app/2 [lang]/3 layout.tsx4 page.tsx5 about/6 page.tsx7 blog/8 page.tsx
Your root layout can now access the current locale:
1// app/[lang]/layout.tsx2export 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:
1// app/[lang]/page.tsx2import { getPayload } from "payload";34export 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 });1819 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:
1// app/dictionaries/en.json2{3 "common": {4 "readMore": "Read More",5 "back": "Back to Home"6 }7}89// app/dictionaries/ja.json10{11 "common": {12 "readMore": "続きを読む",13 "back": "ホームに戻る"14 }15}
Create a utility function to load the translations:
1// app/lib/dictionary.ts2import "server-only";34const dictionaries = {5 en: () => import("../dictionaries/en.json").then((module) => module.default),6 ja: () => import("../dictionaries/ja.json").then((module) => module.default),7};89export const getDictionary = async (locale: "en" | "ja") =>10 dictionaries[locale]();
Use the dictionary in your components:
1// app/[lang]/components/LocalizedUI.tsx2import { getDictionary } from "@/lib/dictionary";34export 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);1112 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:
1// app/[lang]/layout.tsx2export async function generateStaticParams() {3 return [{ lang: "en" }, { lang: "ja" }];4}56export 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:
1// components/LanguageSwitcher.tsx2"use client";34import Link from "next/link";5import { usePathname } from "next/navigation";67export function LanguageSwitcher() {8 const pathname = usePathname();910 // Remove the current locale from the pathname11 const pathnameWithoutLocale = pathname.replace(/^\/(?:en|ja)/, "") || "/";1213 return (14 <div>15 <Link href={pathnameWithoutLocale} locale={false}>16 English17 </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:
1import Link from "next/link";2import { useRouter } from "next/router";34export function LanguageSwitcher() {5 const router = useRouter();67 return (8 <div>9 <Link href={router.asPath} locale="en">10 English11 </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.