Version a DocSite
Ship /v1/ and /v2/ URL trees from one DocSite host, each with its own content area and its own reflection-based API reference.
To serve /v1/ and /v2/ URL trees from one DocSite host, give each version its own ContentArea. One area per version is the whole mechanism for prose-only docs — the Lay out content by version section below is all you need.
The rest of this page layers a per-version API reference on top, which is where the only real friction lives: NuGet allows one version of an assembly per project, so the off-version DLL needs a <PackageDownload> workaround. If you don't need a reflected API tree, stop after the areas section.
The recipe references examples/VersionedDocSiteExample/, which documents Humanizer.Core 2.8.26 alongside 2.14.1. For how AddApiMetadataFromCompiledAssembly and AddApiReference work on a single version, see Auto-generate an API reference tree for a class library.
Before you begin
- A DocSite host already wired with
AddDocSite(see Scaffold a documentation site with DocSite). - A decision about which version is the active
PackageReference. That version resolves viaFromPackageReference("AssemblyName"). Every other version is staged via<PackageDownload>and an explicitAssemblyFilespath.
Lay out content by version
Use one ContentArea per version. The Slug is both the URL prefix and the folder name under Content/, so files at Content/v1/foo.md route to /v1/foo and the sidebar renders an area selector that doubles as a version switcher.
public static void AddVersionedAreas(WebApplicationBuilder builder)
{
builder.Services.AddDocSite(() => new DocSiteOptions
{
SiteTitle = "Humanizer Docs",
SiteDescription = "Side-by-side documentation for two versions of Humanizer.Core, with version-scoped content and a sidebar version selector.",
GitHubUrl = "https://github.com/Humanizr/Humanizer",
HeaderContent = """<a href="/" class="font-bold text-lg">Humanizer Docs</a>""",
FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Humanizer © Mehdi Khalili & contributors. Rendered by Pennington.</footer>""",
Areas =
[
new ContentArea("v1", "v1"),
new ContentArea("v2", "v2"),
],
});
}
The Areas declaration is the only place the version names appear in the host wiring. Adding a v3 later is two lines plus a Content/v3/ folder.
A bare / request — anyone landing on the site root with no version prefix — falls through to the DocSite not-found page unless you give the root a page; add a Content/index.md (or a routed landing component) that redirects to or links the version you treat as current. Marking one version "latest" and showing a deprecation banner on older trees are content-level conventions, not host wiring: drop a shared [!INCLUDE] partial into each old version's pages for the banner, and point the root and header link at the current slug. See Forward visitors from a renamed page for the root-redirect mechanics.
Reference two versions of the same NuGet package
NuGet allows only one <PackageReference> per assembly per project. To document a second version, add a <PackageDownload> element pinned with square-bracket exact-version syntax. <PackageDownload> fetches the package into the NuGet cache without adding it to the compile graph, leaving the <PackageReference> version as the one resolved through the default load context.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Pennington.DocSite\Pennington.DocSite.csproj" />
<ProjectReference Include="..\..\src\Pennington.DocSite.Api\Pennington.DocSite.Api.csproj" />
<ProjectReference Include="..\..\src\Pennington.ApiMetadata.Reflection\Pennington.ApiMetadata.Reflection.csproj" />
</ItemGroup>
<ItemGroup>
<!-- v2 is the active PackageReference. FromPackageReference("Humanizer")
resolves this through the default load context. -->
<PackageReference Include="Humanizer.Core" />
<!-- v1 is staged into the NuGet cache without being compiled against.
PackageDownload is how NuGet expresses "fetch this version, but do not
add it to the compile graph" — necessary because a single project
cannot PackageReference two versions of the same assembly. The exact
version pin (square brackets) is required by PackageDownload. -->
<PackageDownload Include="Humanizer.Core" Version="[2.8.26]" />
</ItemGroup>
<ItemGroup>
<Watch Include="Content\**\*.*" />
</ItemGroup>
</Project>
In Program.cs, register one named provider per version, then pair each with an AddApiReference registration whose RoutePrefix nests under the matching area slug:
public static void AddVersionedApiReferences(WebApplicationBuilder builder)
{
var nuGetPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
var humanizerV1Dll = Path.Combine(nuGetPackages, "humanizer.core", "2.8.26", "lib", "netstandard2.0", "Humanizer.dll");
builder.Services.AddApiMetadataFromCompiledAssembly("humanizer-v1", opts =>
opts.AssemblyFiles.Add(humanizerV1Dll));
builder.Services.AddApiMetadataFromCompiledAssembly("humanizer-v2", opts =>
opts.FromPackageReference("Humanizer"));
builder.Services.AddApiReference("humanizer-v1", opts =>
{
opts.RoutePrefix = "/v1/api/";
opts.TocTitle = "API reference";
});
builder.Services.AddApiReference("humanizer-v2", opts =>
{
opts.RoutePrefix = "/v2/api/";
opts.TocTitle = "API reference";
});
}
- The active reference uses
FromPackageReference("Humanizer")—Assembly.Loadfinds the v2 DLL via the project'sdeps.json. - The off-version uses
AssemblyFiles.Add(path)with an explicit path under the NuGet global-packages folder. Read that folder from theNUGET_PACKAGESenvironment variable and fall back to the per-user default (~/.nuget/packageson Linux and macOS,%USERPROFILE%\.nuget\packageson Windows) —Environment.GetFolderPath(SpecialFolder.UserProfile)resolves the home directory on every platform. Inside it, the simple-name folder is lowercased; the version is the literal<PackageDownload>value; the TFM is whicheverlib/<tfm>/the package ships.
The two registrations resolve as follows:
| Provider name | RoutePrefix |
Resolves |
|---|---|---|
humanizer-v1 |
/v1/api/ |
Humanizer.dll 2.8.26 from the NuGet cache |
humanizer-v2 |
/v2/api/ |
Humanizer.dll 2.14.1 via the active PackageReference |
The Mdazor components (<ApiMemberTable>, <ApiSummary>, …) are registered once and resolve metadata per page via the keyed provider, so two trees coexist with no further wiring.
Cross-link between versions
Each named registration emits xref uids under reference.api.{name}.{slug} — for example, <xref:reference.api.humanizer-v1.string-humanize-extensions> and <xref:reference.api.humanizer-v2.string-humanize-extensions>. Use the qualified form when a v2 content page links to a v1 type to show what changed, or vice versa.
Verify
- Run
dotnet run --project examples/VersionedDocSiteExample. - Visit
/v1/,/v2/,/v1/api/, and/v2/api/— each renders independently. - The startup log prints one
ApiReferenceIndex({name}): published N auto-discovered type pagesline per registration.Ndiffers between versions when the two assemblies differ. - Confirm the sidebar area selector switches between
v1andv2while staying on the same page kind.
Related
- How-to: Auto-generate an API reference tree for a class library — the single-source backend setup this recipe builds on.
- Tutorial: Organize content with sections and areas — the area-driven URL prefix mechanism reused for version slugs.
- Reference: DI and middleware extension methods —
AddDocSiteandDocSiteOptionssurface.