What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Tutorials
Support localized content by language or region with Next.js and Agility. Covers the [locale] route segment, proxy rewrites for clean default-locale URLs, per-locale cache tags, and a sitemap-driven language switcher.
Agility stores every piece of content per locale, so a multilingual site is mostly a routing problem: work out which locale a request is for, then pass that locale into every content read.
This guide uses the App Router pattern from the Next.js internationalization guide: a [locale] route segment, with the proxy (proxy.ts, formerly middleware.ts) routing each request into it.
Starting from the Agility Next.js Starter? The starter is single-locale out of the box:
lib/cms/getAgilityContext.tsreturnslocale: "en-us"for every request, and there is no[locale]segment. The steps below show the changes to make. For a complete working implementation (clean default-locale URLs, a language switcher and hreflang), see the Advanced Next.js Demo Site, which this guide's patterns are taken from.
Coming from the Pages Router? The
i18nkey innext.config.jsis not supported in the App Router. Locale routing is now yours to own through a[locale]segment and the proxy.
app/
[locale]/
layout.tsx # root layout; sets <html lang>
page.tsx # locale root (e.g. /fr)
[...slug]/page.tsx # every CMS page
proxy.ts # rewrites unprefixed URLs into /[defaultLocale]/...
The default locale serves clean, unprefixed URLs (/about-us), and other locales are prefixed (/fr/about-us). Internally both route into app/[locale]/.
In the starter, that means moving app/layout.tsx, app/page.tsx and app/[...slug]/ into a new app/[locale]/ folder.
# .env.local: comma separated, NO spaces. The FIRST one is the default.
AGILITY_LOCALES=en-us,fr
AGILITY_SITEMAP=website
// lib/i18n/config.ts
export const locales = (process.env.AGILITY_LOCALES || "en-us").split(",")
export const defaultLocale = locales[0]
export const isValidLocale = (locale: string) => locales.includes(locale)
/** Build a locale-aware href. The default locale gets no prefix. */
export const localizeUrl = (path: string, locale: string) =>
locale === defaultLocale ? path : `/${locale}${path === "/" ? "" : path}`
Adding a language is then a matter of adding it to AGILITY_LOCALES and creating the content in Agility.
The starter resolves the locale in one place, getAgilityContext. Let it take the locale from the route instead of hard-coding it:
// lib/cms/getAgilityContext.ts
import { draftMode } from "next/headers"
import { agilityConfig } from "@agility/nextjs"
import { defaultLocale } from "lib/i18n/config"
export const getAgilityContext = async (locale?: string) => {
const { isEnabled } = await draftMode()
const isDevelopmentMode = process.env.NODE_ENV === "development"
const isPreview = isEnabled || isDevelopmentMode
return {
locales: agilityConfig.locales,
locale: locale || defaultLocale,
sitemap: agilityConfig.channelName,
isPreview,
isDevelopmentMode,
}
}
Then pass the route's locale param through getAgilityPage:
// lib/cms/getAgilityPage.ts
export interface PageProps {
params: Promise<{ locale: string; slug?: string[] }>
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>
}
export const getAgilityPage = async ({ params }: PageProps) => {
const awaitedParams = await params
const { isPreview: preview, locale } = await getAgilityContext(awaitedParams.locale)
if (!awaitedParams.slug) awaitedParams.slug = [""]
return getAgilityPageProps({
params: awaitedParams, preview, locale, apiOptions: { contentLinkDepth: 0 },
})
}
Add locale routing at the end of the starter's existing proxy.ts, after its preview and dynamic-page handling:
// proxy.ts
import { NextResponse, type NextRequest } from "next/server"
import { defaultLocale, locales } from "./lib/i18n/config"
export async function proxy(request: NextRequest) {
// ...the starter's existing preview and ContentID handling stays above this
// Unprefixed URLs belong to the default locale: REWRITE, not redirect,
// so the visitor keeps the clean URL
const { pathname } = request.nextUrl
const hasLocalePrefix = locales.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
)
if (!hasLocalePrefix) {
const url = request.nextUrl.clone()
url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`
return NextResponse.rewrite(url)
}
}
export const config = {
matcher: ["/", "/((?!api/|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt).*)"],
}
⚠️ Two matcher gotchas. The negative-lookahead pattern does not match the bare root
/, so list it explicitly or your home page skips the rewrite. And each directory exclusion needs a trailing slash: the lookahead is an unanchored prefix test, so a bareapialso excludes/api-reference,/apiary, and anything else merely starting with those letters. Exact filenames likefavicon\.icomust not get a slash.
The starter's generateStaticParams builds its own Agility client (request-only APIs such as draftMode() aren't available at build time). Loop it over your locales:
// app/[locale]/[...slug]/page.tsx
import agilitySDK from "@agility/content-fetch"
import { locales } from "lib/i18n/config"
import { SitemapNode } from "lib/types/SitemapNode"
export async function generateStaticParams() {
const isPreview = process.env.NODE_ENV === "development"
const agilityClient = agilitySDK.getApi({
guid: process.env.AGILITY_GUID,
apiKey: isPreview ? process.env.AGILITY_API_PREVIEW_KEY : process.env.AGILITY_API_FETCH_KEY,
isPreview,
})
const paths: { locale: string; slug: string[] }[] = []
for (const locale of locales) {
agilityClient.config.fetchConfig = {
next: { tags: [`agility-sitemap-flat-${locale}`], revalidate: 60 },
}
const sitemap: { [path: string]: SitemapNode } = await agilityClient.getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode: locale,
})
Object.values(sitemap)
.filter((node, index) => node.redirect === null && !node.isFolder && index !== 0)
.forEach((node) => paths.push({ locale, slug: node.path.split("/").slice(1) }))
}
return paths
}
The locale root route (app/[locale]/page.tsx) needs its own generateStaticParams. Re-exporting the catch-all's default does not bring its generateStaticParams along:
// app/[locale]/page.tsx
import { locales } from "lib/i18n/config"
export { generateMetadata } from "./[...slug]/page"
export { default } from "./[...slug]/page"
export const revalidate = 60
export const dynamic = "force-static"
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
Every Agility call takes the locale as languageCode. The starter's components already receive languageCode as a prop and pass it to getContentItem, so most of them work as-is. Look for anything that calls getAgilityContext() with no argument (in the starter, PostsListing.server.tsx does) and pass the locale in:
const PostListing = async ({ module, languageCode }: UnloadedModuleProps) => {
const { sitemap, locale } = await getAgilityContext(languageCode)
// ...
}
The starter tags its cached fetches with the locale (agility-content-{id}-{locale}, agility-sitemap-flat-{locale}), and its /api/revalidate webhook handler uses the languageCode from the publish event, so publishing the French version of an item invalidates only French data. See Caching with Next.js and Agility.
<html lang> in the locale layoutWith the starter's root layout moved to app/[locale]/layout.tsx, it receives the locale as a param and can set lang directly, as the Next.js guide recommends:
// app/[locale]/layout.tsx
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: routeLocale } = await params
const { locale, sitemap, isDevelopmentMode, isPreview } = await getAgilityContext(routeLocale)
// ...the starter's header and preview bar
return (
<html lang={locale.split("-")[0]}>
{/* ...the starter's <body> */}
</html>
)
}
Don't just swap the prefix: /fr/about-us may not exist if the French slug differs. Resolve the equivalent page through the sitemap instead, matching on pageID (and contentID for dynamic pages, whose pageID is shared across every item in the list):
import { getSitemapFlat } from "lib/cms/getSitemapFlat"
import { localizeUrl } from "lib/i18n/config"
import { SitemapNode } from "lib/types/SitemapNode"
export const resolveLocaleSwitchUrl = async ({ targetLocale, pageID, contentID }: {
targetLocale: string
pageID: number
contentID?: number
}) => {
const sitemap: { [path: string]: SitemapNode } = await getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode: targetLocale,
})
const match = Object.values(sitemap).find((node) =>
contentID ? node.pageID === pageID && node.contentID === contentID : node.pageID === pageID
)
return match ? localizeUrl(match.path, targetLocale) : null
}
Return null when there's no equivalent, and have the switcher fall back to the locale home page rather than a 404.
Emit alternates so search engines connect the translations:
export async function generateMetadata({ params }) {
const { locale } = await params
// ...resolve the current node, then:
return {
alternates: {
canonical: `${baseUrl}${localizeUrl(path, locale)}`,
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}${localizeUrl(path, l)}`])
),
},
}
}
AGILITY_LOCALES, first entry is the default.app/[locale]/ and let getAgilityContext take the locale from the route.generateStaticParams loops every locale, and the locale root route needs its own.languageCode into every read; cache tags are per-locale, so invalidation is per-locale.<html lang> in the locale layout, then add a sitemap-driven language switcher and hreflang alternates.