Type something to search...
How to add pagination to an Astro blog?

How to add pagination to an Astro blog?

To add pagination to an Astro blog, create a dynamic page such as src/pages/blog/[...page].astro, load and sort your posts inside getStaticPaths(), and return paginate(posts, { pageSize: 10 }). Astro builds one page per chunk of posts, so /blog, /blog/2, /blog/3 and so on, and passes each one a page prop. That prop holds the posts for the current page in page.data, plus everything you need for navigation: page.currentPage, page.lastPage, and ready-made URLs in page.url.prev and page.url.next.

Pagination is built into Astro's routing, so you don't need a plugin or any client-side JavaScript. In this article you'll learn how to set up a paginated blog index from a content collection, what's inside the page prop, how to build previous/next links and numbered page links, how to control trailing slashes on generated URLs with the format option added in Astro 7.1, how to paginate tag archives with nested pagination, and the mistakes that cause empty or out-of-order pages.

How Pagination Works in Astro

Pagination is a special case of a dynamic route. You give Astro an array, it splits the array into pages, and it generates one static route per page. The paginate() function is passed to getStaticPaths() as an option, and it returns exactly the array of params and props that getStaticPaths() needs.

The file name decides what the first page's URL looks like:

File nameURLs generated
blog/[page].astro/blog/1, /blog/2, /blog/3
blog/[...page].astro/blog, /blog/2, /blog/3

For a blog, the rest parameter version is almost always what you want: page one lives at /blog rather than /blog/1, so your blog index and the first page of results are the same URL. If rest parameters are new to you, see rest parameters for catch-all routes.

Setting Up the Collection

The examples below paginate a content collection of Markdown posts. In Astro 6 and later, collections are defined in src/content.config.ts with a loader:

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

For testing I created seven posts, post-1.md to post-7.md, with dates from 1 to 7 September. If you're new to collections, what content collections are in Astro explains the setup in more depth.

Building the Paginated Index

Here is the complete paginated blog index:

---
// src/pages/blog/[...page].astro
import type { GetStaticPaths } from "astro";
import { getCollection } from "astro:content";
import Pagination from "../../components/Pagination.astro";

export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection("blog")).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );
  return paginate(posts, { pageSize: 3 });
}) satisfies GetStaticPaths;

const { page } = Astro.props;
---
<html lang="en">
  <head>
    <title>Blog, page {page.currentPage}</title>
    <link rel="canonical" href={new URL(page.url.current, Astro.site)} />
  </head>
  <body>
    <h1>Blog</h1>
    <p>Showing {page.start + 1} to {page.end + 1} of {page.total} posts</p>
    <ul>
      {page.data.map((post) => (
        <li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
      ))}
    </ul>
    <Pagination page={page} />
  </body>
</html>

The important steps:

  1. Load the posts: getCollection("blog") returns every entry.
  2. Sort them yourself: collections aren't returned in date order, so sort newest first before paginating.
  3. Call paginate(): pass the sorted array and a pageSize. The default is 10.
  4. Read the page prop: every generated page receives page, with that page's posts in page.data.

With seven posts and a page size of three, the build produced three pages:

/blog/index.html
/blog/2/index.html
/blog/3/index.html

The last page holds the single remaining post. Its summary line rendered as Showing 7 to 7 of 7 posts, because page.start and page.end are zero-based indexes into the full list.

The canonical link uses page.url.current combined with the site value from your config, which I set to https://example.com. On page two it rendered as https://example.com/blog/2. Canonical URLs matter for paginated archives, and SEO meta tags in Astro covers them in more detail.

What's Inside the page Prop

The page prop has everything you need to render a page of results and link between pages:

PropertyTypeDescription
page.dataArrayThe items on the current page
page.startnumberIndex of the first item on this page, from 0
page.endnumberIndex of the last item on this page
page.sizenumberItems per page
page.totalnumberTotal items across all pages
page.currentPagenumberCurrent page number, from 1
page.lastPagenumberTotal number of pages
page.url.currentstringURL of this page
page.url.prevstring or undefinedPrevious page, or undefined on page 1
page.url.nextstring or undefinedNext page, or undefined on the last page
page.url.firststring or undefinedFirst page, or undefined on page 1
page.url.laststring or undefinedLast page, or undefined on the last page

If you set a base in your config, the URLs in page.url already include it.

Previous and Next Links

A small component keeps the navigation reusable across the blog index and any archive pages:

---
// src/components/Pagination.astro
import type { Page } from "astro";

interface Props {
  page: Page;
}

const { page } = Astro.props;
---
<nav aria-label="Pagination">
  {page.url.prev ? <a href={page.url.prev} rel="prev">Newer posts</a> : null}
  <span>Page {page.currentPage} of {page.lastPage}</span>
  {page.url.next ? <a href={page.url.next} rel="next">Older posts</a> : null}
</nav>

Because prev and next are undefined at the ends, the conditional rendering removes the link automatically. On page two the built HTML was:

<nav aria-label="Pagination"><a href="/blog" rel="prev">Newer posts</a><span>Page 2 of 3</span><a href="/blog/3" rel="next">Older posts</a></nav>

Page two links back to /blog and forward to /blog/3. Note that page.url.prev on page two points to /blog, not /blog/1, because the rest parameter puts page one at the folder URL. Page one shows only the "Older posts" link, and page three shows only "Newer posts". For more on conditional markup like this, see conditional rendering in Astro.

Controlling Trailing Slashes in Page URLs

Notice the URLs in page.url have no trailing slash: /blog/3, not /blog/3/. If the rest of your site uses trailing slashes, you have two options.

The first is the global trailingSlash option. When I set trailingSlash: "always" in the config and rebuilt, the generated links changed to /tags/astro/ and /tags/astro/3/ automatically.

