Build browse-by-{field} pages with AddTaxonomy
Group your content by any front-matter field (cuisine, tag, audience, series) and render the resulting term pages from a Razor component. Hot-reloads when source files change.
To make the same content reachable through more than one browse axis — recipes by cuisine and by dietary tag, docs by audience, posts by series — wire each axis with AddTaxonomy<TFrontMatter, TKey>. Each call emits a /{base}/ index plus one /{base}/{slug}/ term page per distinct key, each rendered from a Razor component you supply.
AddTaxonomy groups the records every other registered IContentService already projects — it does not re-parse files. Markdown is one such source, but so is any custom content service whose records carry TFrontMatter (see Source content from outside the markdown pipeline).
Define your front matter
Add a property for the field you want to group on. Implement Pennington.FrontMatter.ITaggable when one of your axes is multi-valued.
public record RecipeFrontMatter : IFrontMatter, ITaggable
{
public string Title { get; init; } = "";
public string Cuisine { get; init; } = "";
public string[] Tags { get; init; } = [];
}
A recipe page then carries:
---
title: Carbonara
cuisine: italian
tags: [pasta, eggs, weeknight]
---
Register the axis
Each AddTaxonomy<TFrontMatter, TKey> call is one axis. Use SelectKey for single-valued projections, SelectKeys for multi-valued — exactly one of the two is required.
builder.Services.AddTaxonomy<RecipeFrontMatter, string>(opts =>
{
opts.BaseUrl = "/cuisine";
opts.SelectKey = fm => fm.Cuisine;
opts.IndexPage = typeof(Pages.CuisineIndex);
opts.TermPage = typeof(Pages.CuisineTerm);
});
builder.Services.AddTaxonomy<RecipeFrontMatter, string>(opts =>
{
opts.BaseUrl = "/tag";
opts.SelectKeys = fm => fm.Tags;
opts.IndexPage = typeof(Pages.TagIndex);
opts.TermPage = typeof(Pages.TagTerm);
});
A Pasta recipe tagged [pasta, eggs, weeknight] ends up under /tag/pasta/, /tag/eggs/, and /tag/weeknight/. A Sushi recipe with cuisine: japanese ends up under /cuisine/japanese/. The two registrations coexist on the same RecipeFrontMatter because they target different BaseUrls.
Mount the endpoints
AddTaxonomy registers an IContentService so the build crawler discovers the routes; the live HTTP handlers are mounted by MapTaxonomy:
app.MapTaxonomy<RecipeFrontMatter, string>();
Call MapTaxonomy once per <TFrontMatter, TKey> pair — it walks every AddTaxonomy registration of that pair and mounts both index and term endpoints for each.
HtmlRenderer is required to render the components — wire it the same way the bare-host Razor recipe does:
builder.Services.AddRazorComponents();
builder.Services.AddHttpContextAccessor();
See Render a Razor component as a page on a bare host for the full bare-host setup.
Author the term page
The Razor component receives the matching TaxonomyTerm<TFrontMatter, TKey> as a Term parameter:
@using Pennington.Taxonomy
<h1>@Term.Label</h1>
<p>@Term.Items.Count recipes</p>
<ul>
@foreach (var item in Term.Items)
{
<li><a href="@item.Url">@item.FrontMatter.Title</a></li>
}
</ul>
@code {
[Parameter] public TaxonomyTerm<RecipeFrontMatter, string> Term { get; set; } = null!;
}
The index page receives the full term list as Terms:
@using Pennington.Taxonomy
@using System.Collections.Immutable
<h1>Browse by cuisine</h1>
<ul>
@foreach (var term in Terms)
{
<li><a href="@term.Url">@term.Label (@term.Items.Count)</a></li>
}
</ul>
@code {
[Parameter] public ImmutableList<TaxonomyTerm<RecipeFrontMatter, string>> Terms { get; set; } = [];
}
The snippets above are deliberately minimal — bare fragments that get the term data onto the page. Each component backs a route, so wrap its markup in your site layout the same way you would any bare-host Razor page.
Customize slugs and labels
Default slug encoding lowercases the key, replaces whitespace with hyphens, and URL-encodes the rest. Override either:
opts.SlugFor = key => key.ToLowerInvariant(); // skip the URL-encode for plain ASCII
opts.LabelFor = key => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(key); // pretty-print on the term page
Hot reload
When a markdown file the taxonomy reads changes, the cached term list is invalidated and the next request rebuilds it.
Edits during dotnet run propagate immediately.
Verify
- Run
dotnet runand visit/cuisine/— the index lists every cuisine, and/cuisine/japanese/renders the term page with the sushi recipe in it. - Visit
/tag/pasta/— the same carbonara recipe appears under its tag axis, confirming both registrations coexist. - Run
dotnet run -- buildand confirm the static build writesoutput/cuisine/japanese/index.html(and one folder per term underoutput/tag/).
Related
- How-to: Source content from outside the file system
- How-to: Paginate a long listing
- How-to: Render a Razor page on a bare host
- Background: The content pipeline and union types
Caveats
- Listed in the sitemap. Taxonomy routes use
EndpointSource(the canonical HTML lives behindMapTaxonomy's endpoints), but they serve real HTML, so they appear in navigation, search, cross-references, and/sitemap.xml— same as a Source content from outside the markdown pipeline page. - Records of
TFrontMatter, from any source. An axis collects only records whose metadata is aTFrontMatter; everything else is ignored. To feed it from something other than markdown, project that type from a custom service (see Source content from outside the markdown pipeline). - Drafts and future-dated posts are skipped. Items whose
IsHiddenFromBuildistrue—IsDraftset, or aDatein the future — are excluded from every term, same convention as the rest of the pipeline. - One Razor component per axis. Different cuisines can't render with different templates; switch on
Term.KeyinsideTermPageif some terms need a custom layout.