Hugo is an awesome feature-packed static site generator which is also open source and free. It ships with a default pagination template but if you’re looking for full control, I’m going to demonstrate how to build custom pagination.

Introduction

My focus in this tutorial is Hugo, so I won’t be writing CSS and I’ll use as little HTML as possible. I’ll be using some basic JavaScript examples to help explain Hugo’s syntax but JavaScript experience is unnecessary.

Things do get a little heavy towards the end but I’ll go through the code in detail so it should all make sense. If you’d like to see the whole thing, skip ahead to the final code.

Pagination the easy way

If you’re looking for basic pagination you can very easily use Hugo’s default pagination template as follows:

{{ $paginator := .Paginate (where .Data.Pages "Type" "posts") }}
{{ range $paginator.Pages }}
  <!‐‐ Post content such as title and summary goes here. ‐‐>
{{ end }}
<!-- Hugo's default pagination template. -->
{{ template "_internal/pagination.html" . }}
Hugo's awesome default pagination template (poorly styled by me).

Hugo's awesome default pagination template (poorly styled by me).

This will pump out Hugo’s default pagination template and for a lot of people this will be fine, but I want full control of my markup and I don’t like how it handles excess pages.

If you’re also keen to build a custom solution — and learn more about Hugo in the process — read on.

Getting started with Hugo

If you don’t have a Hugo site yet, it’s very easy to set up and I highly recommend it for projects large and small. If you have experience using a CMS such as WordPress you’ll be pleased to find that Hugo uses a lot of similar terminology.

Adding posts

In order to test pagination you’ll need some content on your site. If you’re in the early stages of development, add some dummy posts to your /content/ directory to help generate multiple pages.

Setting the number of posts per page

Hugo defaults to 10 posts per page but for testing purposes I found it more practical to reduce this to 1-2. You can do this in your hugo config file in the following way (shown below in config.yaml):

paginate: 1

Remember to change it back when you’re done testing.

Outputting posts

The easiest way to output posts is on your site’s home page using the /layouts/index.html template. If this file doesn’t exist yet, go ahead and create it and add some standard HTML scaffolding.

Next within index.html we’ll create a custom variable to handle pagination and determine which posts are retrieved.

{{ $paginator := .Paginate (where .Data.Pages "Type" "posts") }}

This is similar to a WordPress query and it sets up a .Paginate object containing all posts matching the where statement. This will be used to output posts using Hugo’s range function, which is roughly equivalent to a for or while loop.

Note that in this example I’ve used “posts” as the content name but it can be anything (for my site I used “articles”). It should be the same as the directory name in your Hugo /content/ folder, for example /content/posts/.

Here’s how we iterate over the .Paginate object:

{{ range $paginator.Pages }}
  <div class="post">
    <h2 class="post__title">
      <a href="{{ .Permalink }}">{{ .Title }}</a>
    </h2>
    <div class="post__summary">
      {{ .Summary }}
    </div>
  </div>
{{ end }}

This will output the post title with a permalink and a summary. The summary is the first 70 words of each post or everything before a <!‐‐more‐‐> comment.

I’m using bem for my CSS class names but you can write your markup however you like.

I do however recommend taking advantage of Hugo’s partial templates for any repetitive blocks of HTML such as this because it allows the same markup to be shared across different Hugo listings (eg. the category and tag taxonomies).

Custom pagination

Up to this point the Hugo code we’ve written is similar to a standard list template but here’s where things get interesting.

First we assign the .Paginator object to a $paginator variable.

{{ $paginator := .Paginator }}

Notice that this is different to the .Paginate object which is used to output posts.

Next we’ll use a Hugo if statement to make sure there’s more than one page before we start outputting page numbers.

{{ if gt $paginator.TotalPages 1 }}
  <!-- Page number code goes here. -->
{{ end }}

Hugo if statements take some getting used to because they’re structured differently to most web programming languages and they use shorthand for operators rather than the symbols you might be familiar with (for example gt instead of >).

In languages such has JavaScript or PHP the above code is written as:

