Issues

Implementing Reusable Pagination in Umbraco

For being someone who is incredibly nerdy, doing basic math is something that for some reason irritates me to no end, especially when it's something that I have to do all the time. I may live in a world of infinite scrolls, staring at my TikTok until too late into the night, but it doesn't mean I want to spend a lot of time thinking about how to make the pagination for each set of videos work.

Whether you're working with large datasets or smaller ones, reusable pagination is pretty zippy to implement with a few quick steps - and the great thing about it being reusable is that means it's extendable, too!

I'm going to take you through setting up your model with a generic list return, through the services, all the way to the render - let's get nerdy!

The Pagination Model

The foundation of a reusable pagination is a well-structured model. I use the ever so straightforward name... PaginationModel. The goal here is designed to be generic, accommodating any type of content that requires pagination. Therefore, the primary things we need to take into consideration are:

  • How many items per page? - PageSize,
  • How many pages? - TotalPages
  • Which page are we on? - CurrentPage
  • How do I navigate through my pages? Well, probably by knowing where in the site we are, so the Url
  • What items are we displaying on this page? - Items

This last piece - the Items - is particularly important because we're using IEnumerable<T> to make this property generic. This means that when we create our model, we need to know what T is. So our model has to be named PaginationModel<T> so we can give it our generic T and get out the items we want, no matter what they are.

public class PaginationModel<T>
{
    public int PageSize { get; set; }
    public int TotalPages { get; set; }
    public int CurrentPage { get; set; }
    public string? Url { get; set; }
    public IEnumerable? Items { get; set; }
}

 

Our Pagination Services

Because I'm writing this for modern versions of Umbraco and its use of dependency injection, it's important to set up both an interface and a class for our pagination service. Therefore, we start with IPaginationService and follow with its implementation, PaginationService.

This is where the meat of the functionality happens, and while there is only one method, you're going to see a lot of <T> here because that is what is holding our articles, events, videos, or whatever list we're paginating!

    public interface IPaginationService
    {
        PaginationModel<T> Paginate<T>(IEnumerable<T> items, string url, int page, int pageSize);
    }
To begin with, we get the total pages by counting the articles and dividing it by our page size. Then we do a bit of checking about our current page - did someone put 24 in the URL but we only have 2 pages? No problem, we set it to never be more than the total page count. And if for some reason it's a negative (I guess they could type that in, too) then we always set the current page to the first page instead.

After that it's a bit of skip and take logic that handles the pagination and returns the items meant to display on this page only in our PaginationMode<T>.

public class PaginationService : IPaginationService
{
    public PaginationModel<T> Paginate<T>(IEnumerable<T> items, string url, int page, int pageSize)
    {
        //get the total pages for pagination
        var totalPages = (int)Math.Ceiling((double)items.Count() / (double)pageSize);

        // Set the pagination start and end based on the total pages
        if (page >= totalPages)
        {
            page = totalPages;
        }
        else if (page < 1)
        {
            page = 1;
        }

        // Skip the current page minus one and take the amount we want, that's our pagination!
        var paginatedItems = items.Skip((page - 1) * pageSize).Take(pageSize);

        return new PaginationModel<T>()
        {
            PageSize = pageSize,
            CurrentPage = page,
            TotalPages = totalPages,
            Url = url,
            Items = paginatedItems
        };
    }
}

And, of course, the final step is registering the service in our IUmbracoBuilder. I usually have an UmbracoBuilderExtensions file that handles all of this, but you should use whatever your standard process is. In the location where you register your services, you'll want to add this line:

builder.Services.AddSingleton<IPaginationService, PaginationService>();

For more information on how to add services to Umbraco's dependency injection, you can check the Umbraco docs! This is the explicit technique that I am referencing.

Rendering the Data

There are two general ways we might want to view our pagination - in a JSON response from an API call for an application solution, or in a view model to render straight into the front-end. Both of these are zippy to implement.

