{{craftSnippets}}
Home Articles Plugins Starter theme Components on Github Gists About
  • Home
  • Articles
  • Building dynamic, AJAX based pagination for Craft CMS
Posted on Jun 06, 2019 by Piotr Pogorzelski

Building dynamic, AJAX based pagination for Craft CMS

Dynamic pagination on githubgists
image/svg+xml Dynamic pagination demo
Learn how to create a dynamic, paginated list of entries that loads content using AJAX - without refreshing page.
Table of contents:
  • Dynamic pagination functionality
  • Setting up dynamic pagination
    • dynamic_pagination.twig
    • pagination_endpoint.twig
    • pagination_pages_list.twig
  • Multiple paginated lists
  • List of entries related to category
  • setBasePath function
  • Further reading

This is something that will turn browsing paginated lists of pages into the seamless user experience. No need for any plugins like Element API - it's purely Twig based. To see dynamic pagination in action, you can check out its Demo page.

Note: this solution requires Craft 3.1.30 to function properly and will not work with earlier versions.

Dynamic pagination functionality #

Let's summarize how dynamic pagination works:

  • Initial paginated list of entries is rendered using Twig.
  • After clicking one of the pagination links, javascript AJAX request is sent to Twig based endpoint. Endpoint returns next bath of paginated entries that are injected into website using javascript, along with updated pagination links.
  • Endpoint returns paginated list of pages and updated pagination links in HTML format. It uses same TWIG component for rendering additional subpages of paginated entries that is used for rendering initial list of entries.
  • This pagination solution is a perfect example of progressive enhancement - when javascript is disabled, it works just like regular link based pagination.
  • You can use browser back and forward button while browsing paginated subpages thanks to use of history API.
  • If user clicks multiple pagination links faster than it takes for AJAX query to return data from server, all not yet completed AJAX requests are canceled, except last one. This of course does not include AJAX requests not related to pagination and made by other parts of your website.
  • After the start of each request, is-loading CSS class is added to paginated list of entries. This class is then removed after request is completed.
  • After the start of each request, page is scrolled to top of list of pages using smooth animation. If user himself starts scrolling while the animation is running, it will be stopped to avoid "fighting" with animation movement.
  • Pagination also works with search results or filtering based on GET parameters.

Setting up dynamic pagination #

Setting up dynamic pagination is actually pretty simple. You will need three files.

dynamic_pagination.twig #

This file renders initial content displayed on page load and contains JS code that handles things like AJAX requests and history API. It uses jQuery. Javascript is placed within Twig {% js %} tag to make passing TWIG variables to javascript easier and encapsulate all pagination-related code in one file (same approach as with VUE single file components).

{% if pagination_list is defined %}
{% js %}

// AJAX REQUEST DATA
{% set current_url = craft.request.getRequestUri()|split(craft.request.getPath())[0]~craft.request.getPath() %}
{% set ajax_data = {
    current_url: current_url,
    pagination_list: pagination_list|hash,
    pagination_parameters: pagination_parameters ?? null,
} %}


// JS SETTINGS
var loading_class = 'is-loading'
var pagination_list_class = 'js-pages-list'
var pagination_wrapper_class = 'js-pages-wrapper'
var animation_speed = 1000

// TWIG TO JS
var endpoint_url = '{{url(pagination_endpoint ?? 'pagination_endpoint')}}'
var url_params = '{{craft.app.request.getQueryStringWithoutPath() is not empty ? '?' ~ craft.app.request.getQueryStringWithoutPath()}}';
var current_url = '{{current_url}}'
var page_trigger = '{{craft.app.config.general.pageTrigger}}'

// AJAX REQUEST
var current_request = null;  
function change_page(page_number, done){

    current_request = $.ajax({
      url: endpoint_url+'/'+page_trigger+page_number+url_params,
      method: 'GET',
      data: {{ajax_data|json_encode|raw}},
      beforeSend: function(){
        if(current_request != null) {
            current_request.abort();
        }
        $('.'+pagination_list_class).addClass(loading_class);

        var page = $("html, body");

        page.on("scroll mousedown wheel DOMMouseScroll mousewheel keyup touchmove", function(){
           page.stop();
        });

        page.animate({ scrollTop: 
            $('.'+pagination_wrapper_class).position().top }, animation_speed, function(){
            page.off("scroll mousedown wheel DOMMouseScroll mousewheel keyup touchmove");
        });

      }
    }).always(function(){
        $('.'+pagination_list_class).removeClass(loading_class);
    }).done(function(data) {
        $('.'+pagination_wrapper_class).html(data);
        if(done){
            done();
        }
    });
}

// BACK/FORWARD BUTTON
var initial_page = '{{craft.request.getPageNum()}}'
window.addEventListener('popstate', function(e) {
    if(e.state){
        change_page(e.state);
    }else{
        change_page(initial_page);
    }
});

