Pagination

Introduction

In web applications, we use pagination to divide a large set of data into smaller chunks. This allows us to display only a few items at a time, and provide controls to navigate between the pages.

You will be familiar with pagination from using any ecommerce website. For example, when you search for a product on Amazon, you will see a list of results with a maximum of 48 items. If there are more than 48 results, you will see a pagination control at the bottom of the page, allowing you to navigate to the next page of results.

Pagination is useful in situations where you have a large set of data that you want to display to the user, but you don’t want to overwhelm them with too much information at once. It also helps to improve the performance of your application, as you only need to load a small amount of data at a time.

Pagination Basics

Let’s take a look at a simple pagination implementation using JSON Placeholder data. We will use the posts endpoint to get a list of posts, and display them in a table with pagination controls. First, let’s setup the HTML structure for our page:

html
	<main>
	  <article></article>
	  <nav class="pagination"></nav>
	</main>

The article element is empty, as this will be populated by our JavaScript code. The nav element will contain our pagination controls. Next, let’s create a function to fetch the posts from the API:

javascript
	export async function getPosts() {
	  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
	  const data = await response.json();
	
	  if (response.ok) {
	    return data;
	  }
	
	  throw new Error(data.message);
	};

Next we need a function to render the posts to the page:

javascript
	export function renderPost(post, parent) {
	  const postElement = document.createElement('div');
	  postElement.classList.add('post');
	  const postTitle = document.createElement('h2');
	  postTitle.textContent = post.title;
	  const postBody = document.createElement('p');
	  postBody.textContent = post.body;
	  postElement.append(postTitle, postBody)
	  parent.append(postElement);
	}
	
	export function renderPosts(posts) {
	  const article = document.querySelector('article');
	  posts.forEach(post => renderPost(post, article));
	};

Next, we will need a function to convert the array of posts into an array of pages. This function will take the array of posts, and the number of posts per page as arguments, and return an array of pages. Each page will be an array of posts.

javascript
	function paginate(items, itemsPerPage) {
	  const totalPages = Math.ceil(items.length / itemsPerPage);
	  const pages = [];
	
	  for (let i = 0; i < totalPages; i++) {
	    const start = i * itemsPerPage;
	    const end = start + itemsPerPage;
	    pages.push(items.slice(start, end));
	  }
	
	  return pages;
	}

This function takes a long array and splits it into an array of smaller arrays, each with a limited number of items. For example, if we have an array of 10 items, and we want to display 3 items per page, we will end up with 4 pages. The first page will contain items 0, 1, and 2. The second page will contain items 3, 4, and 5. The third page will contain items 6, 7, and 8. The fourth page will contain item 9.

Next, we need a function to render the pagination controls. This function will take the array of pages as an argument, and render a button for each page. When a button is clicked, we will call the renderPosts function to display the posts for that page.

javascript
	function renderPagination(paginatedPosts) {
	  const pagination = document.querySelector('.pagination');
	  const article = document.querySelector('article');
	  pagination.innerHTML = '';
	
	  paginatedPosts.forEach((page, index) => {
	    const button = document.createElement('button');
	    button.textContent = index + 1;
	    button.addEventListener('click', () => {
	      article.innerHTML = '';
	      renderPosts(page);
	    });
	    pagination.append(button);
	  });
	}

Finally, we need a function to initialise our page. This function will fetch the posts from the API, paginate them, and render the first page of posts.

javascript
	import { paginate, renderPagination } from './pagination.js';
	import { getPosts, renderPosts } from './posts.js';
	
	export async function setupPostsPage() {
	  const posts = await getPosts();
	  const paginatedPosts = paginate(posts, 10);
	  renderPosts(paginatedPosts[0]);
	  renderPagination(paginatedPosts);
	}

Improving the Pagination

Although we have covered how to create a working pagination system, there are some additional improvements we could add. For example, we could add next and previous buttons to the pagination controls, and disable them when we are on the first or last page. We could also add a button to jump to the first page, and a button to jump to the last page.

