Add a second locale to your site
By the end of this tutorial you'll have a running DocSite at http://localhost:5000 that serves three English pages at /, /about, and /getting-started, plus three Spanish translations at /es/, /es/about, and /es/getting-started. A LanguageSwitcher pill appears in the header and toggles between the two languages without any manual layout edits.
A single ConfigureLocalization action on DocSiteOptions is the toggle that enables multi-locale behavior. The default locale lives at the URL root; every other locale gets a folder prefix equal to its code. The LanguageSwitcher is already wired into DocSite chrome and stays hidden until a second locale is registered.
Prerequisites
- .NET 11 SDK installed
- Completed Scaffold a documentation site with DocSite (provides the single-locale DocSite host this tutorial extends)
- Completed Author a documentation page with DocFrontMatter (so the front-matter shape of each page is already familiar)
The finished code for this tutorial lives in examples/BeyondLocaleExample.
1. Start from a single-locale DocSite
Start with a plain DocSite host serving three English pages from Content/ — no localization, no switcher. A clear baseline makes the contrast obvious when localization arrives in section 2.
- 1
Confirm the English-only host
Here's the starting host. There is no
ConfigureLocalizationaction onDocSiteOptions. That meansLocalizationOptions.IsMultiLocaleis false,UseDocSite's built-inUsePenningtonLocaleRoutingis a no-op, and the built-inLanguageSwitcherinMainLayout.razorrenders nothing.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Locale", Description = "Adding a second locale to a Pennington DocSite.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Beyond Locale</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args);UseDocSite()already callsUsePenningtonLocaleRoutinginternally, so no direct call is needed at any point in this tutorial. - 2
Drop the three English pages into
Content/Place
index.md,about.md, andgetting-started.mddirectly underContent/. These are the default-locale pages — they own the URL root. No locale subfolder belongs here yet.--- title: Welcome description: A DocSite homepage teaching Pennington localization. order: 10 --- # Welcome This site is written in two languages. The English version you're reading lives under `Content/` — the default locale owns the URL root so its pages serve from `/`, `/about`, and `/getting-started`. Use the language switcher in the site header to jump to the Spanish version. Every URL on this site has an equivalent in each configured locale, and the `LanguageSwitcher` component in `MainLayout.razor` builds those links automatically from the current request path.--- title: About description: About this localized DocSite example. order: 20 --- # About This is a minimal DocSite that demonstrates **locale-aware URLs**. Every markdown file under `Content/` is the English (default) version. Every matching file under `Content/es/` is the Spanish translation. When a visitor navigates to `/es/about`, `LocaleDetectionMiddleware` strips the `/es` prefix, stores `"es"` in `LocaleContext`, and the DocSite's `ContentResolver` picks up the Spanish markdown from `Content/es/about.md`. If a Spanish file is missing, the resolver falls back to the English copy and marks the page as a translation-fallback so the reader knows.--- title: Getting Started description: Get started with the localized DocSite example. order: 30 --- # Getting Started To add a new locale to your own Pennington site: 1. Open `Program.cs` and call `loc.AddLocale(code, new LocaleInfo(displayName))` inside the `ConfigureLocalization` action on `DocSiteOptions`. 2. Create `Content/<code>/` and copy each page you want translated from the default-locale tree, translating the front matter `title:` and the body. 3. Run `dotnet run` — `LanguageSwitcher` appears in the site header as soon as `LocalizationOptions.Locales.Count > 1`. There is no other wiring. The default locale keeps its URLs unchanged; every additional locale gets a URL prefix equal to its code.
Checkpoint — Three English pages, no switcher
- Run
dotnet runfromexamples/BeyondLocaleExample - Visit
http://localhost:5000/,http://localhost:5000/about, andhttp://localhost:5000/getting-started— each English page renders - The DocSite header shows the site title and GitHub link but no language switcher pill — because only one locale is registered
2. Register a second locale with ConfigureLocalization
Now let's make the site aware of Spanish. A single ConfigureLocalization action on DocSiteOptions names "en" as the default and registers "es" as a second locale. That one change activates every piece of locale routing, link rewriting, and UI chrome downstream.
- 1
Add the
ConfigureLocalizationaction toDocSiteOptionsHere's the updated host:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Locale", Description = "Adding a second locale to a Pennington DocSite.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Beyond Locale</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", ConfigureLocalization = loc => { loc.DefaultLocale = "en"; loc.AddLocale("en", new LocaleInfo("English")); loc.AddLocale("es", new LocaleInfo("Español", HtmlLang: "es")); }, }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args);The new action has three pieces:
DefaultLocale = "en"— English owns the URL root with no prefix.AddLocale("en", new LocaleInfo("English"))— registers English with the display name the switcher shows.AddLocale("es", new LocaleInfo("Español", HtmlLang: "es"))— registers Spanish. TheHtmlLangvalue is what Pennington emits on the<html>element for that locale's pages.
AddLocaleis overloaded: the string-only form is shorthand when a custom display name orHtmlLangis not needed.Once
Localescontains more than one entry,LocalizationOptions.IsMultiLocaleflips totrue. That single boolean gates the switcher, the locale detection middleware, and the per-locale search index. - 2
Leave
UseDocSite()aloneThere's no need to add
app.UsePenningtonLocaleRouting()toProgram.cs.UseDocSitealready calls it internally. Now thatIsMultiLocaleis true, the middleware rewrites/es/<path>requests into<path>withPathBase = "/es"so Blazor routing sees the stripped path.
Checkpoint — The switcher appears, but Spanish URLs still 404
- Rebuild and run the site (or let hot reload pick up the change)
- Refresh
http://localhost:5000/— the DocSite header now shows a language switcher pill offering English and Español - Click Español — the URL becomes
http://localhost:5000/es/and you see a DocSite fallback notice explaining that Spanish content is missing, because noContent/es/files exist yet
3. Add translated markdown under Content/es/
Now let's give Spanish its content. Mirror the three English pages under a Content/es/ subfolder — same file names, same front-matter keys, translated body copy. The content resolver matches each Spanish URL to the corresponding Spanish file.
- 1
Create
Content/es/and translateindex.mdCreate the
Content/es/subfolder and addindex.mdwith Spanish front-matter and Spanish body copy. The load-bearing rule: the subfolder name matches the locale code passed toAddLocale—eshere, because that is the code registered in step 2.1. Files underContent/es/serve from/es/*; files directly underContent/serve from/*.--- title: Bienvenido description: Página de inicio de un DocSite que enseña la localización de Pennington. order: 10 --- # Bienvenido Este sitio está escrito en dos idiomas. La versión en español que estás leyendo ahora vive en `Content/es/` — cada idioma adicional tiene su propia subcarpeta en el árbol de contenido y un prefijo de URL igual a su código (`/es/`, `/es/about`, `/es/getting-started`). Usa el selector de idioma en la cabecera del sitio para volver al inglés. El componente `LanguageSwitcher` en `MainLayout.razor` construye los enlaces automáticamente a partir de la ruta de la solicitud actual. - 2
Translate
about.mdandgetting-started.mdRepeat the move for the two remaining pages. Each Spanish file keeps the same filename as its English sibling; URL routing derives the path from the filename, not from any front-matter key.
Skipping a translation is fine. The content resolver falls back to the default-locale copy and renders a
FallbackNoticebanner naming the requested and default locales.--- title: Acerca de description: Acerca de este ejemplo de DocSite localizado. order: 20 --- # Acerca de Este es un DocSite mínimo que demuestra **URLs conscientes del idioma**. Cada archivo markdown bajo `Content/` es la versión en inglés (el idioma predeterminado). Cada archivo correspondiente bajo `Content/es/` es la traducción al español. Cuando un visitante navega a `/es/about`, el middleware `LocaleDetectionMiddleware` elimina el prefijo `/es`, guarda `"es"` en `LocaleContext`, y el `ContentResolver` del DocSite busca el markdown en `Content/es/about.md`. Si falta un archivo en español, el resolvedor recurre a la copia en inglés y marca la página como una traducción de reserva para que el lector lo sepa.--- title: Primeros Pasos description: Primeros pasos con el ejemplo de DocSite localizado. order: 30 --- # Primeros Pasos Para añadir un nuevo idioma a tu propio sitio Pennington: 1. Abre `Program.cs` y llama a `loc.AddLocale(code, new LocaleInfo(displayName))` dentro de la acción `ConfigureLocalization` en `DocSiteOptions`. 2. Crea `Content/<code>/` y copia cada página que quieras traducir del árbol del idioma predeterminado, traduciendo el `title:` del front matter y el cuerpo. 3. Ejecuta `dotnet run` — `LanguageSwitcher` aparece en la cabecera del sitio tan pronto como `LocalizationOptions.Locales.Count > 1`. No hay más cableado. El idioma predeterminado mantiene sus URLs sin cambios; cada idioma adicional obtiene un prefijo de URL igual a su código.
Checkpoint — Spanish URLs serve Spanish content
- With the host still running, visit
http://localhost:5000/es/— the page renders in Spanish with no fallback banner - Visit
http://localhost:5000/es/aboutandhttp://localhost:5000/es/getting-started— both serve Spanish translations - Inspect the
<html>element in dev tools on a Spanish page —lang="es"(from theLocaleInfo.HtmlLangset in step 2.1)
4. Use the built-in LanguageSwitcher to move between locales
The LanguageSwitcher component is already baked into DocSite's MainLayout.razor. Now let's verify that it swaps locales in place by rewriting the current URL, landing on the same page in the other language rather than bouncing back to the home page.
- 1
Confirm the final host shape matches
Program.csHere's the final host — identical to what section 2 produced. This is a sanity-check step, not a new code change. Nothing in
UseDocSite()orRunDocSiteAsync(args)changes when a second locale is added.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Locale", Description = "Adding a second locale to a Pennington DocSite.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Beyond Locale</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", ConfigureLocalization = loc => { loc.DefaultLocale = "en"; loc.AddLocale("en", new LocaleInfo("English")); loc.AddLocale("es", new LocaleInfo("Español", HtmlLang: "es")); }, }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args); - 2
Click the switcher from
/es/aboutNavigate to
http://localhost:5000/es/about, open the language switcher in the header, and click English. The URL becomeshttp://localhost:5000/about. The switcher strips the/esprefix because English is the default locale and preserves the rest of the path, so the About page stays in view. That URL rewriting is the switcher's entire job — no client-side state, no cookies involved.
Checkpoint — Locale switching preserves the current page
- From
http://localhost:5000/es/about, click English — the URL becomeshttp://localhost:5000/about - From
http://localhost:5000/getting-started, click Español — the URL becomeshttp://localhost:5000/es/getting-started - From
http://localhost:5000/, click Español — the URL becomeshttp://localhost:5000/es/(the default locale's root maps to the secondary locale's prefix root)
Summary
- A single-locale DocSite becomes multi-locale by adding one
ConfigureLocalizationaction toDocSiteOptions— no explicit middleware call, no layout edits. - The default locale owns the URL root and every other locale gets a code prefix equal to the string passed to
AddLocale, with the matchingContent/<code>/subfolder providing the translations. - The
LanguageSwitcherappears automatically onceLocalizationOptions.IsMultiLocaleflips to true, and it rewrites the current URL in place rather than redirecting to the home page. - When a translation is missing, the content resolver falls back to the default-locale copy and renders a
FallbackNoticebanner naming the requested and default locales.