# Multi-Locale Support with Next.js

> Source: https://agilitycms.com/docs/nextjs/multi-locale-support-with-next-js

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](https://github.com/agility/agilitycms-nextjs-starter).

> **Coming from the Pages Router?** Next.js used to ship built-in i18n routing — the `i18n` key in `next.config.js`, with `locale` and `defaultLocale` handed to `getStaticProps`. **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).

## The shape

```
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]/`.

## 1. Configure your locales

```bash
# .env.local — comma separated, NO spaces. The FIRST one is the default.
AGILITY_LOCALES=en-us,fr
AGILITY_SITEMAP=website
```

```ts
// 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.

## 2. Rewrite unprefixed URLs in the proxy

```ts
// 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 bare `api` also excludes `/api-reference`, `/apiary`, and anything else merely *starting* with those letters. Exact filenames like `favicon\.ico` must **not** get a slash.

## 3. Prerender every locale

```tsx
// 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 own `generateStaticParams` returning `locales.map(locale => ({ locale }))`. Re-exporting the catch-all's `default` does **not** re-export its `generateStaticParams`, so without this `params` is runtime data on that route and `/fr` can't be prerendered.

## 4. Pass the locale into every read

Every Agility call takes the locale as `languageCode`:

```tsx
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](/docs/nextjs/caching-with-next-js-and-agility) (`agility-content-{id}-{locale}`), publishing the French version of an item invalidates only the French pages.

## 5. Set `<html lang>` correctly

The root layout renders a static `lang`, so correct it in the locale layout:

```tsx
// 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}
    </>
  )
}
```

## 6. Build a language switcher that lands on the *same* page

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):

```ts
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.

## 7. Add hreflang

Emit alternates so search engines connect the translations:

```tsx
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)}`])
      ),
    },
  }
}
```

## Summary

1. `AGILITY_LOCALES`, first entry is the default.
2. A `[locale]` segment, with the proxy **rewriting** unprefixed URLs so the default locale keeps clean URLs.
3. `generateStaticParams` loops every locale — and the locale root route needs its own.
4. Pass `languageCode` into every read; cache tags are per-locale, so invalidation is per-locale.
5. A sitemap-driven language switcher, plus `hreflang` alternates.
