Paginate archive and tag listings
Split long blog archives and tag pages into numbered pages, and apply the same pattern to a custom IContentService.
Long listings — a five-year archive, a popular tag with hundreds of posts — get unwieldy past a few dozen entries. BlogSite includes pagination for archives and tag pages; custom content services can reuse the shared Pagination component to do the same.
Before you begin
- A working Pennington site (see Your first Pennington site if not).
- For the custom-service half: a bare
AddPenningtonhost that renders Razor@pagecomponents — the same Blazor wiring the first-page tutorial sets up (AddRazorComponents+MapRazorComponents). - Familiarity with custom content services — the recipe below adds one.
The custom-service recipe is implemented end to end in examples/PaginatedListingExample.
In BlogSite
Set PostsPerPage on BlogSiteOptions. Paginated URLs appear automatically.
builder.Services.AddBlogSite(() => new BlogSiteOptions
{
SiteTitle = "My Blog",
SiteDescription = "Posts and notes.",
PostsPerPage = 10,
});
Resulting routes:
/archive— canonical, page 1 (unchanged)./archive/page/2/,/archive/page/3/, … — emitted only when the post count exceedsPostsPerPage./tags/{tag}/— canonical per-tag page (unchanged)./tags/{tag}/page/2/, … — emitted only for tags that exceedPostsPerPage.
The default is 10. A non-positive value disables pagination entirely (all posts on one page). The home page is intentionally not paginated — it stays curated with the recent-post slot and a link to the archive.
In a custom content service
The pattern is three pieces: core's PagedList<T> record, a Razor page with two @page directives, and an IContentService that yields the paginated routes during discovery.
The PagedList record
Core ships Pennington.Content.PagedList<T> — a page slice plus the metadata the Pagination component needs to render prev/next and numbered links:
public sealed record PagedList<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalItems)
{
/// <summary>Total page count. At least <c>1</c> even when <see cref="TotalItems"/> is zero.</summary>
public int TotalPages => TotalItems <= 0 || PageSize <= 0
? 1
: (int)Math.Ceiling(TotalItems / (double)PageSize);
/// <summary>True when a page exists before <see cref="Page"/>.</summary>
public bool HasPrevious => Page > 1;
/// <summary>True when a page exists after <see cref="Page"/>.</summary>
public bool HasNext => Page < TotalPages;
}
The Razor page
Two @page directives keep the canonical URL clean and add the paginated variant. Read the optional Page parameter, slice the source list through ArticleResolver, and render the shared Pagination component. PageUrl maps page 1 back to the canonical /articles URL.
@* Two @page directives: the canonical /articles URL plus the numbered /articles/page/N/
variant. ArticleResolver slices the article list; the shared Pagination component renders
the prev/numbered/next controls, with PageUrl mapping page 1 back to the canonical URL. *@
@page "/articles"
@page "/articles/page/{Page:int}"
@inject ArticleResolver Resolver
<PageTitle>Articles@(_page is { Page: > 1 } p ? $" (page {p.Page})" : "")</PageTitle>
@if (_page is null)
{
<p>No articles.</p>
return;
}
<h1>Articles</h1>
<ul>
@foreach (var article in _page.Items)
{
<li><a href="@article.Url">@article.Title</a></li>
}
</ul>
<Pagination CurrentPage="@_page.Page" TotalPages="@_page.TotalPages" UrlFor="@PageUrl" />
@code {
/// <summary>1-based page index from the route. Null on the canonical /articles URL (page 1).</summary>
[Parameter] public int? Page { get; set; }
private PagedList<Article>? _page;
protected override async Task OnInitializedAsync()
{
_page = await Resolver.GetPagedAsync(Page ?? 1, pageSize: 20);
}
private static string PageUrl(int page) => page <= 1
? "/articles"
: $"/articles/page/{page}/";
}
ArticleResolver collects the markdown articles from every content source and slices them into pages. It is a plain service — not an IContentService — so it can inject IEnumerable<IContentService> directly with no risk of a cycle.
namespace PaginatedListingExample;
using Pennington.Content;
using Pennington.Pipeline;
/// <summary>One entry in the article listing.</summary>
/// <param name="Url">Canonical URL of the article.</param>
/// <param name="Title">Display title.</param>
public sealed record Article(string Url, string Title);
/// <summary>
/// Collects the markdown articles under <c>/articles/</c> from every registered
/// <see cref="IContentService"/> and serves them one page at a time. Injected by the
/// <c>ArticlesPage</c> Razor component. It is not registered as an <see cref="IContentService"/>,
/// so the plain <see cref="IEnumerable{T}"/> injection here is safe — only the discovery service
/// (which is in that set) has to resolve siblings lazily to avoid a cycle.
/// </summary>
public sealed class ArticleResolver(IEnumerable<IContentService> services)
{
/// <summary>Returns the requested 1-based page of articles, ordered by URL.</summary>
public async Task<PagedList<Article>> GetPagedAsync(int page, int pageSize)
{
var all = await CollectAsync();
var skip = Math.Max(0, (page - 1) * pageSize);
var items = all.Skip(skip).Take(pageSize).ToList();
return new PagedList<Article>(items, page, pageSize, all.Count);
}
private async Task<List<Article>> CollectAsync()
{
var articles = new List<Article>();
await foreach (var item in services.DiscoverAllAsync())
{
if (item.Source.Value is FileSource { IsMarkdown: true } &&
item.Route.CanonicalPath.Value.StartsWith("/articles/"))
{
var url = item.Route.CanonicalPath.Value;
articles.Add(new Article(url, item.Metadata?.Title ?? url));
}
}
return articles.OrderBy(a => a.Url, StringComparer.Ordinal).ToList();
}
}
The content service
A parameterized @page template ({Page:int}) is skipped by Pennington's automatic Razor route discovery. Emit each paginated route explicitly so the static build crawls them.
The service is itself one of the registered IContentService instances, so it must not constructor-inject IEnumerable<IContentService> — that forms a dependency cycle and throws at startup. Inject IServiceProvider instead and resolve the siblings on demand inside DiscoverAsync, excluding self with !ReferenceEquals(s, this). This is the same pattern the library's own SocialCardContentService uses.
public sealed class ArticleListingContentService(IServiceProvider serviceProvider) : IContentService, IMetaContentService
{
private const int PageSize = 20;
/// <inheritdoc/>
public string DefaultSectionLabel => "";
/// <inheritdoc/>
public int SearchPriority => 0;
/// <inheritdoc/>
public async IAsyncEnumerable<DiscoveredItem> DiscoverAsync()
{
// Resolve siblings on demand rather than via a ctor IEnumerable<IContentService>: this
// service is itself in that set, so the ctor injection would be a DI cycle. Filter out every
// meta-service (this one included) so the sibling walk can't recurse back into this discovery
// — reference-equality self-exclusion would miss the fresh transient copies GetServices hands back.
var siblings = serviceProvider.GetServices<IContentService>()
.SourceServices()
.ToList();
var count = 0;
await foreach (var item in siblings.DiscoverAllAsync())
{
if (item.Source.Value is FileSource { IsMarkdown: true } &&
item.Route.CanonicalPath.Value.StartsWith("/articles/"))
{
count++;
}
}
ContentSource source = new RazorPageSource(typeof(ArticlesPage).AssemblyQualifiedName!);
var totalPages = (int)Math.Ceiling(count / (double)PageSize);
for (var page = 2; page <= totalPages; page++)
{
yield return new DiscoveredItem(
new ContentRoute
{
CanonicalPath = new UrlPath($"/articles/page/{page}/"),
OutputFile = new FilePath($"articles/page/{page}/index.html"),
},
source);
}
}
/// <inheritdoc/>
public Task<ImmutableList<ContentToCopy>> GetContentToCopyAsync()
=> Task.FromResult(ImmutableList<ContentToCopy>.Empty);
/// <inheritdoc/>
public Task<ImmutableList<ContentTocItem>> GetContentTocEntriesAsync()
=> Task.FromResult(ImmutableList<ContentTocItem>.Empty);
/// <inheritdoc/>
public Task<ImmutableList<CrossReference>> GetCrossReferencesAsync()
=> Task.FromResult(ImmutableList<CrossReference>.Empty);
}
Register the resolver and the service alongside the markdown source. ArticleResolver is a plain transient; ArticleListingContentService joins the IContentService set the same way any markdown source does.
builder.Services.AddTransient<ArticleResolver>();
builder.Services.AddTransient<IContentService, ArticleListingContentService>();
What ends up where
- Sitemap. Paginated routes appear in
sitemap.xmlautomatically — they come fromDiscoverAsyncas HTML routes andSitemapServiceincludes everything that isn't a redirect or llms-only sidecar. - Search index and llms.txt. Excluded by default:
BlogSiteContentServiceand the custom service above return emptyGetIndexableEntriesAsync()(the default forwards toGetContentTocEntriesAsync()), so their routes never enter the search or llms paths. If a custom service does emit indexable entries for paginated routes, setExcludeFromSearch = trueandExcludeFromLlms = trueon those entries. - Navigation tree. Same — paginated routes have no TOC entry, so they don't show in the sidebar or breadcrumbs.
Verify
- Run
dotnet runand visit/articles. The first 20 articles render, with thePaginationcontrols below them. - Visit
/articles/page/2/. The remaining articles render and the control highlights page 2 — confirmingArticleListingContentService.DiscoverAsyncemitted the overflow route. - Run
dotnet run -- buildand opensitemap.xmlin the output directory. It lists/articles/page/2/alongside the individual article URLs, because the route flows throughDiscoverAsyncas an HTML route.
Related
- Reference:
BlogSiteOptions.PostsPerPage - Background: Content pipeline overview
- Extensibility: Source content from outside the file system