javascript
	export function renderNextButton(paginatedPosts, currentPage) {
	  const pagination = document.querySelector('.pagination');
	  const button = document.createElement('button');
	  button.textContent = 'Next';
	  button.addEventListener('click', () => {
	    article.innerHTML = '';
	    renderPosts(paginatedPosts[currentPage + 1]);
	  });
	  pagination.append(button);
	}
	
	export function renderPreviousButton(paginatedPosts, currentPage) {
	  const pagination = document.querySelector('.pagination');
	  const button = document.createElement('button');
	  button.textContent = 'Previous';
	  button.addEventListener('click', () => {
	    article.innerHTML = '';
	    renderPosts(paginatedPosts[currentPage - 1]);
	  });
	  pagination.append(button);
	}
	
	export function renderFirstButton(paginatedPosts) {
	  const pagination = document.querySelector('.pagination');
	  const button = document.createElement('button');
	  button.textContent = 'First';
	  button.addEventListener('click', () => {
	    article.innerHTML = '';
	    renderPosts(paginatedPosts[0]);
	  });
	  pagination.append(button);
	}
	
	export function renderLastButton(paginatedPosts) {
	  const pagination = document.querySelector('.pagination');
	  const button = document.createElement('button');
	  button.textContent = 'Last';
	  button.addEventListener('click', () => {
	    article.innerHTML = '';
	    renderPosts(paginatedPosts[paginatedPosts.length - 1]);
	  });
	  pagination.append(button);
	}
	
	export function renderPagination(paginatedPosts, currentPage) {
	  const pagination = document.querySelector('.pagination');
	  const article = document.querySelector('article');
	  pagination.innerHTML = '';
	
	  if (currentPage > 0) {
	    // Only show the previous and first buttons if we are not on the first page
	    renderFirstButton(paginatedPosts);
	    renderPreviousButton(paginatedPosts, currentPage);
	  }
	
	  paginatedPosts.forEach((page, index) => {
	    const button = document.createElement('button');
	    button.textContent = index + 1;
	    button.addEventListener('click', () => {
	      article.innerHTML = '';
	      renderPosts(page);
	    });
	    pagination.append(button);
	  });
	
	  if (currentPage < paginatedPosts.length - 1) {
	    // Only show the next and last buttons if we are not on the last page
	    renderNextButton(paginatedPosts, currentPage);
	    renderLastButton(paginatedPosts);
	  }
	}

We can also improve on the structure of the pagination data, by adding metadata about the current page, and the total number of pages. This will allow us to display the current page number, and the total number of pages in the pagination controls.

javascript
	function paginate(items, itemsPerPage, currentPage = 0) {
	  const totalPages = Math.ceil(items.length / itemsPerPage);
	  const pages = [];
	
	  for (let i = 0; i < totalPages; i++) {
	    const start = i * itemsPerPage;
	    const end = start + itemsPerPage;
	    pages.push(items.slice(start, end));
	  }
	
	  return {
	    pages,
	    currentPage,
	    totalPages,
	    nextPage: currentPage + 1,
	    previousPage: currentPage - 1,
	  };
	};

Although this will work in some cases, it will cause problems if we try to navigate to a page that doesn’t exist. For example, if we are on page 1, and we click the previous button, we will try to navigate to page 0. This will cause an error, as there is no page 0. We can fix this by adding some checks to our pagination functions.

javascript
	function paginate(items, itemsPerPage, currentPage = 0) {
	  const totalPages = Math.ceil(items.length / itemsPerPage);
	  const pages = [];
	
	  for (let i = 0; i < totalPages; i++) {
	    const start = i * itemsPerPage;
	    const end = start + itemsPerPage;
	    pages.push(items.slice(start, end));
	  }
	
	  return {
	    pages,
	    currentPage,
	    totalPages,
	    nextPage: currentPage + 1 < totalPages ? currentPage + 1 : null,
	    previousPage: currentPage - 1 >= 0 ? currentPage - 1 : null,
	  };
	};