// PAGINATION CLICK EVENT
$('.'+pagination_wrapper_class).on('click', '[data-number]', function(e){
    e.preventDefault();
    var selected_page = $(this).attr('data-number');
    change_page(selected_page, function(){
        history.pushState(selected_page, null, current_url+'/'+page_trigger+selected_page+url_params);
    });
});

{% endjs %}    

<div class="js-pages-wrapper">
    {% include pagination_list with {
        pagination_parameters: pagination_parameters ?? null,
    } %}
</div>
{% endif %}

Include this file in a place where you want paginated list of entries displayed. You need to pass pagination_list variable into it - it should contain path of pagination_pages_list file. Here is how dynamic_pagination file should be included - using with keyword to pass variable into it.

{% include '_components/dynamic_pagination' with {
    pagination_list: '_components/pagination_pages_list',
} %}

Things to remember:

  • At the start of each request, is-loading class is added to entries list. Don't forget to set it in your CSS to something that indicates that content is loading, for example opacity: 0.5.
  • You can also add some loading indicator that will be hidden by default and temporarily show up when is-loading class is added. If you use font awesome, such indicator can be simply created by using <i class="fas fa-spinner fa-pulse"></i> - it will create spinning "preloader" icon.
  • If you have some JS events attached to elements on paginated list, remember to use event delegation - after changing pagination subpage, new DOM elements are added to the page.

pagination_endpoint.twig #

This file needs to be placed directly in templates folder or in the directory which filename doesn't start with an underscore. Thanks to that, it will be available to AJAX requests - any file without underscore at the beginning of filename can be directly accessed by requesting URL corresponding to its filename. You can learn more about it in Craft docs about routing.

{% if craft.app.request.segments|last == _self and craft.app.request.isAjax %}

    {% set pages_list_path_hashed = craft.request.getParam('pagination_list') %}
    {% if craft.app.security.validateData(pages_list_path_hashed) is not same as(false) %}
        {% include craft.app.security.validateData(pages_list_path_hashed) with {
            pagination_parameters: craft.request.getParam('pagination_parameters')
        } %}
    {% endif %}

{% else %}
    {% redirect currentSite.baseUrl 301 %}
{% endif %}

Here's summary of endpoint file functionality:

  • If it is accessed through web browser instead of through AJAX request, it redirects request to homepage. Endpoint is not supposed to be viewed directly.
  • Endpoint file includes pagination_pages_list file to render requested additional content - same file that is used by dynamic_pagination file to render initial content.
  • As there may be multiple lists, dynamic_pagination file passes list filename as pagination_list GET parameter to pagination_endpoint file. To make sure that filename is not tampered with (what if anyone would be able to make our site include any Twig file he or she wants?) this parameter is hashed using hash filter before AJAX request is sent. Later in endpoint file, hashed value is validated (and decoded) using craft.app.security.validateData function.
  • pagination_pages_list file is included with additional pagination_parameters variable. Its contents are taken from another GET request parameter. It can be used to pass additional parameter to an element query defined there. By default it's empty.

pagination_pages_list.twig #

This file's name is actually default name and can be set to anything you like by passing proper variable to dynamic_pagination file as explained earlier.

pagination_pages_list is file that is used both by dynamic_pagination and by pagination_endpoint. In dynamic_pagination it renders initial content, in pagination_endpoint it renders subsequent content requested by AJAX. By content - I mean both paginated list of entries and pagination links.