if ($paginator.TotalPages > 1) {
  // Page number code goes here.
}

Page numbers

Next, from within our if statement, we loop through the page numbers using the range function.

<ul class="pagination">
  {{ range $paginator.Pagers }}
  <li class="pagination__item">
    <a href="{{ .URL }}" class="pagination__link">
      {{ .PageNumber }}
    </a>
  </li>
  {{ end }}
</ul>

This outputs page numbers and it’s a good start, but we should let our users know which page they’re on.

Here I’ve added an active class to the list item which will appear only on the current page.

<li class="pagination__item{{ if eq . $paginator }} pagination__item--current{{ end }}">

Let’s zero in on that Hugo if statement.

{{ if eq . $paginator }} pagination__item--current{{ end }}

Firstly, it’s important to understand that in Hugo, when you’re within a range function, the . is roughly equivalent to this in JavaScript. It refers to the current item in the loop. You can learn more about context in Hugo here.

In this case $paginator will be equal to whatever page the user is currently viewing. When output, its actual value is “Pager 1” if you’re on page one. Similarly, the page item within the loop — represented by . — has the value of “Pager 1” or “Pager 2”.

Hugo makes adding next and previous buttons very easy. It assumes they won’t be shown on the first and last pages respectively and there are handy boolean properties which handle this logic. Since these return a true or false value, Hugo allows us to write a more succinct if statement.

Here’s how we output the previous page link:

{{ if $paginator.HasPrev }}
  <li class="pagination__item pagination__item--previous">
    <a href="{{ $paginator.Prev.URL }}" class="pagination__link pagination__link--previous">
      «
    </a>
  </li>
{{ end }}

Notice that we can access the previous page URL via $paginator.Prev.URL.

The next page link is output in much the same way:

{{ if $paginator.HasNext }}
  <li class="pagination__item pagination__item--next">
    <a href="{{ $paginator.Next.URL }}" class="pagination__link pagination__link--next">
      »
    </a>
  </li>
{{ end }}

I’m not 100% convinced that first and last page links are essential to usable pagination, but adding them is relatively easy. Hugo doesn’t have a first and last equivalent to .HasPrev and .HasNext but the logic is the same: they probably shouldn’t appear on the first and last pages respectively.

We can write a simple if statement to ensure the first page link is only shown if the user is not on the first page:

  {{ if ne $paginator.PageNumber 1 }}
    <li class="pagination__item pagination__item--first">
      <a class="pagination__link pagination__link--first" href="{{ $paginator.First.URL }}">
        ««
      </a>
    </li>
  {{ end }}

The first page link can be accessed via $paginator.First.URL.

We can run a similar check to make sure the last page link isn’t shown on the last page:

  {{ if ne $paginator.PageNumber $paginator.TotalPages }}
    <li class="pagination__item pagination__item--last">
      <a class="pagination__link pagination__link--last" href="{{ $paginator.Last.URL }}">
        »»
      </a>
    </li>
  {{ end }}

Our code so far

This is coming together nicely. Here’s what we have so far:

{{ $paginator := .Paginator }}

<!-- If there's more than one page. -->
{{ if gt $paginator.TotalPages 1 }}

  <ul class="pagination">
    
    <!-- First page. -->
    {{ if ne $paginator.PageNumber 1 }}
    <li class="pagination__item pagination__item--first">
      <a class="pagination__link pagination__link--first" href="{{ $paginator.First.URL }}">
        ««
      </a>
    </li>
    {{ end }}

    <!-- Previous page. -->
    {{ if $paginator.HasPrev }}
    <li class="pagination__item pagination__item--previous">
      <a href="{{ $paginator.Prev.URL }}" class="pagination__link pagination__link--previous">
        «
      </a>
    </li>
    {{ end }}
  
    <!-- Page numbers. -->
    {{ range $paginator.Pagers }}
    <li class="pagination__item{{ if eq . $paginator }} pagination__item--current{{ end }}">
      <a href="{{ .URL }}" class="pagination__link">
        {{ .PageNumber }}
      </a>
    </li>
    {{ end }}

    <!-- Next page. -->
    {{ if $paginator.HasNext }}
    <li class="pagination__item pagination__item--next">
      <a href="{{ $paginator.Next.URL }}" class="pagination__link pagination__link--next">
        »
      </a>
    </li>
    {{ end }}

    <!-- Last page. -->
    {{ if ne $paginator.PageNumber $paginator.TotalPages }}
    <li class="pagination__item pagination__item--last">
      <a class="pagination__link pagination__link--last" href="{{ $paginator.Last.URL }}">
        »»
      </a>
    </li>
    {{ end }}

  </ul><!-- .pagination -->
{{ end }}