These additional expressions will check if the next or previous page exists, and if it does, it will return the page number. If the next or previous page doesn’t exist, it will return null. We can then use this information to disable the next and previous buttons when we are on the first or last page.

Alternatively, we could loop back to the first page when we reach the last page, and loop back to the last page when we reach the first page. This would allow us to remove the checks for the next and previous pages, and simplify our code.

javascript
	function paginate(items, itemsPerPage, currentPage = 0) {
	  const totalPages = Math.ceil(items.length / itemsPerPage);
	  const pages = [];
	
	  for (let i = 0; i < totalPages; i++) {
	    const start = i * itemsPerPage;
	    const end = start + itemsPerPage;
	    pages.push(items.slice(start, end));
	  }
	
	  return {
	    pages,
	    currentPage,
	    totalPages,
	    nextPage: (currentPage + 1) % totalPages,
	    previousPage: (currentPage - 1 + totalPages) % totalPages,
	  };
	};

In this example we use the modulo operator to loop back to the first page when we reach the last page, and loop back to the last page when we reach the first page. Modulo is used to find the remainder after dividing one number into another, for example 5 % 2 = 1, as 5 divided by 2 is 2 with a remainder of 1. We can use this to loop back to the first page when we reach the last page, and loop back to the last page when we reach the first page.

Rendering Metadata

Now that we have added metadata to our pagination data, we can use this to display the current page number, and the total number of pages in the pagination controls.

javascript
	export function renderPagination(paginatedPosts) {
	  const pagination = document.querySelector('.pagination');
	  const article = document.querySelector('article');
	  pagination.innerHTML = '';
	
	  const currentPage = paginatedPosts.currentPage;
	  const totalPages = paginatedPosts.totalPages;
	
	  if (currentPage > 0) {
	    // Only show the previous and first buttons if we are not on the first page
	    renderFirstButton(paginatedPosts);
	    renderPreviousButton(paginatedPosts, currentPage);
	  }
	
	  paginatedPosts.forEach((page, index) => {
	    const button = document.createElement('button');
	    button.textContent = index + 1;
	    button.addEventListener('click', () => {
	      article.innerHTML = '';
	      renderPosts(page);
	    });
	    pagination.append(button);
	  });
	
	  if (currentPage < paginatedPosts.length - 1) {
	    // Only show the next and last buttons if we are not on the last page
	    renderNextButton(paginatedPosts, currentPage);
	    renderLastButton(paginatedPosts);
	  }
	
	  const pageCounter = document.createElement('p');
	  pageCounter.textContent = `Page ${currentPage + 1} of ${totalPages}`;
	  pagination.append(pageCounter);
	}

Server Side Pagination

In some cases, the API endpoint we are using will come pre-paginated by the server. This means that the server will return a limited number of items, and provide metadata about the total number of items, and the current page. This allows us to display the current page number, and the total number of pages in the pagination controls.

Let’s take a look at an example paginated API response:

json
	{
	  "data": [
	    {
	      "id": 1,
	      "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
	      "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
	    },
	    {
	      "id": 2,
	      "title": "qui est esse",
	      "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
	    },
	    {
	      "id": 3,
	      "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
	      "body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut"
	    }
	  ],
	  "page": 1,
	  "totalPages": 10,
	  "totalItems": 30
	}

In this fictional response, we only have access to one page at a time. In order to retrieve the next page, we need to make another request to the API, and provide the page number as a query parameter. For example, to get the second page of results, we would make a request to https://example.com/posts?page=2.

This means that clicking the pagination controls will require another API request, which will take some time to complete. Let’s update the code with asynchronous functions to handle this.

javascript
	export async function getProducts(page = 1) {
	  const response = await fetch(`https://v2.api.noroff.dev/online-shop?page=${page}`);
	  const data = await response.json();
	
	  if (response.ok) {
	    return data;
	  }
	
	  throw new Error("Could not fetch products");
	};

Now we can update the pagination function to use the new API response format.

