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.
import { defineConfig } from "pzzadocs";
export default defineConfig({
title: "PzzaDocs",
baseUrl: "/docs",
defaultLocale: "en",
locales: ["de", "fr"],
localeStrategy: "suffix",
localePrefix: "as-needed",
});| Name | Type | Default | Description |
|---|---|---|---|
defaultLocale | string | - | Locale of the source content. With suffix, files without a suffix belong to it. With directory, it names the source directory. Required for directory. |
locales | string[] | [] | 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. |
translations | Record<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
guides
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.
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
guides
de
guides
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:
localePrefix | English (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]
docs
[[...slug]]
api/search
A small helper keeps the locale list and the validation in one place:
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.
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.
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.
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.
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.
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:
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:
import { resolveLabels } from "pzzadocs";
const labels = resolveLabels(config, locale);| Name | Type | Default | Description |
|---|---|---|---|
RootProvider.labels | Partial<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.labels | Partial<UiLabels> | - | Labels for the server-rendered header, currently the repository icon link. |
DocsPage.labels | Partial<UiLabels> | - | Labels for the server-rendered footer, currently the last updated line. |
DocsPage.locale | string | "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:
| Key | Placeholder | Filled 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.
| Key | Default | Where it appears |
|---|---|---|
search | Search | Accessible name of the header search trigger. |
searchShort | Search... | Visible text of the header search trigger. |
searchPlaceholder | Search documentation... | Placeholder of the search input. |
searchDialog | Search documentation | Accessible name of the search dialog. |
searchResults | Results | Accessible name of the result list. |
searchLoading | Loading... | Shown while the index is being fetched. |
searchNoResults | No results for "{query}" | Empty state of the search dialog. |
searchError | Could not load search index | Shown when the index request fails. |
onThisPage | On this page | Heading of the table of contents, desktop and popover. |
previous | Previous | Label of the previous page card in the pagination footer. |
next | Next | Label of the next page card in the pagination footer. |
pagination | Pagination | Accessible name of the pagination footer. |
breadcrumb | Breadcrumb | Accessible name of the breadcrumb trail. |
lastUpdated | Last updated | Prefix of the timestamp in the page footer. |
copyMarkdown | Copy Markdown | Main button of the page actions menu. |
copied | Copied | Button text right after the Markdown was copied. |
markdownCopied | Markdown copied to clipboard | Screen reader announcement after copying. |
viewOptions | View options | Accessible name of the page actions dropdown trigger. |
openInGitHub | Open in GitHub | Dropdown entry, shown when editUrl is set. |
viewAsMarkdown | View as Markdown | Dropdown entry linking to the Markdown route. |
openIn | Open in {name} | Dropdown entries that open the page in an assistant. |
copyCode | Copy code | Accessible name of the copy button on code blocks. |
openNavigation | Open navigation | Accessible name of the mobile menu button. |
closeNavigation | Close navigation | Accessible name of the mobile drawer close button. |
navigation | Navigation | Accessible name of the mobile drawer. |
switchToLight | Switch to light theme | Accessible name of the theme toggle while dark. |
switchToDark | Switch to dark theme | Accessible name of the theme toggle while light. |
repository | Repository | Accessible name of the GitHub icon link in the header. |
zoomImage | Zoom image | Accessible name of zoomable images. |
closeImage | Close image | Accessible name of the lightbox close button. |
dismiss | Dismiss | Accessible name of the Banner close button. |
selectLanguage | Select language | Accessible 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.
"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.
"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}` }))}
/>
);
}| Name | Type | Default | Description |
|---|---|---|---|
locales | LocaleOption[] | - | Entries with code, label, optional short trigger text, icon (for example a flag) and href. |
current | string | - | 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. |
className | string | - | 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.