Localization

Translated content with the suffix or directory strategy, locale prefixes in URLs, locale-aware routes, and translated UI strings.

Localization has two independent halves. Content is the set of translated MDX files and meta.json manifests that the source reads per locale. UI strings are the labels the framework itself renders, such as "On this page" or "Search documentation", which you override per locale through translations. Both are configured in docs.config.ts and both fall back to the default: a page that is not translated yet is served from the default locale, and a label that is not translated yet is rendered in English.

docs.config.ts
import { defineConfig } from "pzzadocs";

export default defineConfig({
  title: "PzzaDocs",
  baseUrl: "/docs",
  defaultLocale: "en",
  locales: ["de", "fr"],
  localeStrategy: "suffix",
  localePrefix: "as-needed",
});
NameTypeDefaultDescription
defaultLocalestring-Locale of the source content. With suffix, files without a suffix belong to it. With directory, it names the source directory. Required for directory.
localesstring[][]Translated locales. Each may appear as a file suffix or as a directory under contentDir.
localeStrategy"suffix" | "directory""suffix"How translated files are laid out. See the two strategies below.
localePrefix"never" | "as-needed" | "always""never"Whether page URLs carry a locale segment before baseUrl.
translationsRecord<string, Partial<UiLabels>>-UI string overrides keyed by locale, merged over the English defaults.

Routing stays yours

PzzaDocs does not detect the visitor's language, set cookies, or redirect. It reads content per locale and builds URLs with the prefix you asked for; which locale a request maps to is decided by your Next.js routes. That makes it pair with any routing or i18n library, or with a plain [locale] segment and nothing else.

Content strategies#

Suffix#

The default. A translation sits next to its source file with the locale in the file name: setup.de.mdx beside setup.mdx, meta.de.json beside meta.json. Files without a suffix belong to defaultLocale.

content/docs
index.mdx
index.de.mdx
meta.json
meta.de.json
guides
meta.json
setup.mdx
setup.de.mdx
deploy.mdx

When the source scans for de, setup.de.mdx replaces setup.mdx and meta.de.json replaces meta.json in that folder. Files of other locales, such as setup.fr.mdx, are skipped. guides/deploy.mdx has no German variant, so the German tree serves the English file at the German URL.

ts
defaultLocale: "en",
locales: ["de", "fr"],
// localeStrategy defaults to "suffix"

Declare every locale

A suffix is only recognised when it is listed in locales. With locales: ["de"], a file named setup.fr.mdx is treated as a page with the slug setup.fr, not as a French translation.

Directory#

One directory per locale under contentDir. defaultLocale names the source directory, and the other directories hold translations with the same relative paths.

content/docs
en
index.mdx
meta.json
guides
meta.json
setup.mdx
deploy.mdx
de
index.mdx
meta.json
guides
setup.mdx
ts
defaultLocale: "en",
locales: ["de"],
localeStrategy: "directory",

The locale directory is an overlay, not a separate tree. The source walks the default locale's directory and, for every file and folder, prefers the file at the same relative path under the requested locale. de/guides/setup.mdx replaces en/guides/setup.mdx; de/guides/deploy.mdx does not exist, so the English page is served; de/guides/meta.json does not exist, so the English manifest orders the German folder. A file that exists only under de/ is served for German and nowhere else, so a locale can carry pages the source does not have.

The first path segment is the locale directory and never becomes part of the slug: content/docs/de/guides/setup.mdx is served at /docs/guides/setup (plus the locale prefix, see below), not at /docs/de/guides/setup.

defaultLocale is required

createSource throws when localeStrategy is "directory" and defaultLocale is missing, because it cannot know which directory holds the source content.

Fallback#

In both strategies a missing translation falls back to the default locale file, so a half-translated site still renders every page in every locale. The fallback page keeps its URL under the requested locale and reports locale as the requested locale on its PageData, so prev/next links, breadcrumbs, and the search index stay within one locale.

Passing defaultLocale to a source method is the same as omitting the locale: source.getPage(slug, "en") and source.getPage(slug) return the same page.

URL prefixes#

localePrefix decides whether the locale appears in page URLs. The source applies it to every url it produces: page data, the sidebar tree, neighbours, the search index, and the llms outputs. With baseUrl: "/docs", defaultLocale: "en", and locales: ["de"], the page guides/setup resolves to:

