This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

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

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. 1

    Confirm the English-only host

    Here's the starting host. There is no ConfigureLocalization action on DocSiteOptions. That means LocalizationOptions.IsMultiLocale is false, UseDocSite's built-in UsePenningtonLocaleRouting is a no-op, and the built-in LanguageSwitcher in MainLayout.razor renders 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 calls UsePenningtonLocaleRouting internally, so no direct call is needed at any point in this tutorial.

  2. 2

    Drop the three English pages into Content/

    Place index.md, about.md, and getting-started.md directly under Content/. 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 run from examples/BeyondLocaleExample
  • Visit http://localhost:5000/, http://localhost:5000/about, and http://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. 1

    Add the ConfigureLocalization action to DocSiteOptions

    Here'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. The HtmlLang value is what Pennington emits on the <html> element for that locale's pages.

    AddLocale is overloaded: the string-only form is shorthand when a custom display name or HtmlLang is not needed.

    Once Locales contains more than one entry, LocalizationOptions.IsMultiLocale flips to true. That single boolean gates the switcher, the locale detection middleware, and the per-locale search index.

  2. 2

    Leave UseDocSite() alone

    There's no need to add app.UsePenningtonLocaleRouting() to Program.cs. UseDocSite already calls it internally. Now that IsMultiLocale is true, the middleware rewrites /es/<path> requests into <path> with PathBase = "/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 no Content/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. 1

    Create Content/es/ and translate index.md

    Create the Content/es/ subfolder and add index.md with Spanish front-matter and Spanish body copy. The load-bearing rule: the subfolder name matches the locale code passed to AddLocalees here, because that is the code registered in step 2.1. Files under Content/es/ serve from /es/*; files directly under Content/ 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. 2

    Translate about.md and getting-started.md

    Repeat 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 FallbackNotice banner 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/about and http://localhost:5000/es/getting-started — both serve Spanish translations
  • Inspect the <html> element in dev tools on a Spanish page — lang="es" (from the LocaleInfo.HtmlLang set 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. 1

    Confirm the final host shape matches Program.cs

    Here's the final host — identical to what section 2 produced. This is a sanity-check step, not a new code change. Nothing in UseDocSite() or RunDocSiteAsync(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. 2

    Click the switcher from /es/about

    Navigate to http://localhost:5000/es/about, open the language switcher in the header, and click English. The URL becomes http://localhost:5000/about. The switcher strips the /es prefix 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 becomes http://localhost:5000/about
  • From http://localhost:5000/getting-started, click Español — the URL becomes http://localhost:5000/es/getting-started
  • From http://localhost:5000/, click Español — the URL becomes http://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 ConfigureLocalization action to DocSiteOptions — 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 matching Content/<code>/ subfolder providing the translations.
  • The LanguageSwitcher appears automatically once LocalizationOptions.IsMultiLocale flips 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 FallbackNotice banner naming the requested and default locales.