Type-Safe Blog Pagination in Astro with Content Collections and paginate()
Combine Astro content collections with a Zod schema and the paginate() helper to get type-checked frontmatter, draft filtering, and fully static /blog/N pages at build time.
06 Feb 2026, 09:52 UTC

The problem: a growing blog with no structure
Once a Markdown blog passes a dozen posts, two things break down: frontmatter typos slip through silently (a missing pubDate here, a misspelled tag there), and a single ever-longer index page becomes unwieldy. Astro's content collections solve the first problem by validating frontmatter against a Zod schema at build time, and the paginate() helper solves the second by generating static /blog/1, /blog/2 routes during the build — no server, no client JavaScript.
This guide wires both together: a typed blog collection, draft filtering, and paginated static output. Examples assume Astro 4.x/5.x with the default static output mode. One caveat up front: the collections config file location and loader API changed across major versions, so check which form your installed version expects (src/content/config.ts in older versions versus src/content.config.ts with explicit loaders in newer ones) and adapt accordingly.
Prerequisites
- An Astro project with static output (the default; no
output: 'server'inastro.config.mjs). - Posts as Markdown or MDX files under
src/content/blog/. - Node.js and the Astro dev server working (
npm run dev).
Step 1: Define the collection with a Zod schema
Create the collections config file. The schema turns frontmatter into typed, validated data — a bad entry fails the build instead of rendering broken HTML.
// src/content/config.ts (older Astro) — see note below for newer versions
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };In newer Astro versions the file moves to src/content.config.ts and collections declare a loader, e.g. loader: glob({ pattern: '**/*.md', base: './src/content/blog' }) imported from astro/loaders. The schema concept is identical; only the wiring differs. Run your editor's type checking or astro check to confirm which form your version accepts.
Every post in src/content/blog/ now needs frontmatter matching the schema:
---
title: "Shipping smaller images"
pubDate: 2026-09-30
tags: ["performance"]
draft: false
---
Post body in Markdown...Step 2: Paginate in getStaticPaths
Create src/pages/blog/[...page].astro. The rest-parameter filename lets one route generate /blog, /blog/2, /blog/3, and so on. Inside getStaticPaths, query the collection, filter drafts, sort, and hand the array to paginate().
---
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return paginate(posts, { pageSize: 10 });
};
const { page } = Astro.props;
---
<h1>Blog — page {page.currentPage} of {page.lastPage}</h1>
<ul>
{page.data.map((post) => (
<li><a href={`/blog/${post.slug}/`}>{post.data.title}</a></li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>← Newer</a>}
{page.url.next && <a href={page.url.next}>Older →</a>}
</nav>Key behaviors to know:
paginate()returns the path array for you — do not also return your own paths from the same function.- The
pageprop carriesdata(the sliced entries),currentPage,lastPage,total, andurl.prev/url.next(undefined at the ends, so guard the links as shown). - The second argument to
getCollectionis a filter callback, so drafts never reachpaginate()and never get their own URLs in the listing. - Because output is static, all slicing happens at build time. Changing
pageSizeor adding a post regenerates every page on the next build.
Step 3: Render individual posts
A separate route renders each entry. entry.render() (or the newer render() helper from astro:content, depending on version) returns a Content component for the Markdown body.
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toDateString()}</time>
<Content />
</article>Note that post.data.pubDate is a real Date object thanks to z.coerce.date() — no manual parsing, and a malformed date in frontmatter fails the build with a schema error.
Verifying the result
- Run
npm run devand visit/blog, then follow the Older link. Confirm each page shows the correct slice of ten posts and thatpage.url.previs absent on page 1. - Run
npm run buildand inspectdist/: you should find static HTML for each paginated route (for exampledist/blog/index.html,dist/blog/2/index.html). This confirms no runtime rendering is involved. - Deliberately break one post — remove its
titleor writepubDate: not-a-date— and rebuild. The build should fail with a collection validation error naming the offending file. Fix it and rebuild to green. This is worth doing once so the error format is familiar when it happens accidentally; with many entries, locating the single invalid file is the main friction point. - Set
draft: trueon one post and confirm it disappears from both the listing and its direct URL.
Limitations and recovery
paginate() only works inside getStaticPaths. If you later switch a route to on-demand rendering (SSR), you must paginate manually: read the page number from Astro.url.searchParams or route params, then slice() the sorted collection yourself. Also note that pagination routes are rebuilt wholesale — there is no incremental update of a single page, which is fine for typical blog sizes but worth knowing for very large collections.
Recovery is straightforward because everything is build-time state: if a schema change breaks the build, revert the config or the offending frontmatter and rebuild. If pagination links 404 in production but worked in dev, check your site and base settings in astro.config.mjs and confirm the host serves the nested index.html structure Astro emits.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.