localePrefixEnglish (default)German
"never" (default)/docs/guides/setup/docs/guides/setup
"as-needed"/docs/guides/setup/de/docs/guides/setup
"always"/en/docs/guides/setup/de/docs/guides/setup

The locale segment goes before baseUrl, matching the app/[locale]/docs route shape in Next.js. When baseUrl is /, the URLs are /de and /de/guides/setup.

"never" is for sites that serve one locale per deployment or pick the locale from a domain, a cookie, or a header; the URLs are identical in every locale and your route decides what to render. "as-needed" keeps the default locale's URLs clean. "always" makes every URL explicit and requires defaultLocale; createSource throws without it.

Routes#

A [locale] segment above the docs routes gives every request a locale. Pass it to each source method and to the UI components. The example below uses localePrefix: "as-needed" and a plain segment; the default locale is served from the root routes as well, so both /docs/guides/setup and /de/docs/guides/setup resolve.

app
[locale]
layout.tsx
docs
layout.tsx
[[...slug]]
page.tsx
api/search
route.ts

A small helper keeps the locale list and the validation in one place:

lib/locales.ts
import config from "@/docs.config";

export const locales = [config.defaultLocale ?? "en", ...config.locales];

export function isLocale(value: string): boolean {
  return locales.includes(value);
}

Root layout#

RootProvider takes the labels for the locale and the search index URL for that locale. resolveLabels merges translations[locale] over the English defaults; see UI strings below.

app/[locale]/layout.tsx
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { resolveLabels } from "pzzadocs";
import { RootProvider } from "pzzadocs/ui";
import "pzzadocs/styles.css";

import config from "@/docs.config";
import { isLocale, locales } from "@/lib/locales";

interface LayoutProps {
  children: ReactNode;
  params: Promise<{ locale: string }>;
}

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function Layout({ children, params }: LayoutProps) {
  const { locale } = await params;
  if (!isLocale(locale)) {
    notFound();
  }

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <RootProvider
          theme={config.theme}
          search={{ ...config.search, indexUrl: `/api/search?locale=${locale}` }}
          labels={resolveLabels(config, locale)}
        >
          {children}
        </RootProvider>
      </body>
    </html>
  );
}

Docs layout#

getPageTree(locale) returns the sidebar for that locale with localized titles from meta.json and localized URLs. DocsLayout takes the same labels for the parts it renders on the server.

The static parts of the config are written for the default locale, so derive a copy per request: prefix nav.links[].href and activePrefix with the locale and set homeUrl (where the logo links) to the locale's home, for example /de.

app/[locale]/docs/layout.tsx
import type { ReactNode } from "react";
import { resolveLabels } from "pzzadocs";
import { DocsLayout } from "pzzadocs/ui";

import config from "@/docs.config";
import { source } from "@/lib/source";

interface LayoutProps {
  children: ReactNode;
  params: Promise<{ locale: string }>;
}

export default async function Layout({ children, params }: LayoutProps) {
  const { locale } = await params;
  const tree = await source.getPageTree(locale);

  return (
    <DocsLayout tree={tree} config={config} labels={resolveLabels(config, locale)}>
      {children}
    </DocsLayout>
  );
}

Page#

Every source call receives the locale, generateStaticParams enumerates every page of every locale, and DocsPage gets the locale for the "Last updated" date format and the labels for its footer.

app/[locale]/docs/[[...slug]]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { resolveLabels } from "pzzadocs";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "pzzadocs/ui";

import config from "@/docs.config";
import { locales } from "@/lib/locales";
import { source } from "@/lib/source";

interface PageProps {
  params: Promise<{ locale: string; slug?: string[] }>;
}

export async function generateStaticParams() {
  const params = await Promise.all(
    locales.map(async (locale) => {
      const pages = await source.getPages(locale);
      return pages.map((page) => ({ locale, slug: page.slug }));
    }),
  );
  return params.flat();
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale, slug = [] } = await params;
  const page = await source.getPage(slug, locale);
  if (!page) {
    return {};
  }
  return { title: page.title, description: page.description };
}