The limitations of this code

For a short while when I reached this point I thought I had my custom pagination all sorted. But there are issues with what we’ve written. There’s nothing to stop page numbers being output indefinitely which is fine if there are 10 pages. But what if there are 20… or 50?

At this point I strongly considered abandoning my quest for custom pagination. Hugo’s built-in pagination is really good and I could probably hide anything I didn’t want using CSS. But no, I pressed on.

Smarter page numbers

Before I go any further I’ll outline what I wanted to achieve:

  • A set number of page links either side of the current page (adjacent links).
  • The same number of overall page numbers showing at all times.
  • No dots between page numbers when there are a lot of page numbers.

I also want to note that at this point in the article the Hugo coding gets a little more intense. I’ll try to break things down as much as possible but depending on your development experience it may be difficult to follow.

What do smarter page numbers look like?

Below are some examples of how pagination would look if there are 10 pages with 2 adjacent links.

NOTE: Orange = current page

Some notes about the logic of the above pagination:

  • Maximum number of pages to display can be found with ($adjacent_links * 2) + 1 which in this example is 5. I will call this $max_links.
  • If the total number of pages doesn’t exceed the maximum number of pages to display ($max_links), there’s no need for complicated pagination logic; all page numbers will be shown.
  • Pages 1-3 and 8-10 show the same group of page numbers but with a different active item. These pages are rendered differently to the middle pages.
  • The above “lower limit” pages (1-3) can be identified as being less than or equal to $adjacent_links + 1. I will call this threshold $lower_limit.
  • The “upper limit” pages (8-10) can be identified as being greater than or equal to .TotalPages - $adjacent_links. I will call this threshold $upper_limit.

Coding smarter page numbers

To kick things off we will set up some config variables.

<!-- Number of links either side of the current page. -->
{{ $adjacent_links := 2 }}

<!-- $max_links = ($adjacent_links * 2) + 1 -->
{{ $max_links := (add (mul $adjacent_links 2) 1) }}

<!-- $lower_limit = $adjacent_links + 1 -->
{{ $lower_limit := (add $adjacent_links 1) }}

<!-- $upper_limit = $paginator.TotalPages - $adjacent_links -->
{{ $upper_limit := (sub $paginator.TotalPages $adjacent_links) }}

Much like if statements, Hugo arithmetic is unituitive and takes some getting used to. I usually write an HTML comment above each line in more traditional terms as a gift to my future self.

Next, within our {{ range $paginator.Pagers }} loop, we’ll use Hugo’s scratchpad to store a boolean page number flag. This will be used to show / hide page numbers and it will be set to false by default (i.e. hidden).

{{ range $paginator.Pagers }}
  {{ $.Scratch.Set "page_number_flag" false }}
{{ end }}

Why are we using .Scratch here? Because Hugo variables which are declared within an if statement can’t be accessed outside of said if statement. Variables on the scratchpad aren’t bound be these limitations and can be set and retrieved just like regular variables.

Simple page numbers

We then need to determine whether complex logic is required to hide page numbers. If the total number of pages is greater than the maximum number of links to show ($max_links) we will use complex logic.

