SEO and User-friendly Ajax Pagination

Here are the goals we will accomplish with this progressive enhancement:

In our example we are building a Really Important Website that has a fantastic list of key contacts for every important location in the universe. It’s a massive list, so we want to paginate it, and I’d like to show eight at a time. We’re going to work from the outside in, so that that layout components will make sense.

First thing to do is to make a parent HTML layout that will also accept Ajax requests without sending or processing the whole page. We’re going to use the new variable is_ajax_request in ExpressionEngine 3.2 to do this. Notice that I’m prefixing my layout template names with an underscore. This makes them “hidden” templates that cannot be directly accessed by a visitor; they can only be accessed when you specify them as either layouts or embeds.

layouts/_html-layout.html

{if is_ajax_request}
    {layout:contents}
{if:else}
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <!-- Here we let templates using this layout set the title tag. -->
        <title>{if layout:title != ''}{layout:title} | {/if}{site_name}</title>
    </head>
    <body>
        <!-- Here you probably have your site nav, a page header, etc., but for
          for this example, I'm abbreviating the markup to only what is relevant
          to our Ajax pagination example.
        -->


        <!-- The layout contents variable will be replaced with the content of templates
            that use this layout. We also are adding a way for templates to provide an
            id attribute so we can hook onto this container for CSS or JavaScript.
        -->
        <section id="{if layout:content_id}{layout:content_id}{/if}">
            {layout:contents}
        </section>

        <!-- The rest of your site's markup for sidebars, your footer, other scripts etc. would
            go here. Notice that we also have a layout variable for any page-specific JavaScript
            that a given template might need to load or provide.
        -->
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js"></script>
        {layout:js}
    </body>
    </html>
{/if}

Notice that if it’s an Ajax request, the only content we are outputting is the container contents supplied by our templates. In our case it’s going to be primarily a Channel Entries tag. We want this outer wrapper to be general purpose and reusable. We use a {layout:js} variable that also lets the templates supply needed behaviors. We could do the same with sidebars and other components that are not the same on every page of the site.

Now let’s peel one layer of the onion back and look at our next layout template:

layouts/_multi-layout

As you can tell by the name, we’re now getting more specific in our layout’s purpose, in this case for multi-entry pages. Ultimately this will be used for our Managers listing page, And it is general purpose, so it can be used on other multi-entry pages as needed. It will hold our JavaScript for the Ajax pagination, defined in a `{layout:set name="js"}{/layout:set}` tag pair which we already told the parent `_html-layout` wrapper where it belongs.

{layout='layouts/_html-layout'}

<!-- Provide an id attribute for our content container. -->
{layout:set name='content_id' value='managers-listing'}

<!-- Bring forward content from our template. -->
{layout:contents}

<!-- Set the JS that all of our "Managers" content pages need. It may look scary, but
    there are only a dozen or so lines of code, the rest are verbose comments
    explaining the methodology.
-->
{layout:set name="js"}
    <script>
        $(document).ready(function()
        {
            // '#managers-listing' is the id we have provided for our content's parent container.
            // '.managers-listing-pagination' is a class we will give to our pagination links containers.
            // Since the content, including pagination, is replaced in the DOM by each Ajax request,
            // we define this event handler with event delegation, watching the parent container
            // that exists in our original markup and is not replaced or removed from the DOM.
            $('#managers-listing').on('click', '.managers-listing-pagination a', function(e){
                // Prevent the browser from its normal behavior when the link is clicked
                // and grab the href of the pagination link they clicked.
                e.preventDefault();
                var source = $(this).attr('href');

                // Add a load indicator for slow connections. Use whatever you like, if you aren't
                // familiar with implementing them you can get some ideas at http://cssload.net
                var loadIndicator = $('<div class="loader" id="ajax-load-indicator"></div>');
                $('#managers-listing').prepend(loadIndicator);

                // Fetch our content
                $.get(source, function(data)
                {
                    // Insert our new content, removing the load indicator.
                    $('#managers-listing').html(data);
                    $('#ajax-load-indicator').fadeOut('fast').remove();

                    // Update our page title for browser tabs, getting the page number from the pagination link they clicked.
                    // Since our pagination links exist twice in the DOM (top and bottom), we only want to grab the :first
                    // or page 3 will display "Page 33", and so on.
                    var title = 'Managers - Page ' + $('.managers-listing-pagination:first a.active').text() + ' | {site_name}';
                    document.title = title;

                    // For security reasons, pushState() will not update the URL if it includes a domain,
                    // so we're using regex to keep only the path, e.g. /managers/P4.
                    var path = source.replace(/https?:\/\/[^\/]+/i, '');

                    // Push this page onto the browser history stack for forward/back button functionality.
                    history.pushState({}, title, path);
                });
            });
        });
    </script>
{/layout:set}

The inline comments explain in detail what we’re doing. Basically we watch for pagination links to be clicked, fetch their content via Ajax, and make sure the page titles and the browser history are updated accordingly. That way we’ve progressively enhanced the user experience without breaking any expected browser behavior for the sake of being slick. And for search engine robots that do understand JavaScript, it’s very important for indexing.

Now we need to create a template for the /managers URL that will use the _multi-layout. We’ll add our Channel Entries tag with pagination, and we’re done!

managers/index

{layout='layouts/_multi-layout'}

{exp:channel:entries channel='managers' limit='8' paginate='both' orderby='title' sort='asc'}
    {if no_results}
        {redirect='404'}
    {/if}

    <!-- Your markup to display the entries would go here. -->
    <h2>SEO and User-friendly Ajax Pagination</h2>

    <!-- Our pagination block is below, it will be placed above and below the entries,
        since we specified paginate="both"
    -->
    {paginate}
        <!-- Set a layout variable for the page title. This is important when the full
            page is accessed and by user agents that do not have JavaScript. For our
            Ajax requests, we've already taken care of this in _managers-layout. Notice
            that in this case the " | {site_name}" bit is taken care of in our
            _html-layout where the title tag is output.
        -->
        {layout:set name='title'}Managers - Page {current_page}{/layout:set}

        <!-- Our pagination links are in a container with the class we used in our JavaScript earlier. -->
        <div class='managers-listing-pagination'>
            {pagination_links}
        </div>
    {/paginate}
{/exp:channel:entries}

Again the inline comments explain what we’re doing, but I would like to draw attention to some protection we’ve added against URL fiddling or mistyped links.

{if no_results}
    {redirect='404'}
{/if}

This tells ExpressionEngine to display the site’s 404 page (with proper 404 headers), which we’ve defined in our Template Settings if there aren’t any results. When would that be? When the URL gives clues to the tag about what to show, but there aren’t any entries matching that criteria. For example, out of bounds page requests, like page 4,872 when there aren’t that many entries: /managers/P4872.

Where to go from here?

This simple example can be expanded upon, and of course marked up and styled to your heart’s content. The key technical elements of the implementation are:

Follow these principles and you can easily deliver SEO and user-friendly Ajax pagination, impressing clients and improving the experience for their site’s visitors.

Derek Jones's avatar
Derek Jones

Derek has been making software since age six, crossing over into video and music production, graphic design, and even retail management before helping build ExpressionEngine at EllisLab.

Comments 1

April 6, 2019

stefanos

Hi Derek,

Thanks for this great tutorial. Very helpfull for dynamic pagination !