export default async function Page({ params }: PageProps) {
  const { locale, slug = [] } = await params;
  const page = await source.getPage(slug, locale);
  if (!page) {
    notFound();
  }

  const [tree, neighbours] = await Promise.all([source.getPageTree(locale), source.getNeighbours(slug, locale)]);

  return (
    <DocsPage
      toc={page.toc}
      tree={tree}
      url={page.url}
      neighbours={neighbours}
      full={page.full}
      lastModified={page.lastModified}
      editUrl={config.editUrl?.(page)}
      markdownUrl={config.llm.enabled ? config.llm.markdownPath(page.url) : undefined}
      locale={locale}
      labels={resolveLabels(config, locale)}
    >
      <DocsTitle>{page.title}</DocsTitle>
      <DocsDescription>{page.description}</DocsDescription>
      <DocsBody>{page.content}</DocsBody>
    </DocsPage>
  );
}

page.url already carries the locale prefix, so markdownPath(page.url) and the Markdown route under app/[locale] line up without extra work. The Markdown and llms routes take the locale the same way: source.getMarkdown(slug, locale), source.getLlmsTxt(locale), source.getLlmsFullTxt(locale).

Search index#

The search dialog fetches one index, so serve one per locale. The root layout above points indexUrl at /api/search?locale=de; the route reads the query parameter and builds the index for that locale.

app/api/search/route.ts
import { source } from "@/lib/source";
import { isLocale } from "@/lib/locales";

export async function GET(request: Request) {
  const locale = new URL(request.url).searchParams.get("locale") ?? undefined;
  const index = await source.getSearchIndex(locale && isLocale(locale) ? locale : undefined);
  return Response.json(index);
}

A route that reads the request can no longer be force-static. To keep static output, write one JSON file per locale at build time with getSearchIndex(locale) and set indexUrl to /search/${locale}.json.

Default locale at the root#

With localePrefix: "as-needed" the default locale's URLs have no segment, so /docs/guides/setup has to reach the same page as /en/docs/guides/setup. Rewrite root requests to the default locale in next.config.ts, or keep a second copy of the routes without the segment. With "always", redirect /docs and bare / to /en/docs instead. With "never", mount the routes once without a [locale] segment and choose the locale from whatever your routing library provides.

next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  async rewrites() {
    return [{ source: "/docs/:path*", destination: "/en/docs/:path*" }];
  },
};

export default config;

UI strings#

Every string the framework renders is a key of UiLabels. translations maps a locale to a partial set of overrides:

docs.config.ts
export default defineConfig({
  defaultLocale: "en",
  locales: ["de"],
  translations: {
    de: {
      searchShort: "Suchen...",
      searchPlaceholder: "Dokumentation durchsuchen...",
      searchNoResults: 'Keine Ergebnisse für "{query}"',
      onThisPage: "Auf dieser Seite",
      previous: "Zurück",
      next: "Weiter",
      lastUpdated: "Zuletzt aktualisiert",
      copyMarkdown: "Markdown kopieren",
    },
  },
});

resolveLabels(config, locale) returns the full UiLabels object for a locale: translations[locale] merged over DEFAULT_LABELS. When locale is omitted it uses defaultLocale, so you can translate the default locale too. Pass the result to the three components that render framework text:

ts
import { resolveLabels } from "pzzadocs";

const labels = resolveLabels(config, locale);
NameTypeDefaultDescription
RootProvider.labelsPartial<UiLabels>-Provides the labels to every client component: search trigger and dialog, TOC, breadcrumbs, pagination, page actions, mobile navigation, theme toggle, code copy buttons, image zoom, and the banner.
DocsLayout.labelsPartial<UiLabels>-Labels for the server-rendered header, currently the repository icon link.
DocsPage.labelsPartial<UiLabels>-Labels for the server-rendered footer, currently the last updated line.
DocsPage.localestring"en-US"BCP 47 tag passed to toLocaleDateString for the last updated date. Pass the route locale.

Client components read labels from RootProvider, so omitting labels on DocsLayout or DocsPage only leaves those two server-rendered strings in English. Omitting it everywhere renders the English defaults.

Placeholders#

Two labels contain a placeholder that the framework fills at render time with fillLabel:

KeyPlaceholderFilled with
searchNoResults{query}The text typed into the search dialog.
openIn{name}The assistant name in the page actions menu, for example "Claude" or "Cursor".

Keep the placeholder in your translation, in any position the language needs. fillLabel is exported for your own strings as well: fillLabel("Open in {name}", { name: "Cursor" }) returns Open in Cursor, and an unknown placeholder is left in place.

Label reference#

Every key of UiLabels with its English default and where the framework renders it.

