---
title: Paginate archive and tag listings
description: "Split long blog archives and tag pages into numbered pages, and apply the same pattern to a custom IContentService."
canonical_url: https://usepennington.net/how-to/discovery/pagination/
sidecar_url: https://usepennington.net/how-to/discovery/pagination.md
content_hash: sha256:710f9f3ae51425608733ceba9106fe6de723ef4a12ad9cba32ca35135555e9f0
tokens: 2987
uid: how-to.discovery.pagination
reading_time_minutes: 4
---

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

 
 - A working Pennington site (see [Your first Pennington site](https://usepennington.net/tutorials/getting-started/first-page.md) if not).
 - For the custom-service half: a bare `AddPennington` host that renders Razor `@page` components — the same Blazor wiring the [first-page tutorial](https://usepennington.net/tutorials/getting-started/first-page.md) sets up (`AddRazorComponents` + `MapRazorComponents`).
 - Familiarity with [custom content services](https://usepennington.net/how-to/content-services/custom-content-service.md) — the recipe below adds one.
 
 
The custom-service recipe is implemented end to end in [examples/PaginatedListingExample](https://github.com/usepennington/pennington/tree/main/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:symbol
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:symbol
@* 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:symbol
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:symbol
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.
 
 
## Related

 
 - Reference: [BlogSiteOptions.PostsPerPage](https://usepennington.net/reference/api/blog-site-options.md)
 - Background: [Content pipeline overview](https://usepennington.net/explanation/core/content-pipeline.md)
 - Extensibility: [Source content from outside the file system](https://usepennington.net/how-to/content-services/custom-content-service.md)
 
 
[Previous
                
                Serve the site in multiple languages](https://usepennington.net/how-to/discovery/localization.md)[Next
                    
                Flag missing and outdated translations in the build report and dev overlay](https://usepennington.net/how-to/discovery/audit-translations.md)