This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

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

The custom-service recipe is implemented end to end in examples/PaginatedListingExample.

In BlogSite

Set PostsPerPage on BlogSiteOptions. Paginated URLs appear automatically.

csharp
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 exceeds PostsPerPage.
  • /tags/{tag}/ — canonical per-tag page (unchanged).
  • /tags/{tag}/page/2/, … — emitted only for tags that exceed PostsPerPage.

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:

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

razor
@* 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.

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

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

csharp
builder.Services.AddTransient<ArticleResolver>();
builder.Services.AddTransient<IContentService, ArticleListingContentService>();

What ends up where

  • Sitemap. Paginated routes appear in sitemap.xml automatically — they come from DiscoverAsync as HTML routes and SitemapService includes everything that isn't a redirect or llms-only sidecar.
  • Search index and llms.txt. Excluded by default: BlogSiteContentService and the custom service above return empty GetIndexableEntriesAsync() (the default forwards to GetContentTocEntriesAsync()), so their routes never enter the search or llms paths. If a custom service does emit indexable entries for paginated routes, set ExcludeFromSearch = true and ExcludeFromLlms = true on those entries.
  • Navigation tree. Same — paginated routes have no TOC entry, so they don't show in the sidebar or breadcrumbs.

Verify

  • Run dotnet run and visit /articles. The first 20 articles render, with the Pagination controls below them.
  • Visit /articles/page/2/. The remaining articles render and the control highlights page 2 — confirming ArticleListingContentService.DiscoverAsync emitted the overflow route.
  • Run dotnet run -- build and open sitemap.xml in the output directory. It lists /articles/page/2/ alongside the individual article URLs, because the route flows through DiscoverAsync as an HTML route.