{{ range $paginator.Pagers }}
  {{ $.Scratch.Set "page_number_flag" false }}

  <!-- Complex page numbers. -->
  {{ if gt $paginator.TotalPages $max_links }}

    <!-- Logic for complex page numbers (see below). -->

  <!-- Simple page numbers. -->
  {{ else }}

    {{ $.Scratch.Set "page_number_flag" true }}

  {{ end }}

  {{ if eq ($.Scratch.Get "page_number_flag") true }}
    <li class="pagination__item{{ if eq . $paginator }} pagination__item--current{{ end }}">
      <a href="{{ .URL }}" class="pagination__link">
        {{ .PageNumber }}
      </a>
    </li>
  {{ end }}
{{ end }}

For the simple page numbers we will set the page_number_flag to true for all items in the {{ range }} loop since we want them all to show.

When the scratch flag variable is true, we will output the page number HTML using the same markup as before.

Complex page numbers

As for the complex page number links, we can use an if statement to check for lower limit links, upper limit links and middle page links.

<!-- Lower limit pages. -->
<!-- If the user is on a page which is in the lower limit.  -->
{{ if le $paginator.PageNumber $lower_limit }}

  <!-- Logic to show only necessary lower limit pages. -->

<!-- Upper limit pages. -->
<!-- If the user is on a page which is in the upper limit. -->
{{ else if ge $paginator.PageNumber $upper_limit }}

  <!-- Logic to show only necessary upper limit pages. -->

<!-- Middle pages. -->
{{ else }}
  
  <!-- Logic to show only necessary middle pages. -->

{{ end }}

From here we need to ensure that only the necessary pages are shown for each of the three scenarios by setting the page_number_flag to true.

Lower limit page numbers

The lower limit page numbers are very straight forward. We want to show pages from 1 to $max_links which in our working example is 5.

<!-- If the current loop page is less than max_links. -->
{{ if le .PageNumber $max_links }}
  {{ $.Scratch.Set "page_number_flag" true }}
{{ end }}

Upper limit page numbers

These are only slightly more complicated. We want to identify all page numbers above .TotalPages - $max_links.

<!-- If the current loop page is greater than total pages minus $max_links -->
{{ if gt .PageNumber (sub $paginator.TotalPages $max_links) }}
  {{ $.Scratch.Set "page_number_flag" true }}
{{ end }}

Middle page numbers

This is the most complex of the three scenarios and Hugo’s syntax makes it more difficult to comprehend. I’ll start by writing the code in a more familiar language (JS).

if ( 
  .PageNumber >= $paginator.PageNumber - $adjacent_links
  &&
  .PageNumber <= $paginator.PageNumber + $adjacent_links
) {
  // Set flag to true.
}

In plain English the if statement is asking:

  • IF the current loop page number is greater than or equal to the page number the user is currently viewing minus $adjacent_links
  • AND
  • IF the current loop page number is less than or equal to the page number the user is currently viewing plus $adjacent_links.

This is a long-winded way of showing two page numbers either side of the current page.

Here’s the actual Hugo code:

{{ if and ( ge .PageNumber (sub $paginator.PageNumber $adjacent_links) ) ( le .PageNumber (add $paginator.PageNumber $adjacent_links) ) }}
  {{ $.Scratch.Set "page_number_flag" true }}
{{ end }}

Great, let’s check out the whole thing.

The final code

<!--
//
//  OUTPUT POSTS
//––––––––––––––––––––––––––––––––––––––––––––––––––
-->
{{ $paginator := .Paginate (where .Data.Pages "Type" "posts") }}

{{ range $paginator.Pages }}
  <div class="post">
    <h2 class="post__title">
      <a href="{{ .Permalink }}">{{ .Title }}</a>
    </h2>
    <div class="post__summary">
      {{ .Summary }}
    </div>
  </div>
{{ end }}

<!--
//
//  PAGE NUMBERS
//––––––––––––––––––––––––––––––––––––––––––––––––––
-->
{{ $paginator := .Paginator }}

<!-- Number of links either side of the current page. -->
{{ $adjacent_links := 2 }}

<!-- $max_links = ($adjacent_links * 2) + 1 -->
{{ $max_links := (add (mul $adjacent_links 2) 1) }}

<!-- $lower_limit = $adjacent_links + 1 -->
{{ $lower_limit := (add $adjacent_links 1) }}