{# example element query - adjust it to your website #}
{% paginate craft.entries.section('articles').limit(3) as pageInfo, pageEntries %}

{% if craft.request.isAjax() %}
    {% do pageInfo.setBasePath(craft.request.getParam('current_url')) %}
{% endif %}

{# replace code below with your entries list and pagination component #}
{% include '_components/pagination' %}

<div class="js-pages-list">
{% for entry in pageEntries %}
    <a href="{{entry.url}}">{{entry.title}}</a>
{% endfor %}
</div>

{% include '_components/pagination' %}

In this file, you will need to set up your element query that will display list of entries (or other elements, like categories). Right now it has example query that will fetch entries from section with handle articles.

Things to remember:

  • For pagination links, you can use my Ellipsis pagination component for Craft CMS or any other pagination - just make sure that each pagination link has data-number attribute representing number of subpage it links to - just like links ellipsis pagination have.
  • List of entries needs to have js-pages-list class.

Multiple paginated lists #

You can have multiple pagination lists that display content from different sources - and all of them can use the same dynamic_pagination and pagination_endpoint file. These files are constructed to be universal - that's why you pass variables into dynamic_pagination instead changing them inside it or endpoint. For multiple pagination lists you will need only multiple "pages list" files.

List of entries related to category #

Example element query placed in pagination_pages_list will fetch all entries belonging to specific section. What if we are using pagination on the category page and we need to fetch entries related to this category?

In such situation we need to be able to have access to category ID in pagination_pages_list file, to use it in relatedTo element query method. To achieve this, we need to pass this variable to dynamic_pagination file as pagination_parameters.

{% include '_components/dynamic_pagination' with {
    pagination_list_filename: '_components/pagination_pages_list',
    pagination_parameters: {
        category_id: category.id
    }
} %}

dynamic_pagination file is constructed in such way that pagination_parameters variable will be passed both to pagination_pages_list used on initial content render and to AJAX query that loads additional content.

Now that pagination_parameters.category_id variable is avaible to pagination_pages_list, we can use it in element query.

{% paginate craft.entries.section('articles').relatedTo(pagination_parameters.category_id).limit(3) as pageInfo, pageEntries %}

setBasePath function #

Here's little explanation of what setBasePath used in pagination_pages_list file does.

By default, pagination links are based on URL of the page that pagination is used on. For example, for some-page, pagination link would look like this some-page/p2. However that would also mean that when pagination_endpoint.twig renders pagination links, these would use endpoint URL and look like this: pagination_endpoint/p2.

This is why setBasePath function is used in pagination_pages_list file. Each request to endpoint contains current_url parameter that is later used to set base URL of pagination links. setBasePath function is used only when pagination_pages_list is used to render additional subpages of paginated entries which is determined by craft.request.isAjax().

Originally, I planned to achieve the same effect using more cumbersome solution - replacing URL of endpoint in links returned from said endpoint to proper URL using a regular expression. At the same time I submitted qithub request for adding the ability to set base path of pagination links manually... and after 24 hours it was done.

Further reading #

  • Creating an API with Twig
  • Ellipsis pagination component for Craft CMS

TAGS:
#navigation #twig component
If you want to get latest updates on Craft CMS tutorials and components, follow me on Twitter or subscribe to RSS feed.
Quick links for this article:
Dynamic pagination on githubgists
image/svg+xml Dynamic pagination demo
Articles on blog:
  • Frontend testing for Craft CMS websites with Codeception and Cypress
  • Building reactive Craft Commerce product page with Sprig plugin
  • Dynamically generated PDF attachments for Freeform submissions
  • Using template hooks in Craft CMS
  • Alpine JS modal component for Craft CMS
  • Using template UI elements to extend Craft CMS control panel
  • Matrix within a Matrix - possible solutions for Craft CMS
  • Universal email template for Craft CMS
  • Creating attributes table from entry fields in Craft CMS
  • Namespacing forms in Craft CMS
  • Creating map-based navigation for Craft CMS
  • Placeholder image macro for Craft CMS
  • Building AJAX contact form with Craft CMS
  • Using incognito field plugin for Craft CMS
  • Email footer creator made with Craft CMS
  • Infinite scrolling and lazy loading with Craft CMS
  • Using Javascript in Twig templates with Craft CMS
  • Twig templating tips and tricks for Craft CMS
  • Basic SEO functionality for Craft CMS
  • Working with dates in Craft CMS templates
  • Working with SVG images in Craft CMS templates
  • Responsive and lazy-loaded youtube videos with Craft CMS
  • Debugging and inspecting Twig templates in Craft CMS
  • Creating article excerpts with Twig component in Craft CMS
  • Adding favicons to Craft CMS website
  • Truncating text with Twig macros in Craft CMS
  • Universal language switcher for Craft CMS
  • Read time macro for Craft CMS
  • Using attr() function to render HTML attributes in Craft CMS
  • Building dynamic, AJAX based pagination for Craft CMS
  • How to add Disqus comments to Craft CMS website
  • Ellipsis pagination component for Craft CMS
  • Converting email addresses into links using Twig macro
  • Breadcrumb created from URL for Craft CMS
  • Best developer-oriented Craft CMS plugins
  • Search autocomplete component for Craft CMS
  • RSS feed - template component for Craft CMS
  • Testing emails sent by Craft CMS using Mailtrap
  • Quick edit link - Twig component for Craft CMS
  • Filtering entries in control panel using Searchit plugin
  • Fetching routes into Twig templates in Craft CMS


Frontend testing for Craft CMS websites with Codeception and Cypress

Building reactive Craft Commerce product page with Sprig plugin

Dynamically generated PDF attachments for Freeform submissions

Using template hooks in Craft CMS

Alpine JS modal component for Craft CMS

Using template UI elements to extend Craft CMS control panel

Matrix within a Matrix - possible solutions for Craft CMS

Universal email template for Craft CMS

Copyright ©2023 Piotr Pogorzelski