Astro content collections are really ergonomic and a delight to use if you want to build something with data that changes infrequently.
Collections yield Entries derived from structurally similar data, often stored as local files. Astro supports linking these entries through references, enabling relationships like a single Post referencing multiple Tag entries (many-to-many cardinality). However, Astro’s Content Layer API doesn’t provide much for eager-loading references, or bidirectional queries.
In this post I’ll walk through how to implement a simple Astro content app that uses a local collection of Posts and Tags, and share some strategies I reach for when resolving collection references.
1. Define the collections with references
import { glob } from 'astro/loaders';
import { defineCollection, reference, z } from 'astro:content';
const tagSchema = z.object({
name: z.string(),
slug: z.string(),
});
const postSchema = z.object({
pubDate: z.coerce.date(),
title: z.string(),
slug: z.string(),
tags: z.array(reference('tags')),
});
const posts = defineCollection({
loader: glob({
pattern: ['**/*.md', '**/*.mdx'],
base: './src/content/posts/',
}),
schema: postSchema,
});
const tags = defineCollection({
loader: glob({
pattern: ['**/*.md'],
base: './src/content/tags/',
}),
schema: tagSchema,
});
export const collections = {
posts, tags
};2. Add project files that conform to the collection schemas
Next, add some local files to the project that match the base directories and pattern, e.g., src/content/tags/*.md, which we configured in the loader for each collection.
---
name: Astro
slug: astro
------
title: This is an example
pubDate: 2025-11-02
slug: an-example-post
tags:
- astro
---3. Resolve the Post’s Tag references
Our goal here is to display an index listing of Posts, maybe presented as individual Cards, that include the title of the Post and all of the associated Tag names in the Card.
Out of the box, when querying Posts with getCollection, the returned posts will each have Tag references but these will only have the id property and not name. Unfortunately, the Content Layer API doesn’t expose something to eager-load all the other Tag properties.
Astro has several utilities like getEntries and getEntry that we can use to resolve the references of our collection data. Let’s create a utility function that can accomplish loading all the tags per Post. We’ll use this later in an Astro code fence:
import type { CollectionEntry } from 'astro:content';
import { getEntries } from 'astro:content';
interface ResolvePostsTagsProps {
posts: CollectionEntry<'posts'>[]
}
export const resolvePostsTags = async (posts: CollectionEntry<'posts'>[]) =>
await Promise.all(
posts
.sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
)
.map(async (p: CollectionEntry<'posts'>) => {
const tagEntries = await getEntries(p.data.tags);
return {
...p, tagEntries,
};
}),
);
Astro provides a useful type generic, CollectionEntry, which we can use when passing collection data to functions such as resolvedPostsTags.
It’s important to note that this is SSG and all collection queries, including getEntries([...tags]), happen at build time so there’s no real runtime performance impact of what is seemingly an N+1 problem. Also, collections under the hood use an Astro ImmutableDataStore which is essentially a Map() of entries making lookups really snappy at build time.
4. Render the posts with resolved tags
Finally, render each Post with its resolved tagEntries. First, move the Post presentation logic into a reusable component for easier use in your pages/index.astro template section.
import type { CollectionEntry } from 'astro:content';
export type ResolvedPostWithTags = CollectionEntry<'posts'> & {
tagEntries: CollectionEntry<'tags'>[]
};
export default function Post({ post}: {
post: ResolvedPostWithTags
}) {
return (
<li>
<a href={`/posts/${post.data.slug}`}>
<h3>{post.data.title}</h3>
<div>
{post.tagEntries.length
? (
<div>
{post.tagEntries.map(({ data: { name } }, key) => (
<span key={key}>
{name}
</span>
))}
</div>
)
: null}
</div>
</a>
</li>
);
}
Lastly, we’ll plug this into our main index template:
---
import Layout from '../layouts/Layout.astro';
import Post from '../components/Post';
import { getCollection } from 'astro:content';
import { resolvePostsTags } from "../collections/resolver";
const posts = await getCollection('posts');
const resolved = resolvePostsTags({ posts });
---
<Layout title="...">
<div>
<h2>Latest Posts</h2>
<ul>
{resolved.map(post => <Post post={post} />)}
</ul>
</div>
</Layout>
5. The other side of the references
What if we want to grab all Posts for a single Tag instead?
getCollection can take a function that filters the collection based on some condition, e.g., having any Tag references that match a specific Tag id.
---
import { getCollection, getEntry } from 'astro:content';
import { resolvePostsTags } from "../collections/resolver";
const { slug } = Astro.params;
if (!slug) throw new Error('Slug not found');
const tag = await getEntry('tags', slug);
if (!tag) throw new Error(`Tag not found: ${slug}`);
const tagPosts = await getCollection('posts', ({ data }) =>
data.tags.some(t => t.id === tag.id),
);
const resolved = resolvePostsTags({ posts: tagPosts });
---