Advanced Content Collections in Astro
Go beyond basic content collections: schema references between collections, computed fields, custom loaders, advanced querying patterns, and full type safety across your Astro project.
What you'll learn
- ✓How to create references between collections using zod transforms
- ✓How to build computed fields that derive data at build time
- ✓How to write custom loaders for external data sources
- ✓Advanced querying patterns with filtering, sorting, and pagination
- ✓How to enforce full type safety from schema to template
Prerequisites
- •Familiarity with basic Astro content collections
- •TypeScript and zod basics
Beyond the Basics
If you have already set up a content collection with defineCollection and a zod schema, you know the basics: define a schema, drop Markdown files in a folder, query them with getCollection. This article covers the patterns that emerge when your content grows past a dozen files and your data model gets relational.
References Between Collections
A blog post might reference an author. A course lesson might reference a course. Astro does not have built-in foreign keys between collections, but you can enforce them with zod and resolve them at query time.
Defining the Collections
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const authors = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/authors' }),
schema: z.object({
name: z.string(),
avatar: z.string().url(),
bio: z.string().max(160),
twitter: z.string().optional(),
}),
});
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
publishedAt: z.coerce.date(),
// Reference to an author by ID
authorId: z.string(),
// Multiple tags referencing a tags collection
tagIds: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
const tags = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/tags' }),
schema: z.object({
label: z.string(),
color: z.string().regex(/^#[0-9a-f]{6}$/i),
}),
});
export const collections = { authors, blog, tags };
Resolving References at Query Time
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
// Resolve the author reference
const author = await getEntry('authors', post.data.authorId);
if (!author) throw new Error(`Author ${post.data.authorId} not found`);
// Resolve tag references
const allTags = await getCollection('tags');
const postTags = allTags.filter((t) => post.data.tagIds.includes(t.id));
---
<article>
<h1>{post.data.title}</h1>
<div class="meta">
<img src={author.data.avatar} alt={author.data.name} width="40" />
<span>{author.data.name}</span>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
</div>
<div class="tags">
{postTags.map((tag) => (
<span style={`background: ${tag.data.color}`}>{tag.data.label}</span>
))}
</div>
<Content />
</article>
Validating References at Build Time
You can add a zod refine to catch broken references before they hit production:
// In content.config.ts — validate after loading
import { getCollection } from 'astro:content';
// Use a zod superRefine on the blog schema
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
authorId: z.string(),
tagIds: z.array(z.string()).default([]),
}),
});
For runtime validation of references, create a utility:
// src/lib/content-utils.ts
import { getCollection, getEntry } from 'astro:content';
export async function getPostWithRelations(postId: string) {
const post = await getEntry('blog', postId);
if (!post) return null;
const author = await getEntry('authors', post.data.authorId);
const allTags = await getCollection('tags');
const tags = allTags.filter((t) => post.data.tagIds.includes(t.id));
return { ...post, author, tags };
}
export async function getAllPostsWithAuthors() {
const [posts, authors] = await Promise.all([
getCollection('blog', ({ data }) => !data.draft),
getCollection('authors'),
]);
const authorMap = new Map(authors.map((a) => [a.id, a]));
return posts.map((post) => ({
...post,
author: authorMap.get(post.data.authorId)!,
}));
}
Computed Fields
Sometimes you need fields that are derived from the content itself: reading time, excerpt, word count. You can compute these during the query phase.
// src/lib/reading-time.ts
export function estimateReadingTime(content: string): number {
const words = content.split(/\s+/).length;
return Math.ceil(words / 200);
}
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import { estimateReadingTime } from '@/lib/reading-time';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const enrichedPosts = posts
.map((post) => ({
...post,
readingTime: estimateReadingTime(post.body ?? ''),
excerpt: (post.body ?? '').slice(0, 200).replace(/[#*_`]/g, '').trim() + '...',
}))
.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
---
<ul>
{enrichedPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
<h2>{post.data.title}</h2>
<p>{post.excerpt}</p>
<span>{post.readingTime} min read</span>
</a>
</li>
))}
</ul>
Custom Loaders
The glob loader reads files from disk, but you can write loaders that pull from a CMS, database, or API.
// src/loaders/notion-loader.ts
import type { Loader } from 'astro/loaders';
export function notionLoader(databaseId: string): Loader {
return {
name: 'notion-loader',
async load({ store, logger }) {
logger.info('Fetching pages from Notion...');
const response = await fetch(
`https://api.notion.com/v1/databases/${databaseId}/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${import.meta.env.NOTION_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
},
);
const data = await response.json();
for (const page of data.results) {
const title = page.properties.Name.title[0]?.plain_text ?? 'Untitled';
const slug = page.properties.Slug?.rich_text[0]?.plain_text ?? page.id;
store.set({
id: slug,
data: {
title,
publishedAt: new Date(page.properties.PublishedAt?.date?.start),
status: page.properties.Status?.select?.name ?? 'draft',
},
});
}
logger.info(`Loaded ${data.results.length} pages from Notion`);
},
};
}
Use it in your config:
// src/content.config.ts
import { notionLoader } from './loaders/notion-loader';
const notionPosts = defineCollection({
loader: notionLoader('your-database-id'),
schema: z.object({
title: z.string(),
publishedAt: z.coerce.date(),
status: z.enum(['draft', 'published', 'archived']),
}),
});
Advanced Querying Patterns
Filtering with Compound Conditions
const publishedRecentPosts = await getCollection('blog', ({ data }) => {
const isPublished = !data.draft;
const isRecent = data.publishedAt > new Date('2026-01-01');
const hasTag = data.tagIds.includes('astro');
return isPublished && isRecent && hasTag;
});
Pagination Helper
// src/lib/paginate.ts
export function paginate<T>(items: T[], page: number, perPage: number) {
const totalPages = Math.ceil(items.length / perPage);
const start = (page - 1) * perPage;
const end = start + perPage;
return {
items: items.slice(start, end),
currentPage: page,
totalPages,
hasPrev: page > 1,
hasNext: page < totalPages,
};
}
---
// src/pages/blog/page/[page].astro
import { getCollection } from 'astro:content';
import { paginate } from '@/lib/paginate';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(),
);
const totalPages = Math.ceil(sorted.length / 10);
return Array.from({ length: totalPages }, (_, i) => ({
params: { page: String(i + 1) },
props: { posts: sorted },
}));
}
const { posts } = Astro.props;
const page = Number(Astro.params.page);
const { items, hasPrev, hasNext, totalPages } = paginate(posts, page, 10);
---
<ul>
{items.map((post) => (
<li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
))}
</ul>
<nav>
{hasPrev && <a href={`/blog/page/${page - 1}`}>Previous</a>}
<span>Page {page} of {totalPages}</span>
{hasNext && <a href={`/blog/page/${page + 1}`}>Next</a>}
</nav>
Grouping by Category
// src/lib/group-posts.ts
import { getCollection } from 'astro:content';
export async function getPostsByCategory() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const grouped = new Map<string, typeof posts>();
for (const post of posts) {
const cat = post.data.category ?? 'Uncategorized';
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push(post);
}
return grouped;
}
Type Safety End-to-End
The biggest advantage of content collections is that types flow from schema to template. Here is how to keep that chain unbroken.
Inferring Types from Collections
// src/types.ts
import type { CollectionEntry } from 'astro:content';
export type BlogPost = CollectionEntry<'blog'>;
export type Author = CollectionEntry<'authors'>;
// Derived type for enriched posts
export type EnrichedPost = BlogPost & {
author: Author;
readingTime: number;
};
Using Inferred Types in Components
---
// src/components/PostCard.astro
import type { EnrichedPost } from '@/types';
interface Props {
post: EnrichedPost;
}
const { post } = Astro.props;
---
<article class="post-card">
<img src={post.author.data.avatar} alt="" width="32" height="32" />
<h3>{post.data.title}</h3>
<span>{post.readingTime} min</span>
</article>
If you rename a field in your schema, TypeScript will immediately flag every template and utility that references the old name. No grep required.
Common Pitfalls
-
Circular references: If collection A references B and B references A, your query code can recurse. Resolve one direction only, or use lazy resolution.
-
Large collections slow builds: Each call to
getCollectionreads every file. Cache the result in a module-level variable if you call it from multiple components on the same page. -
Draft filtering: Always filter drafts explicitly. There is no built-in draft mechanism, so
draft: truefiles will appear in production unless you filter them out in everygetCollectioncall. -
ID assumptions: Collection entry IDs come from the file path relative to the collection root. If you reorganize files into subfolders, IDs change and references break.
Summary
Advanced content collections turn Astro into a lightweight CMS framework. References between collections give you relational data without a database. Custom loaders let you pull from any source. Computed fields keep derived data out of your frontmatter. And TypeScript types flow from schema to template, catching errors before they ship.
Related articles
- Astro Astro Content Collections Tutorial
Build a typed blog with Astro content collections, including Zod schemas, references, and dynamic routes generated from Markdown files.
- Astro Building and Using Astro Integrations
How Astro integrations work under the hood: using official integrations, configuring MDX and Tailwind, and building your own custom integration with lifecycle hooks.
- Astro Mastering View Transitions in Astro
Deep dive into Astro View Transitions: custom animations, transition groups, lifecycle events, fallback behavior, and production patterns for smooth page navigation.
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.