For this particular example, I am going to show how it works with an Article class that I have built with ModelsBuilder in Umbraco - but remember, this is generic! It means you can use it with any class you want - only how you get your initial list of items to paginate will be different depending on your implementation.

Option 1: An API Response

For accessing your paginated items through an API, create a Controller that inherits from UmbracoApiController to hold the logic you're going to pass into your service.

For the purposes of this example, I am going to assume you are deciding on the front-end how many articles you want to have as well as what page you are on and what your current URL is. That means you will need to pass your parameters into the call in GetArticles(string url, int pageSize, int page).

It's also important to note that because I am not using the content API that was introduced in v12, I have made a small ArticleSnippet model that I don't display directly in the article but reference in the code. This pulls in only the title, URL, summary, and an image URL for the article. You can use whatever you want in little models like this!

public class ArticlesApiController : UmbracoApiController
{
    private readonly IUmbracoContextAccessor _umbracoContext;
    private readonly IPaginationService _paginationService;

    public ArticlesApiController(IUmbracoContextAccessor umbracoContext, IPaginationService paginationService)
    {
        _umbracoContext = umbracoContext;
        _paginationService = paginationService;
    }

    [HttpGet]
    public PaginationModel<ArticleSnippet> GetArticles(string url, int pageSize, int page)
    {
        if (_umbracoContext.TryGetUmbracoContext(out var ctx))
        {
            // Use your favorite way of getting your list of articles in Umbraco
            var umbracoArticles = ctx.Content?.GetByXPath("//articlesLanding/article").OfType<Article>();

            if (umbracoArticles != null)
            {
                // Rendering the entire article outside of the content api in v12+ isn't great
                // we'll make a smaller model that gives us only what we need
                var articleSnippets = umbracoArticles.Select(article => new ArticleSnippet
                {
                    Title = article.Name,
                    Url = article.Url(),
                    Summary = article.Summary,
                    Image = article.Image != null ? article.Image.GetCropUrl(600, 200) : null
                });

                // We pass our snippets into the pagination, not the original articles from Umbraco
                var paginatedArticles = _paginationService.Paginate(articleSnippets, url, page, pageSize);

                return paginatedArticles;
            }
        }

        return new PaginationModel<ArticleSnippet>();
    }
}

And now you can call it with a GET post using something like /umbraco/api/ArticlesApi/GetArticles?url=/blog&page=1&pageSize=12.

Option 2: A View Component and Razor View

If you want to use your pagination straight in a view, then the best way to do that is to pass what you need in through a ViewComponent.

The ViewComponent Class

In this example, my PaginatedArticlesViewComponent serves as the bridge between the service layer and the presentation layer instead of an API Controller that serves up the data. However, just like the previous example, it utilizes the IPaginationService to fetch paginated content based on my list of items, the current page, specified page size.

I also use a little bit of sneaky HttpContext reading to get parameters from the querystring because even though it may not remain accurate, I think being able to bookmark and link directly to pages in a paginated list is very useful!

One thing you'll notice is that even though I call the pagination, I don't actually pass in T. Because my articles are already strongly typed and T is the same throughout the service, when I pass them in, it already knows what the type is.

public class PaginatedArticlesViewComponent : ViewComponent
{
    private readonly IHttpContextAccessor _contextAccessor;
    private readonly IPaginationService _paginationService;

    public PaginatedArticlesViewComponent(IHttpContextAccessor contextAccessor, IPaginationService paginationService)
    {
        _contextAccessor = contextAccessor;
        _paginationService = paginationService;
    }

