Astro Collection Pagination Using Rest Parameter Routes
Astro has built-in pagination to split collections into multiple pages, with each page containing a specified number of entries. This feature works with dynamic routes, allowing for URLs like /blog/, /blog/2/, /blog/3/, and so on.
Astro’s pagination via paginate() is designed around static generation, meaning page boundaries are computed at build time rather than runtime.
Because of this, getStaticPaths is responsible not only for route generation, but also for determining which collection entries belong to each page. Pagination therefore becomes a compile-time step rather than something calculated at runtime.
Rest Parameter Route vs Named Page Route
Astro pagination can be implemented using either a rest parameter route ([...page].astro) or a named dynamic route (/page/[n].astro).
The rest parameter approach allows /blog/ to serve as the first page while additional pages such as /blog/2/ and /blog/3/ are handled by the same dynamic route. When page 1 is intended to use the collection’s root URL, this avoids introducing a separate route definition or redirect for the first page. This also avoids duplicate URLs such as both /blog/ and /blog/1/, allowing the collection root to remain the canonical first page.
In contrast, a named route may require additional handling to keep page 1 at /blog/, such as a separate route or redirect.
Astro Project Structure
The following project structure shows an example using a collection named blog containing 25 entries. There is also a dynamic route called [...page].astro which contains the pagination implementation. The [...slug].astro file handles individual post pages and is outside the scope of this article.
AstroProject/
└── src/
├── content/
│ └── blog/
│ ├── post1.mdx
│ ├── post2.mdx
│ ├── post3.mdx
│ ├── ...
│ └── post25.mdx
└── pages/
└── blog/
├── [...slug].astro
└── [...page].astro
Implementing Pagination
In the [...page].astro file, add the following code segment to the frontmatter. Adjust the collection name as needed.
This example assumes the blog collection schema defines an optional pubDate field, used here to sort entries newest first.
Key parts of the code are:
import type { GetStaticPaths } from 'astro';imports helper types to work with results returned bygetStaticPathsin dynamic routes.satisfies GetStaticPathsvalidates the return value against Astro’s expected type while preserving the inferred types.- The
pageSizeis set to10entries per page.
---
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async ({paginate}) => {
const posts = (await getCollection('blog'))
.sort((a, b) => (b.data.pubDate?.valueOf() ?? 0) - (a.data.pubDate?.valueOf() ?? 0));
return paginate(posts, { pageSize: 10 } );
}) satisfies GetStaticPaths;
const { page } = Astro.props;
const pageNumbers = Array.from({length: page.lastPage}, (_, i) => i + 1);
---
Fixing the never Type Error in page.data
An issue may occur where TypeScript returns the error Property 'data' does not exist on type 'never' when the page variable is not correctly inferred. The fix, shown in the code above, is to add satisfies GetStaticPaths to the getStaticPaths export.
This occurs because paginate() returns a generic type derived from the supplied collection. Without satisfies GetStaticPaths, TypeScript may fail to preserve that inferred type, causing page.data to resolve to never. Using satisfies GetStaticPaths validates the return value while preserving inference so page is correctly typed as Page<T>.
Rendering the Current Page Entries
Add the following to [...page].astro to access and list each entry or post for the current page.
{
page.data.map((post) => (
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
))
}
Adding Page Navigation
Include the following in [...page].astro to add page navigation links, such as “Previous Page”, “Next Page”, and numbered pages.
An extra condition ensures that the first page links to /blog/ instead of /blog/1/. Astro automatically generates the correct collapsed URL for page.url.prev and page.url.next, but the numbered page links are built manually here, so the same first-page URL handling has to be applied explicitly with the ternary.
The current page is rendered as a <span> with aria-current="page" rather than a link, and each remaining page link includes an aria-label so screen readers announce a clear label such as Page 3 instead of just the bare number.
{
(page.lastPage > 1) && (
<nav aria-label="Pagination">
{ page.url.prev && ( <a href={page.url.prev}>Previous Page</a> ) }
{ pageNumbers.map((num) => (
num === page.currentPage
? <span aria-current="page">{num}</span>
: <a href={num === 1 ? '/blog/' : `/blog/${num}/`} aria-label={`Page ${num}`}>{num}</a>
))
}
{ page.url.next && ( <a href={page.url.next}>Next Page</a> ) }
</nav>
)
}
Summary
Astro’s built-in paginate() API works well with rest parameter routes, producing clean URLs while keeping pagination entirely static. Combined with satisfies GetStaticPaths, it also preserves TypeScript inference, avoiding the common page.data type issue and making it straightforward to build accessible page navigation.