<!-- $upper_limit = $paginator.TotalPages - $adjacent_links -->
{{ $upper_limit := (sub $paginator.TotalPages $adjacent_links) }}

<!-- If there's more than one page. -->
{{ if gt $paginator.TotalPages 1 }}

  <ul class="pagination">
    
    <!-- First page. -->
    {{ if ne $paginator.PageNumber 1 }}
    <li class="pagination__item pagination__item--first">
      <a class="pagination__link pagination__link--first" href="{{ $paginator.First.URL }}">
        ««
      </a>
    </li>
    {{ end }}

    <!-- Previous page. -->
    {{ if $paginator.HasPrev }}
    <li class="pagination__item pagination__item--previous">
      <a href="{{ $paginator.Prev.URL }}" class="pagination__link pagination__link--previous">
        «
      </a>
    </li>
    {{ end }}
  
    <!-- Page numbers. -->
    {{ range $paginator.Pagers }}
    
      {{ $.Scratch.Set "page_number_flag" false }}

      
      <!-- Advanced page numbers. -->
      {{ if gt $paginator.TotalPages $max_links }}


        <!-- Lower limit pages. -->
        <!-- If the user is on a page which is in the lower limit.  -->
        {{ if le $paginator.PageNumber $lower_limit }}

          <!-- If the current loop page is less than max_links. -->
          {{ if le .PageNumber $max_links }}
            {{ $.Scratch.Set "page_number_flag" true }}
          {{ end }}


        <!-- Upper limit pages. -->
        <!-- If the user is on a page which is in the upper limit. -->
        {{ else if ge $paginator.PageNumber $upper_limit }}

          <!-- If the current loop page is greater than total pages minus $max_links -->
          {{ if gt .PageNumber (sub $paginator.TotalPages $max_links) }}
            {{ $.Scratch.Set "page_number_flag" true }}
          {{ end }}


        <!-- Middle pages. -->
        {{ else }}
          
          {{ if and ( ge .PageNumber (sub $paginator.PageNumber $adjacent_links) ) ( le .PageNumber (add $paginator.PageNumber $adjacent_links) ) }}
            {{ $.Scratch.Set "page_number_flag" true }}
          {{ end }}

        {{ end }}

      
      <!-- Simple page numbers. -->
      {{ else }}

        {{ $.Scratch.Set "page_number_flag" true }}

      {{ end }}

      <!-- Output page numbers. -->
      {{ if eq ($.Scratch.Get "page_number_flag") true }}
        <li class="pagination__item{{ if eq . $paginator }} pagination__item--current{{ end }}">
          <a href="{{ .URL }}" class="pagination__link">
            {{ .PageNumber }}
          </a>
        </li>
      {{ end }}

    {{ end }}

    <!-- Next page. -->
    {{ if $paginator.HasNext }}
    <li class="pagination__item pagination__item--next">
      <a href="{{ $paginator.Next.URL }}" class="pagination__link pagination__link--next">
        »
      </a>
    </li>
    {{ end }}

    <!-- Last page. -->
    {{ if ne $paginator.PageNumber $paginator.TotalPages }}
    <li class="pagination__item pagination__item--last">
      <a class="pagination__link pagination__link--last" href="{{ $paginator.Last.URL }}">
        »»
      </a>
    </li>
    {{ end }}

  </ul><!-- .pagination -->
{{ end }}

There’s most likely a more concise way of coding this type of navigation so if you have any suggestions hit me up in the comments.

What next?

To take this to the next level I recommend moving the page numbers section of your code in to a Hugo partial in /layouts/partials/ and calling it something imaginative like pagination.html.

This means your pagination code can be re-used in other places around your site such as a /posts/ page, category page or taxonomy page. Hugo is smart enough to know which kind of posts to show on these pages.

Your partial can be called via {{ partial “pagination.html” . }} but it will only work if you output your posts using .Paginate in the template where you call it.

Further reading

  • Hugo’s documentation is very good and I recommend diving into the pagination docs if you still have questions.