    public IViewComponentResult Invoke(IEnumerable<Article> articles, string url, int pageSize)
    {
        var ctx = _contextAccessor.GetRequiredHttpContext();
        if(ctx != null)
        {
            // We get the querystring here from the request
            var query = ctx.Request.Query;
            // Find the page variable if it exists
            var page = query["page"].ToString();
            // If it doesn't exist, we set it to 1; but it will default to 1 when we paginate anyway. This is just so we can make it an int.
            var pageId = page.IsNullOrWhiteSpace() ? 1 : int.Parse(page);

            // Here we pass our articles, url, the page id, and the page size into the service, then return the nicely paginated package.
            var model = _paginationService.GetArticlesWithPagination(articles, url, pageId, pageSize);

            return View("/Views/Partials/ViewComponents/PaginatedArticles.cshtml", model);
        }

        return null;
    }
}

This component is invoked within the Razor view, passing in the necessary parameters such as the list of articles, the current URL, and the desired page size - this is because I like to allow my front-end colleagues to control the more display dynamics of it, and often because I am passing in things I am already receiving from the Umbraco context. No reason to reinvent the wheel!

The Razor View

Finally, the Razor view ArticlesLanding.cshtml and the partial view PaginatedArticles.cshtml render the paginated content and the pagination controls, respectively. We use the main view to invoke the PaginatedArticlesViewComponent with the relevant parameters because it gives our front-enders control and flexibility without having to ask us to change variables whenever they need an adjustment.

Then, the partial view iterates over the paginated items and displays the pagination controls based on the PaginationModel.

@* ArticlesLanding.cshtml *@

@using Umbraco.Cms.Web.Common.PublishedModels;
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.ArticlesLanding>
@using ContentModels = Client.Core.Models.Generated;
@using Client.Core.ViewComponents;
@{
    Layout = "Main.cshtml";
}

<section class="comp articles-section">
    <div class="container-lg">
        @* THE ARTICLES ARE CALLED HERE *@

        @(await Component.InvokeAsync<PaginatedArticlesViewComponent>(new 
            { 
                articles = Model.Children?.OfType<ContentModels.Article>(), 
                url = Model.Url(),
                pageSize = 12
            }))
    </div>
</section>
@* PaginatedArticles.cshtml *@

@inherits UmbracoViewPage<PaginationModel<ContentModels.Article>>
@using Client.Core.Models
@using ContentModels = Client.Core.Models.Generated;

@if (Model.Items != null && Model.Items.Any())
{
    <div class="row">
        @foreach (var articleCard in Model.Items.OfType<ContentModels.Article>())
        {
            <div>
                @* Article Stuff Here *@
                @articleCard.Name
            </div>
        }
    </div>


    <ul class="pagination">
        @if(Model.TotalPages > 1)
        {
            for (int i = 1; i <= Model.TotalPages; i++)
            {
                var activeClass = Model.CurrentPage == i ? " active" : string.Empty;
                <li><a href="@Model.Url?page=@i" class="pagination-link@(activeClass)">@i </a></li>
            }
        }
    </ul>
}
The use of a view component for pagination abstracts the complexity and makes the pagination system reusable across different views and contexts.

It's likely you could break this down even more, but since most things don't render the same - articles and events have different fields in the View - then it's good to consider how generic might be good but could create confusion for front-enders attempting to edit very embedded razor files.

I always recommend that developer experience is just as important as necessary abstraction so balancing both is always one of my top priorities!

Conclusion

There you have it, reusable pagination that can be plugged into any Umbraco instance and rendered directly into a View or returned in an API. No more math (simple or otherwise), no more rewriting looping logic, and still straightforward for those working on the front-end to see how the content is presented in an Umbraco way they can be familiar with.

I hope you're able to make use of this and if you have any suggestions for improving it (but still keeping it friendly), I'd love to hear from you!

Janae Cram

Janae Cram is a co-creator and the code behind Skrift. She's a bit of a perfectionist and likes her code to look as pretty as the front-facing applications she creates as a senior developer for ProWorks Corporation. She has an odd collection of My Little Ponies, loves to play video games and dress up like a geek for LARP on Friday nights, and is a VTuber on twitch. Her D&D class of choice is Bard, because if you can kick ass and sing, why wouldn't you?

comments powered by Disqus