
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 name | URLs 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:
- Load the posts:
getCollection("blog")returns every entry. - Sort them yourself: collections aren't returned in date order, so sort newest first before paginating.
- Call paginate(): pass the sorted array and a
pageSize. The default is 10. - Read the page prop: every generated page receives
page, with that page's posts inpage.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:
| Property | Type | Description |
|---|---|---|
page.data | Array | The items on the current page |
page.start | number | Index of the first item on this page, from 0 |
page.end | number | Index of the last item on this page |
page.size | number | Items per page |
page.total | number | Total items across all pages |
page.currentPage | number | Current page number, from 1 |
page.lastPage | number | Total number of pages |
page.url.current | string | URL of this page |
page.url.prev | string or undefined | Previous page, or undefined on page 1 |
page.url.next | string or undefined | Next page, or undefined on the last page |
page.url.first | string or undefined | First page, or undefined on page 1 |
page.url.last | string or undefined | Last 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 callingpaginate(), or your newest post could land on page three. - Using [page].astro for a blog index: it creates
/blog/1and nothing at/blog. Use[...page].astroso page one is the index. - Filtering after paginating: remove drafts with the
getCollection()filter callback or.filter()beforepaginate(), otherwise pages can end up short or empty. - Missing params in nested pagination: each
paginate()call needsparamsfor every other bracket in the path. - Expecting on-demand pagination:
paginate()works withgetStaticPaths(), which only runs for prerendered routes. For on-demand pages, read a page number fromAstro.paramsor 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.