KeyDefaultWhere it appears
searchSearchAccessible name of the header search trigger.
searchShortSearch...Visible text of the header search trigger.
searchPlaceholderSearch documentation...Placeholder of the search input.
searchDialogSearch documentationAccessible name of the search dialog.
searchResultsResultsAccessible name of the result list.
searchLoadingLoading...Shown while the index is being fetched.
searchNoResultsNo results for "{query}"Empty state of the search dialog.
searchErrorCould not load search indexShown when the index request fails.
onThisPageOn this pageHeading of the table of contents, desktop and popover.
previousPreviousLabel of the previous page card in the pagination footer.
nextNextLabel of the next page card in the pagination footer.
paginationPaginationAccessible name of the pagination footer.
breadcrumbBreadcrumbAccessible name of the breadcrumb trail.
lastUpdatedLast updatedPrefix of the timestamp in the page footer.
copyMarkdownCopy MarkdownMain button of the page actions menu.
copiedCopiedButton text right after the Markdown was copied.
markdownCopiedMarkdown copied to clipboardScreen reader announcement after copying.
viewOptionsView optionsAccessible name of the page actions dropdown trigger.
openInGitHubOpen in GitHubDropdown entry, shown when editUrl is set.
viewAsMarkdownView as MarkdownDropdown entry linking to the Markdown route.
openInOpen in {name}Dropdown entries that open the page in an assistant.
copyCodeCopy codeAccessible name of the copy button on code blocks.
openNavigationOpen navigationAccessible name of the mobile menu button.
closeNavigationClose navigationAccessible name of the mobile drawer close button.
navigationNavigationAccessible name of the mobile drawer.
switchToLightSwitch to light themeAccessible name of the theme toggle while dark.
switchToDarkSwitch to dark themeAccessible name of the theme toggle while light.
repositoryRepositoryAccessible name of the GitHub icon link in the header.
zoomImageZoom imageAccessible name of zoomable images.
closeImageClose imageAccessible name of the lightbox close button.
dismissDismissAccessible name of the Banner close button.
selectLanguageSelect languageAccessible name of the locale switcher trigger and menu.

Labels in custom components#

useLabels() from pzzadocs/ui returns the resolved UiLabels for the current locale inside any client component under RootProvider. Outside a provider it returns the English defaults, so a component works in isolation too.

components/search-button.tsx
"use client";

import { useLabels, useSearch } from "pzzadocs/ui";

export function SearchButton() {
  const labels = useLabels();
  const { setOpen } = useSearch();
  return (
    <button type="button" onClick={() => setOpen(true)}>
      {labels.searchShort}
    </button>
  );
}

Your own strings do not belong in UiLabels; keep them in whatever i18n setup the rest of the app uses. LabelsProvider is exported as well, for a subtree that needs a different set of labels than the one RootProvider received.

Locale switcher#

LocaleSwitcher from pzzadocs/ui is a header menu for changing the language, styled like the page actions menu. Each entry is a link when it has an href, otherwise a button that calls onSelect; use the link form when every locale has a URL you can compute, and the callback form with routing libraries that switch locales programmatically. Put it in the headerEnd slot of DocsLayout.

components/docs-locale-switcher.tsx
"use client";

import { LocaleSwitcher } from "pzzadocs/ui";
import { usePathname } from "next/navigation";

const LOCALES = [
  { code: "en", label: "English" },
  { code: "de", label: "Deutsch" },
  { code: "fr", label: "Français" },
];

export function DocsLocaleSwitcher({ current }: { current: string }) {
  const pathname = usePathname();
  // Strip the current prefix, then add the target one; the default locale has none.
  const base = current === "en" ? pathname : pathname.replace(`/${current}`, "");
  return (
    <LocaleSwitcher
      current={current}
      locales={LOCALES.map((locale) => ({ ...locale, href: locale.code === "en" ? base : `/${locale.code}${base}` }))}
    />
  );
}
NameTypeDefaultDescription
localesLocaleOption[]-Entries with code, label, optional short trigger text, icon (for example a flag) and href.
currentstring-Code of the active locale, marked with a check in the menu.
onSelect(code: string) => void-Called with the chosen code when the entry has no href, or in addition to navigating when it has one.
classNamestring-Added to the wrapper element.

The trigger shows the active entry's icon and short text (the upper-cased code by default). The menu supports arrow keys, Home, End and Escape, and its accessible name comes from the selectLanguage label.