javascript
	function renderPagination(paginatedProducts) {
	  const pagination = document.querySelector('.pagination');
	  const article = document.querySelector('article');
	  pagination.innerHTML = '';
	
	  const currentPage = paginatedProducts.page;
	  const totalPages = paginatedProducts.totalPages;
	
	  if (currentPage > 1) {
	    // Only show the previous and first buttons if we are not on the first page
	    renderFirstButton(paginatedProducts);
	    renderPreviousButton(paginatedProducts, currentPage);
	  }
	
	  paginatedProducts.data.forEach((product, index) => {
	    const button = document.createElement('button');
	    button.textContent = index + 1;
	    button.addEventListener('click', async () => {
	      article.innerHTML = '';
	      const products = await getProducts(index + 1);
	      renderProducts(products);
	    });
	    pagination.append(button);
	  });
	
	  if (currentPage < paginatedProducts.totalPages) {
	    // Only show the next and last buttons if we are not on the last page
	    renderNextButton(paginatedProducts, currentPage);
	    renderLastButton(paginatedProducts);
	  }
	
	  const pageCounter = document.createElement('p');
	  pageCounter.textContent = `Page ${currentPage} of ${totalPages}`;
	  pagination.append(pageCounter);
	}

We are now set up to dynamically fetch the next page of data when the user clicks the button.

Prefetching

Although the above example works to fetch the next page of data when the user clicks the button, it would be better if we could fetch the next page of data before the user clicks the button. This would allow us to display the next page of data instantly, without having to wait for the API request to complete.

We can do this by using the prefetch attribute on the button element. This attribute allows us to specify a URL to prefetch, which will be loaded in the background. This means that when the user clicks the button, the data will already be loaded, and we can display it instantly.

javascript
	export function renderPrefetchButton(paginatedProducts, target, title) {
	  const url = `https://v2.api.noroff.dev/online-shop?page=${target}`;
	  const button = document.createElement('button');
	  button.textContent = title;
	  button.setAttribute('prefetch', url);
	  return button;
	}
	
	const nextButton = renderPrefetchButton(paginatedProducts, currentPage, 'Next');

Let’s implement this in the main render function:

javascript
	function renderPagination(paginatedProducts) {
	  const pagination = document.querySelector('.pagination');
	  const article = document.querySelector('article');
	  pagination.innerHTML = '';
	
	  const currentPage = paginatedProducts.page;
	  const totalPages = paginatedProducts.totalPages;
	
	  if (currentPage > 1) {
	    // Only show the previous and first buttons if we are not on the first page
	    renderFirstButton(paginatedProducts);
	    renderPreviousButton(paginatedProducts, currentPage);
	  }
	
	  paginatedProducts.data.forEach((product, index) => {
	    const button = createPrefetchButton(paginatedProducts, index + 1);
	    button.addEventListener('click', async () => {
	      article.innerHTML = '';
	      const products = await getProducts(index + 1);
	      renderProducts(products);
	    });
	    pagination.append(button);
	  });
	
	  if (currentPage < paginatedProducts.totalPages) {
	    // Only show the next and last buttons if we are not on the last page
	    renderNextButton(paginatedProducts, currentPage);
	    renderLastButton(paginatedProducts);
	  }
	
	  const pageCounter = document.createElement('p');
	  pageCounter.textContent = `Page ${currentPage} of ${totalPages}`;
	  pagination.append(pageCounter);
	}

Summary

In this lesson we have covered how to implement pagination in a web application. We have covered the basics of pagination, and how to implement it using JavaScript. We have also covered how to improve the pagination by adding next and previous buttons, how to add metadata about the current page, and the total number of pages. Finally, we have covered how to implement server side pagination, and how to prefetch the next page of data to improve the user experience.

Lesson Task

Create a simple Cat Facts browser application using either of these two API endpoints:

• CAT FACTS Documentation - https://docs.noroff.dev/docs/v2/basic/cat-facts API - https://v2.api.noroff.dev/cat-facts

The application should display 12 products at a time, with buttons to load the next and previous pages of products. The application should also display the current page number, and the total number of pages.

Additional Goals

Use the perPage and page query parameters to asynchronously load each next page instead of storing all cat facts in one array.