The second is the format option for paginate(), added in Astro 7.1. It receives each computed URL and returns the URL you want, without changing the rest of your site's settings:

---
// src/pages/blog/[...page].astro (getStaticPaths only)
export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection("blog")).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );
  return paginate(posts, {
    pageSize: 3,
    format: (url) => (url.endsWith("/") ? url : `${url}/`),
  });
}) satisfies GetStaticPaths;
---

Logging the input showed the function receives URLs like /blog and /blog/2. After the change, page two's navigation became:

<nav aria-label="Pagination"><a href="/blog/" rel="prev">Newer posts</a><span>Page 2 of 3</span><a href="/blog/3/" rel="next">Older posts</a></nav>

The canonical URL on page one also became https://example.com/blog/, since page.url.current passes through the same function.

Numbered Page Links

Previous and next links are enough for many blogs, but a row of page numbers helps readers jump around a larger archive. The page prop doesn't include a list of every page's URL, so build them from page.lastPage:

---
// src/components/PageNumbers.astro
import type { Page } from "astro";

interface Props {
  page: Page;
  base: string; // e.g. "/blog"
}

const { page, base } = Astro.props;
const numbers = Array.from({ length: page.lastPage }, (_, i) => i + 1);
const href = (n: number) => (n === 1 ? `${base}/` : `${base}/${n}/`);
---
<ol class="page-numbers">
  {numbers.map((n) => (
    <li>
      <a href={href(n)} aria-current={n === page.currentPage ? "page" : undefined}>
        {n}
      </a>
    </li>
  ))}
</ol>

Use it next to the other component with <PageNumbers page={page} base="/blog" />. On page two it produced:

<ol class="page-numbers"><li><a href="/blog/">1</a></li><li><a href="/blog/2/" aria-current="page">2</a></li><li><a href="/blog/3/">3</a></li></ol>

aria-current="page" tells screen readers which page is active, and gives you a hook for styling the current number. Setting aria-current to undefined on the other links removes the attribute entirely. For archives with dozens of pages, show a window of numbers around the current page rather than all of them.

Nested Pagination for Tag Pages

Tag and category archives need pagination too. Nested pagination combines paginate() with another route parameter: you return one paginated set per tag and flatten them into a single array.

---
// src/pages/tags/[tag]/[...page].astro
import type { GetStaticPaths } from "astro";
import { getCollection } from "astro:content";
import Pagination from "../../../components/Pagination.astro";

export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection("blog")).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );
  const tags = [...new Set(posts.flatMap((post) => post.data.tags))];

  return tags.flatMap((tag) =>
    paginate(
      posts.filter((post) => post.data.tags.includes(tag)),
      { params: { tag }, props: { tag }, pageSize: 2 },
    ),
  );
}) satisfies GetStaticPaths;

const { page, tag } = Astro.props;
---
<html lang="en">
  <head>
    <title>Posts tagged {tag}</title>
  </head>
  <body>
    <h1>Posts tagged “{tag}”</h1>
    <ul>
      {page.data.map((post) => (
        <li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
      ))}
    </ul>
    <Pagination page={page} />
  </body>
</html>

Two options make this work:

  • params: : tells paginate() which value to put in the [tag] segment. Without it, Astro can't build the URLs.
  • props: : passes extra data to every page in that set, here so the heading can show the tag name.

With five posts tagged astro and two tagged css, the build generated:

/tags/astro/index.html
/tags/astro/2/index.html
/tags/astro/3/index.html
/tags/css/index.html

Building complete tag and category pages, including an index of all tags, is covered in tags and category pages in Astro.

Common Mistakes

  • Forgetting to sort: getCollection() doesn't sort by date. Sort before calling paginate(), or your newest post could land on page three.
  • Using [page].astro for a blog index: it creates /blog/1 and nothing at /blog. Use [...page].astro so page one is the index.
  • Filtering after paginating: remove drafts with the getCollection() filter callback or .filter() before paginate(), otherwise pages can end up short or empty.
  • Missing params in nested pagination: each paginate() call needs params for every other bracket in the path.
  • Expecting on-demand pagination: paginate() works with getStaticPaths(), which only runs for prerendered routes. For on-demand pages, read a page number from Astro.params or the query string and slice the data yourself.

FAQ: Pagination in Astro

No. Pagination is built into Astro. The paginate() function is passed to getStaticPaths() and generates one static page per chunk of data, with no client-side JavaScript required.

Name the file [...page].astro instead of [page].astro. With a rest parameter, the first page is generated at the folder URL and later pages at /blog/2, /blog/3 and so on.

Ten by default. Set the pageSize option to change it, for example a pageSize of 6.

Either set trailingSlash to always in your Astro config, which Astro applies to the generated URLs, or use the format option on paginate(), available since Astro 7.1, to return each URL with a trailing slash.

Yes. Use nested pagination: name the file with an extra parameter, such as tags/[tag]/[...page].astro, and return one paginate() result per tag with params containing the tag value.

No. paginate() is used inside getStaticPaths(), which only runs for prerendered routes. On-demand pages need to read the page number from the URL and slice the data manually.


Conclusion

Astro's built-in pagination takes a sorted array and turns it into a set of static pages with almost no code. Name the file [...page].astro, return paginate(posts, { pageSize }) from getStaticPaths(), and render page.data along with the links in page.url. A small Pagination component keeps the navigation consistent across your index and archive pages.

From there, it's easy to extend: add numbered links built from page.lastPage, normalise URLs with trailingSlash or the newer format option, and use nested pagination to give every tag its own paginated archive. Sort first, filter first, and your readers will always find the newest posts on page one.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper