Pagination
Long listings — blog archives, product catalogs, search results — rarely belong on a single page. Astro ships a first-class paginate() helper that slices a collection into evenly sized chunks and emits one statically rendered route per chunk. Because the work happens at build time, every page ships as plain HTML with zero client-side JavaScript, so pagination stays fast and SEO-friendly by default.
How paginate() works
paginate() is available on the object passed to getStaticPaths(). You give it an array of items plus options, and it returns the exact array shape getStaticPaths() expects: one entry per page, each with params and props. The helper does all the math — total pages, slice boundaries, and the navigation URLs — so you never compute offsets by hand.
The route file must contain a [page] rest-free dynamic segment (or use the index for page one). A typical setup lives at src/pages/blog/[page].astro:
---
import type { GetStaticPaths, Page } from "astro";
import { getCollection } from "astro:content";
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: 5 });
}) satisfies GetStaticPaths;
type BlogPost = Awaited<ReturnType<typeof getCollection<"blog">>>[number];
const { page } = Astro.props as { page: Page<BlogPost> };
---
<h1>Blog — page {page.currentPage} of {page.lastPage}</h1>
<ul>
{page.data.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
</li>
))}
</ul>
This generates /blog/1/, /blog/2/, and so on — one route for every five posts.
The Page object
Every paginated route receives a page prop typed as Page<T>. It carries both the slice of data for the current page and all the metadata you need to render navigation.
| Property | Type | Description |
|---|---|---|
page.data | T[] | The items belonging to the current page. |
page.start | number | Zero-based index of the first item on this page. |
page.end | number | Zero-based index of the last item on this page. |
page.total | number | Total number of items across all pages. |
page.currentPage | number | The current page number (1-based). |
page.size | number | The pageSize you requested. |
page.lastPage | number | The total number of pages. |
page.url.current | string | URL of the current page. |
page.url.prev | string | undefined | URL of the previous page, or undefined on page one. |
page.url.next | string | undefined | URL of the next page, or undefined on the last page. |
page.url.first | string | undefined | URL of the first page. |
page.url.last | string | undefined | URL of the last page. |
Building navigation links
The url.prev and url.next values are undefined at the boundaries, which makes conditional rendering clean — no manual bounds checks. Render the controls directly from the page object:
---
const { page } = Astro.props;
---
<nav aria-label="Pagination">
{page.url.prev ? (
<a href={page.url.prev}>← Newer</a>
) : (
<span aria-disabled="true">← Newer</span>
)}
<span>Page {page.currentPage} / {page.lastPage}</span>
{page.url.next ? (
<a href={page.url.next}>Older →</a>
) : (
<span aria-disabled="true">Older →</span>
)}
</nav>
<p>Showing items {page.start + 1}–{page.end + 1} of {page.total}.</p>
For a build of 12 posts with pageSize: 5, the second page renders:
Output:
Page 2 / 3
Showing items 6–10 of 12.
Per-page item counts and options
paginate() accepts a small options object. pageSize controls how many items land on each page (default 10). You can also forward extra params and props that should appear on every generated route — useful when the listing is itself nested under another dynamic segment.
---
export const getStaticPaths = (async ({ paginate }) => {
const tags = ["astro", "ssr", "islands"];
return tags.flatMap((tag) => {
const tagged = posts.filter((p) => p.data.tags.includes(tag));
return paginate(tagged, {
pageSize: 8,
params: { tag },
props: { tag },
});
});
}) satisfies GetStaticPaths;
---
This produces routes such as /tags/astro/2/, scoping pagination to each tag. The [tag] segment comes from params, while [page] is managed by paginate().
Tip: To make page one live at the section root (
/blog/instead of/blog/1/), name the filesrc/pages/blog/[...page].astroand use a rest parameter. Astro omits the1for the first page automatically when you use the spread segment.
Warning:
paginate()only runs insidegetStaticPaths(). It is unavailable in on-demand (SSR) routes, where you would instead read a page number from the request URL and slice the data yourself.
Best practices
- Sort your data inside
getStaticPaths()before callingpaginate()so page order is deterministic across builds. - Keep
pageSizesmall enough that each page stays light, but large enough to avoid an excessive number of routes. - Render prev/next links from
page.url.prevandpage.url.nextand treatundefinedas the disabled state — never compute boundary logic manually. - Show a human-readable range using
page.start + 1throughpage.end + 1, since those indices are zero-based. - Add
<link rel="prev">and<link rel="next">in the document head from the same URLs to help crawlers understand the sequence. - Use a
[...page]rest segment when you want the first page to live at the clean section root.