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 covers the App Router approach used by the Agility Next.js Starter.
Coming from the Pages Router? Next.js used to ship built-in i18n routing — the
i18nkey innext.config.js, withlocaleanddefaultLocalehanded togetStaticProps. That is not available in the App Router. You now own locale routing yourself, via a[locale]route segment plus a proxy rewrite. It's a few more lines, and it removes the constraints the built-in version imposed (locale detection, prefix strategy, and fallbacks are all yours).
app/
[locale]/
layout.tsx # site chrome; resolves locale once
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]/.
# .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)
export const getLocaleFromPathname = (pathname: string) =>
locales.find((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)) || null
/** Build a locale-aware href. The default locale gets no prefix. */
export const localizeUrl = (path: string, locale: string) =>
locale === defaultLocale ? path : `/${locale}${path}`
Adding a language is now a matter of adding it to AGILITY_LOCALES and creating the content in Agility.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server"
import { defaultLocale, locales } from "@/lib/i18n/config"
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocalePrefix = locales.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
)
const isStaticFile = pathname.includes(".") || pathname.startsWith("/_next")
if (!hasLocalePrefix && !isStaticFile) {
// REWRITE, not redirect — the visitor keeps the clean URL
return NextResponse.rewrite(new URL(`/${defaultLocale}${pathname}`, request.nextUrl.origin))
}
return NextResponse.next()
}
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
/— list it explicitly (matcher: ["/", "/((?!api/|…).*)"]) or your home page skips the rewrite and 404s. 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.
// app/[locale]/[...slug]/page.tsx
import { getSitemapFlat } from "@/lib/cms/getSitemapFlat"
import { locales } from "@/lib/i18n/config"
export async function generateStaticParams() {
const allPaths: { locale: string; slug: string[] }[] = []
for (const locale of locales) {
const sitemap = await getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode: locale,
preview: false,
})
allPaths.push(
...Object.values(sitemap)
.filter((node) => !node.isFolder && !node.redirect)
.map((node) => ({ locale, slug: node.path.split("/").filter(Boolean) }))
)
}
return allPaths
}
The locale root route (
app/[locale]/page.tsx) needs its owngenerateStaticParamsreturninglocales.map(locale => ({ locale })). Re-exporting the catch-all'sdefaultdoes not re-export itsgenerateStaticParams, so without thisparamsis runtime data on that route and/frcan't be prerendered.
Every Agility call takes the locale as languageCode:
export default async function Page({ params }) {
const { locale } = await params
const { isPreview } = await getAgilityContext(locale)
const content = await getContentItem({
contentID: module.contentid,
languageCode: locale,
preview: isPreview,
})
}
Because the locale is part of every cache tag (agility-content-{id}-{locale}), publishing the French version of an item invalidates only the French pages.
<html lang> correctlyThe root layout renders a static lang, so correct it in the locale layout:
// app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
const htmlLang = locale.split("-")[0] // "fr-ca" -> "fr"
return (
<>
<script
dangerouslySetInnerHTML={{
__html: `document.documentElement.lang=${JSON.stringify(htmlLang)}`,
}}
/>
{children}
</>
)
}
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):
export const resolveLocaleSwitchUrl = async ({ targetLocale, pageID, contentID }) => {
const sitemap = await getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode: targetLocale,
preview: false,
})
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.[locale] segment, with the proxy rewriting unprefixed URLs so the default locale keeps clean URLs.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.hreflang alternates.