Infinite Scrolling Pagination in Hugo Website

Posted on

Ever wanted to implement a neat ‘infinite scrolling’ blog with ‘load more’ button on your statically-generated personal website, but don’t want to have to resort to weird hacks in order to do it? Well, good news. While it’s not well-documented, it’s entirely possible with built-in Hugo functionality.

Prerequisites

To start off, let’s assume that you’re working on your list template, and the main part looks something like this:

 1 2 3 4 5 6 7 8 910111213141516
<div id="posts">
{{ $range := .Pages }}
{{ range $range }}
	<div class="post">
		<a class="post-link" href="{{ .Permalink }}">
			<h2 class="post-title">
				{{ .Title }}
			</h2>
		</a>
		<div class="post-content">
			{{ .Summary }}
		</div>
		<a href="{{ .Permalink }}" class="read-more">Read More</a>
	</div>
{{ end }}
</div>

I’ll sum up a range of possibilities for you here, starting at the most basic, and ending at the most polished.

Basic Infinite Scrolling

This is your basic paged view, just with a single ‘Next Page’ button.

 1 2 3 4 5 6 7 8 91011121314151617181920
<div id="posts">
{{ $range := .Pages }}
{{ $paginator := .Paginate $range }}
{{ range $paginator.Pages }}
	<div class="post">
		<a class="post-link" href="{{ .Permalink }}">
			<h2 class="post-title">
				{{ .Title }}
			</h2>
		</a>
		<div class="post-content">
			{{ .Summary }}
		</div>
		<a href="{{ .Permalink }}" class="read-more">Read More</a>
	</div>
{{ end }}
{{ if and (gt $paginator.TotalPages 1) ($paginator.HasNext) }}
	<a class="nextpage" href="{{ $paginator.Next.URL }}">Next Page</a>
{{ end }}
</div>

To make it “load more” instead of just page normally, we just need to add one little change. Instead of iterating over the paginator’s pages, we can iterate over the entire range and use the paginator to know how many posts to grab.

{{ range (first (mul $paginator.PageNumber $paginator.PageSize) $range) }}

Instead of the original range, we’re now iterating over the first X of the entire range of posts. That X is determined by the paginator’s current page, and its page size. This means that assuming your page size is 3, on page 1 you write out the first 3 posts. On page 2, you write out the first 6 posts. On page 3, the first 9 posts, etc.

It still has a couple flaws:

Let’s fix those, separately.

Basic Infinite Scrolling With Jump

The “jump” in this case is just a way to make the browser, when the page is loaded, jump back to the last post you haven’t read. It’s not perfect, as there will still be a flash as the page refreshes, but it’s the best you can do without adding Javascript.

 1 2 3 4 5 6 7 8 910111213141516171819202122
<div id="posts">
{{ $range := .Pages }}
{{ $paginator := .Paginate $range }}
{{ $pageSize := $paginator.PageSize }}
{{ $totalPostsToShow := mul $paginator.PageNumber $pageSize }}
{{ range $index, $el := (first $totalPostsToShow .Pages) }}
	<div {{ if eq $index (sub $totalPostsToShow $pageSize) }}id="newpage"{{ end }} class="post">
		<a class="post-link" href="{{ .Permalink }}">
			<h2 class="post-title">
				{{ .Title }}
			</h2>
		</a>
		<div class="post-content">
			{{ .Summary }}
		</div>
		<a href="{{ .Permalink }}" class="read-more">Read More</a>
	</div>
{{ end }}
{{ if and (gt $paginator.TotalPages 1) ($paginator.HasNext) }}
	<a class="nextpage" href="{{ $paginator.Next.URL }}#newpage">Next Page</a>
{{ end }}
</div>

The code isn’t much different. We’ve made variables out of a few common values, the page size and the amount of posts to show. We’ve also added a simple if to the post element, saying that “if the element is the first element on the new page (or to put it another way, its index in the list is the total amount of posts to show minus the page size)” then we’ll give it an ID of ‘newpage’.

And then the simple change at the end, adding a ‘#newpage’ to the end of the URL for the next page, makes the browser automatically scroll down to the new post.

This is functional, but in the age of Javascript, it still has a couple flaws:

Javascript Infinite Scrolling

Now, for those who have skipped ahead straight to this part, you might want to read the above as well. The above can work as a fallback for when JS doesn’t work, and will probably make search engines happier to boot. It’s way simpler than this section, so just go through it quickly.

With Javascript, we can take advantage of AJAX calls to load the data, and just shove it into the page on-the-fly. It’s still a static website, you’re still just loading literally what the next page has as if the user had clicked the button directly. You can even massage the URL bar to make it display the correct URL, allow refreshing to work, etc.

On the other hand, it will be more complicated to understand and write, and be aware that you may have to modify this code more to make it fit your use-case. In my examples I’ll be assuming the only thing that changes on the page is the list of posts on-screen.

Note: For simplicity’s sake, I’ll be using jQuery. All of the following can definitely be done with plain JS, but it’s an exercise for the reader to translate it to plain JS.

 1 2 3 4 5 6 7 8 910111213141516171819202122232425262728293031323334353637
<script>
    //we will bind an on-click handler for the next page button, but
    //handled by the body. This will allow us to replace the button
	$('body').off('click').on('click', '.posts .nextpage', function(e) {
		e.preventDefault(); //block the link click from changing the page
		//fire off an AJAX request to load the next page
		var nextPageUrl = $(this).prop('href');
		$.ajax({
			url: nextPageUrl,
			dataType: 'text', //this is to avoid jQuery running any <script> tags
			success: function(html) {
				var currentContainer = $('#posts');
				//create a temporary hidden element to attach the created document
				//this is the simplest jQuery way to do this safely.
				var tempDiv = $('<div>').appendTo(document.body).css('display', 'none');
				tempDiv[0].innerHTML = html;
				//find the container element in the new document
				var newContainer = tempDiv.find('#posts');
				//replace the container on the current page with the new one
				currentContainer.replaceWith(newContainer);
				//remove the temporary element
				tempDiv.remove();
				//update the URL bar
				if (window.history.pushState)
				{
					window.history.pushState(null, null, nextPageUrl);
				}
				console.log("Successfully changed page to " + nextPageUrl);
			},
			error: function(xhr, status, error) {
				console.error(xhr, status, error);
				//default to non-JS behaviour and click-through as normal
				window.location.href = nextPageUrl;
			}
		})
	});
</script>

Add this JS script to your page, preferably in your footer to not slow down your page load.

The code is commented, however to sum it up, it will hijack clicks on the next page button. It will then fire off an HTTP request to get the next page, exactly in the same way your browser would load it, and it’ll get the raw HTML. If that failed, it’ll fallback to regular behaviour. If it succeeds, it puts that HTML into a hidden div to access it, and then it finds the element that contains your posts.

It then replaces the current container you can see with the new one, and updates the link in your browser to be the new one, which will let the refresh button work correctly, as well as signalling to the user that the load has happened.

And that’s basically it for the actual functionality.

Proposed Improvements

There are many potential flaws with this, which I’d like to highlight as the sort of thing that people might want to improve on their own: