Using Pagination in Astro

9 min read

Updated:

Introduction

Astro has a very in-depth section of the documentation devoted to Pagination. If you’re looking to do Pagination in Astro, it’s well-defined.

The documentation has a few different (but related) concepts. First, it has paths such as /astronauts/[page].astro, where [page] is the page number corresponding to the currentPage of the Pagination. When I read this, it was exactly what I wanted, so I promptly added a /posts/[page].astro.

Second, in the Static (SSG) Mode section of the docs, they use the example of /dogs/[dog].astro where [dog] is the page about that dog. When I read this, it was exactly what I wanted for a single post view, so I promptly added a /posts/[...slug].astro. Honestly, I don’t know what the destructuring dots do (e.g., ...slug). I assume it allows you multiple paths: /posts/some-other-path/my-single-post.

Both of these pages required having:

// in [...slug].astro, no parameter used in getStaticPaths
export async function getStaticPaths() {
  //... get sorted posts
  return sortedPosts.map((p) => ({
    params: { slug: p.slug },
  }));
}

const { slug } = Astro.params;
// in [page].astro, using the `paginate()` function
// SITE is a constant that uses some configuration site-wide. I saw this in a theme, and liked the pattern.
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  //... get sorted posts
  return paginate(sortedPosts, { pageSize: Number(SITE.postPerPage) });
}

// This `page` comes from the `paginate()` function, and contains some useful data:
// <https://docs.astro.build/en/reference/api-reference/#the-pagination-page-prop>
const { page } = Astro.props

So now I’ve got two pages:

  1. [page].astro - which will handle pagination for me (e.g., /post/1, /post/2, etc.)
  2. [...slug].astro - which will handle post pages for me (e.g., /post/using-pagination-in-astro)

But what if I want a third page called index.astro where I list the first pagination page?

According to the documentation:

If a page uses dynamic params in the filename, that component will need to export a getStaticPaths() function.

In other words, getStaticPaths() will never get called inside of my index.astro or /posts because it does not have a dynamic parameter, it is a Static Route. I don’t want to link users to /posts/1 when they visit my blog. There has to be a better way of accomplishing what I need.

Looking at the [page].astro. It renders a Posts component that takes in Page<CollectionEntry<'post'>> as a prop. When getStaticPaths({paginate}) is called, it will return a page prop of that type to my page. Then, I pass this page variable directly to my Posts component as a prop of the same kind. I want to reuse all this for the index.astro page because it works well.

---
// in [page].astro, using the `paginate()` function
// SITE is a constant that uses some configuration site-wide. I saw this in a theme, and liked the pattern.
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  //... get sorted posts
  return paginate(sortedPosts, { pageSize: Number(SITE.postPerPage) });
}

// This `page` comes from the `paginate()` function, and contains some useful data:
// <https://docs.astro.build/en/reference/api-reference/#the-pagination-page-prop>
const { page } = Astro.props
---
<Posts {page} />

To get index.astro to work, I need to overcome the following issues:

  1. I won’t be able to use getStaticPaths because this is not a dynamic route.
  2. If I can’t use getStaticPaths, then I can’t use paginate() because, from what I’ve seen, it’s a private function only available as a parameter to getStaticPaths.

Initial Solution

I decided I was too lazy to create a specialized component that sorted my posts in the same way as posts/[page].astro but only returned the amount found in SITE.postsPerPage, and hacked the Pagination component to show the next page. Whew, I get the willies and the chills just thinking about it.

Not only is it duplicating code, but a year+ from now, when I need to change anything about index.astro, I will be perplexed that I’m rendering three different components—2 of which render the same thing—inside:

  1. [page].astro
  2. [...slug].astro
  3. index.astro

So, I wanted to keep it more straightforward. Since I’m already passing page as a prop to my Posts component, why not “hack” that and re-use my Posts component?

Example Component

// ... get sortedPosts

const pagination = getPagination({
  items: sortedPosts,
  page: 1,
  isIndex: true,
});

// Example of the math that the `paginate()` function uses
// Ex (3 per page) (current page 1) = 0
// Ex (3 per page) (current page 2) = 3
// Ex (3 per page) (current page 3) = 6
const start = (pagination.currentPage - 1) * SITE.postPerPage;
// Ex (3 per page) (current page 1) = 2
// Ex (3 per page) (current page 2) = 5
// Ex (3 per page) (current page 3) = 8
const end = pagination.currentPage * SITE.postPerPage - 1;
// Ex (3 per page) (current page 1) = 4
// Ex (3 per page) (current page 1) = 4
// Ex (3 per page) (current page 3) = 4
const lastPage = pagination.totalPages;
// Ex (3 per page) (current page 1) = 3
// Ex (3 per page) (current page 2) = 3
// Ex (3 per page) (current page 3) = 3
let size = SITE.postPerPage;
// Ex (3 per page) (current page 1) = 10
// Ex (3 per page) (current page 2) = 10
// Ex (3 per page) (current page 3) = 10
const total = pagination.totalPages * SITE.postPerPage;

const page: Page<CollectionEntry<'post'>> = {
  data: pagination.paginatedItems,
  // Index of first item on current page, starting at 0.
  // (e.g. if pageSize: 25, this would be 0 on page 1, 25 on page 2, etc.)
  start,
  // Index of last item on current page.
  end,
  // The total number of items across all pages.
  total,
  // The current page number, starting with 1.
  currentPage: pagination.currentPage,
  // How many items per-page.
  size,
  // The total number of pages.
  lastPage,
  url: {
    current: '/posts/1',
    prev: undefined,
    next: `/posts/2`,
    first: undefined,
    last: undefined,
  },
};

A lot is going on in this snippet. So, let me break it down.

First, we get the posts and sort them. However you want to sort your posts. By the way, for me, it looks like this:

const posts = await getCollection('post');
const featuredPost = await getCollection('post', ({ data }) => {
  // only return isFeatured === true
  return data.featured.isFeatured;
});

const sortedPosts = getPostsSortedByDate(posts);

But it’s better to just say //... get sortedPosts since you can fetch posts from any source. I just happen to be using Markdown.

Next, I call a getPagination function, which I stole from a theme called Astro Paper. The theme is excellent; if you’re looking for something quick and easy to learn Astro, I’d suggest looking at it. It taught me a lot when I was initially picking up Astro.

The getPagination function looks like this:

interface GetPaginationProps<T> {
  items: T;
  page: string | number;
  isIndex?: boolean;
}

const getPagination = <T>({ items, page, isIndex = false }: GetPaginationProps<T[]>) => {
  const totalPagesArray = getPageNumbers(items.length);
  const totalPages = totalPagesArray.length;

  const currentPage = isIndex
    ? 1
    : page && !isNaN(Number(page)) && totalPagesArray.includes(Number(page))
      ? Number(page)
      : 0;

  const lastPost = isIndex ? SITE.postPerPage : currentPage * SITE.postPerPage;
  const startPost = isIndex ? 0 : lastPost - SITE.postPerPage;
  const paginatedItems = items.slice(startPost, lastPost);

  return {
    totalPages,
    currentPage,
    paginatedItems,
  };
};

const getPageNumbers = (numberOfPosts: number) => {
  const numberOfPages = numberOfPosts / Number(SITE.postPerPage);

  let pageNumbers: number[] = [];
  for (let i = 1; i <= Math.ceil(numberOfPages); i++) {
    pageNumbers = [...pageNumbers, i];
  }

  return pageNumbers;
};

You can read the JavaScript to understand what it’s doing in more detail, but basically, it does the following:

  1. Takes the total number of posts and chunks them according to our site setting of postPerPage.
  2. Gets the Current page
  3. The last post number for that page
  4. The starting post number for that page
  5. Then, perform some array aerobatics to return a slice of those posts for that page.

This is essentially what the paginate() function is doing while returning much more metadata.

I use the information returned via this function to populate the metadata for posts. There’s some math involved, but it really only needs to be good enough for the first page. Once the user clicks “Next” on your pagination, it should link them to /posts/2, and if they click “Previous,” it should link them to /posts/1.

Once we’re on a /posts/[page].astro, we’ve handed it off to Astro’s built-in and excellent paginate function.

Conclusion

I learned that getStaticPaths is only used on pages with dynamic routes and not for static routes. An alternative must be used on a static route like index.astro.

Since my static route only needs to be good enough to hand off to the dynamic route, I can significantly simplify this process. For example, instead of having a paginate function, what if I just did:

const posts = await getCollection('post');
const featuredPost = await getCollection('post', ({ data }) => {
  return data.featured.isFeatured;
});

const sortedPosts = getPostsSortedByDate(posts);

// get rid of getPagination function
const paginatedItems = sortedPosts.slice(0, SITE.postPerPage);

const start = 0;
const end = SITE.postPerPage - 1;
const lastPage = sortedPosts.length;
let size = SITE.postPerPage;
const total = sortedPosts.length * SITE.postPerPage;

const page: Page<CollectionEntry<'post'>> = {
  data: paginatedItems,
  // Index of first item on current page, starting at 0.
  // (e.g. if pageSize: 25, this would be 0 on page 1, 25 on page 2, etc.)
  start,
  // Index of last item on current page.
  end,
  // The total number of items across all pages.
  total,
  // The current page number, starting with 1.
  currentPage: 1,
  // How many items per-page.
  size,
  // The total number of pages.
  lastPage,
  url: {
    current: '/posts/1',
    prev: undefined,
    next: `/posts/2`,
    first: undefined,
    last: undefined,
  },
};

I only need the index.astro page to be good enough to hand off to the dynamic route. Doing it this way, I can reuse my Posts component:

<Posts {page} />

On both the index.astro and [page].astro, the logic to inject page doesn’t really matter. Since it’s a component, it doesn’t care how it gets its props, just that it can use them.

Then, on the [...slug].astro page, I render the markdown. This solution feels nice to me, and I have learned a lot about this aspect of Astro, so I thought I’d share it in this post. Hopefully, you were able to learn something, too.