
How to query and sort content collections in Astro?
To query a content collection in Astro, call getCollection("name") from astro:content to get every entry as an array, or getEntry("name", "id") to get a single entry. getCollection() accepts an optional filter function as its second argument, such as ({ data }) => !data.draft, to return only matching entries. Sorting is up to you: Astro doesn't guarantee any order, so you sort the returned array with standard JavaScript, for example posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) for newest first. Everything else, from limiting to grouping, is ordinary array work on fully typed entries.
That simplicity is the strength of the API, but it means the patterns are up to you, and a few of them have sharp edges. In this article you'll learn the query functions and what they return, how to filter by frontmatter, environment and folder, how to sort by date, title and custom order, how to group and limit results, how to build a reusable query helper, and how to handle missing entries. The examples were run against an Astro 7.4 test project and the outputs shown are from that build.
The Query Functions
Build-time collections have four functions for reading data, all imported from astro:content:
| Function | Returns | Use it for |
|---|---|---|
getCollection(name, filter?) | CollectionEntry[] | Lists, archives, getStaticPaths() |
getEntry(name, id) | CollectionEntry | undefined | One known entry |
getEntry(reference) | CollectionEntry | undefined | Resolving a reference() field |
getEntries(references) | CollectionEntry[] | Resolving an array of references |
Each entry has an id, a typed data object, a raw body for Markdown and MDX, and a collection name. The data types come from your schema, which is why post.data.pubDate can be sorted as a Date without any casting. If you haven't defined a schema yet, see how to define a content collection schema with Zod first; the sorting examples below assume pubDate uses z.coerce.date().
Live collections have their own getLiveCollection() and getLiveEntry(), which return an object with entries or entry plus an error. This article sticks to build-time collections.
Getting Every Entry
The simplest query returns everything:
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
---
<p>{posts.length} posts</p>
The collection name is type-checked against the collections you exported from src/content.config.ts. A typo such as getCollection("blgo") is caught by astro check and in your editor.
Filtering Entries
The second argument to getCollection() is a callback that receives each entry and returns true to keep it. Destructure what you need:
const guides = await getCollection("blog", ({ data }) => {
return data.category === "guides";
});
Hiding Drafts in Production
The most common filter excludes drafts. To see drafts while writing but leave them out of the production build, check import.meta.env.PROD:
const posts = await getCollection("blog", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
import.meta.env.PROD is true during astro build and false in astro dev. In my test project, a post with draft: true appeared on the dev server, and astro build produced no page for it, no tag page for its only tag, and no entry in any list, because every page used the same filter.
That last point is important. If one page filters drafts and another doesn't, a draft can still leak through a tag page or an RSS feed. The fix is to centralise the query, which I'll show shortly.
Filtering by Folder
Entries loaded by glob() keep their folder path in the id. A file at src/content/blog/2025/Year-In-Review.md gets the id 2025/year-in-review. That makes folder filtering a string check:
const posts2025 = await getCollection("blog", ({ id }) => {
return id.startsWith("2025/");
});
The same pattern works for language folders in a multilingual site, such as id.startsWith("en/").
Filtering by Tag
Tags are arrays, so use includes():
const astroPosts = await getCollection("blog", ({ data }) => {
return data.tags.includes("astro");
});
If your schema gives tags a default of [], this is safe for every entry. Without a default, guard with data.tags?.includes("astro").
Filtering by Date
Scheduled posts can be hidden until their date arrives, at least as of the last build:
const now = new Date();
const published = await getCollection("blog", ({ data }) => {
return data.pubDate <= now;
});
On a static site, "now" means build time. A post dated tomorrow won't appear until you rebuild after that date, which is why scheduled publishing usually pairs with a daily scheduled build.
Sorting Entries
The docs are explicit that the order of getCollection() results is non-deterministic and platform-dependent. It might look sorted on your laptop and come out differently on your CI server. Always sort when order matters.
Newest First
Comparing valueOf() gives the timestamps as numbers:
const posts = (await getCollection("blog")).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
Swap a and b for oldest first.
By Last Updated
To show recently updated posts first, fall back to pubDate when there's no updatedDate:
const fresh = posts.toSorted(
(a, b) =>
(b.data.updatedDate ?? b.data.pubDate).valueOf() -
(a.data.updatedDate ?? a.data.pubDate).valueOf(),
);
toSorted() returns a new array and leaves the original alone. That matters when you reuse one sorted list for several purposes on the same page. sort() mutates in place, which is fine directly after getCollection() but can surprise you later.
Alphabetically
For titles, use localeCompare() rather than < and >, so accented characters and case are handled sensibly:
const alphabetical = posts.toSorted((a, b) =>
a.data.title.localeCompare(b.data.title),
);
In the test project this produced:
A long post
Content collections in practice
Custom slug
My first post
Writing posts in MDX
Year in review
By a Custom Order Field
Documentation sidebars and portfolios often need a hand-picked order. Add a number to the schema, such as order: z.number().default(999), and sort by it, using the title as a tiebreaker:
const docs = (await getCollection("docs")).sort(
(a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
);
The || only runs the second comparison when the first returns 0, which is a compact way to chain sort keys.
Limiting Results
Once the array is sorted, slice() takes the first few:
const latestThree = posts.slice(0, 3);
There's no limit option on getCollection(). The whole collection is loaded from the data store either way, which is fast because it's already in memory at build time.
Grouping Entries
An archive page grouped by year is a good example of how much plain JavaScript can do. Object.groupBy() is available in current Node versions, and Astro 7 requires Node 22.12 or later:
---
// src/pages/archive.astro
import { getPublishedPosts } from "../lib/posts";
const posts = await getPublishedPosts();
const byYear = Object.groupBy(posts, (post) =>
String(post.data.pubDate.getFullYear()),
);
const years = Object.keys(byYear).sort((a, b) => Number(b) - Number(a));
---
{years.map((year) => (
<section>
<h2>{year}</h2>
<ul>
{byYear[year]?.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
</section>
))}
Two details came from running astro check on this:
- Return a string key: my first version returned the year as a number, and TypeScript complained when indexing the result with a string from
Object.keys(). Wrapping the year inString()keeps the key type consistent. - Use optional chaining:
Object.groupBy()types each group as possibly undefined, sobyYear[year]?.map()is needed to satisfy strict mode.
Because posts was sorted before grouping, each year's posts stay in newest-first order.
Building a Reusable Query Helper
As soon as more than one page lists posts, put the filter and sort in one function. Every page, tag archive, RSS feed and sitemap then agrees on what "published" means:
// src/lib/posts.ts
import { getCollection, type CollectionEntry } from "astro:content";
export type Post = CollectionEntry<"blog">;
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection("blog", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
return posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
}
Pages then become one line:
---
// src/pages/blog/index.astro
import { getPublishedPosts } from "../../lib/posts";
const posts = await getPublishedPosts();
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>{post.data.title}</a>{" "}
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString("en-GB")}
</time>
</li>
))}
</ul>
Note the {" "} between the link and the date. Astro 7 changed the default compressHTML setting to 'jsx', which strips whitespace between elements the way React does. Without the explicit space, my first build rendered the title and date jammed together.
If you need the helper to work across several collections, the CollectionKey type from astro:content is the union of all your collection names and can type a generic wrapper.
Getting a Single Entry
getEntry() takes the collection and the id:
---
// src/pages/featured.astro
import { getEntry, render } from "astro:content";
const post = await getEntry("blog", "second-post");
if (!post) {
throw new Error("Featured post not found");
}
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<Content />
It returns undefined when there's no match, and TypeScript makes you handle that. On a static page, throwing an error fails the build, which is usually what you want for a hard-coded ID. On an on-demand page that takes the ID from the URL, return a redirect or a 404 response instead.
getEntry() also accepts a reference object, which is how you resolve fields defined with reference(). That is covered in how to reference one collection from another.
Using Queries in getStaticPaths()
For static sites, the query that drives your routes runs inside getStaticPaths(). Pass the whole entry as a prop so the page doesn't need to query again:
---
// src/pages/blog/[...id].astro
import { render } from "astro:content";
import { getPublishedPosts } from "../../lib/posts";
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<Content />
Using the shared helper here means drafts never get a URL in production. For previous and next links, find the current post's index in the sorted array and pass its neighbours as props too.
Paginating a Sorted Collection
Astro's paginate() helper takes any array, so pass it the sorted posts:
---
// src/pages/blog/page/[...page].astro
import type { GetStaticPaths } from "astro";
import { getPublishedPosts } from "../../../lib/posts";
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getPublishedPosts();
return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
const { page } = Astro.props;
---
<ul>
{page.data.map((post) => <li>{post.data.title}</li>)}
</ul>
{page.url.next && <a href={page.url.next}>Older posts</a>}
Sort before paginating, or page two may contain posts newer than page one. The full pattern, including page numbers and the first page URL, is in how to paginate an Astro blog.
Performance Notes
Querying collections is cheap, but a few habits keep large sites fast:
- Query once per page: store the result in a variable rather than calling
getCollection()repeatedly in the template. - Pass entries as props: in dynamic routes, pass the entry from
getStaticPaths()instead of callinggetEntry()again in the page body. - Avoid render() in lists: rendering every post's body just to show titles is wasteful. Use
datafields for listings and callrender()only on the post page. - Consider retainBody: false: if you never use
entry.body, setting it on theglob()loader reduces the size of the data store for large collections.
FAQ: Querying and Sorting Collections
Astro doesn't guarantee the order of collection results, and it can vary between platforms. Sort the array yourself every time order matters, ideally inside one shared helper function.
Make sure pubDate is a Date by using z.coerce.date() in the schema, then sort the array by subtracting the first post's pubDate.valueOf() from the second's for newest first, or the other way round for oldest first.
There's no limit option. Sort the array and use slice(0, n) to keep the first n entries. The full collection is already in memory at build time, so this is fast.
Filter with import.meta.env.PROD ? data.draft !== true : true. PROD is true during astro build, so drafts are excluded from production but still visible on the dev server.
glob() includes the folder path in each entry's id, so pass a filter that keeps entries whose id starts with the folder name followed by a slash. This also works for language folders such as en/ or fr/.
It returns undefined. Check for it before using the entry: throw an error on static pages with a hard-coded id, or return a 404 or redirect on pages rendered on demand.
Conclusion
Querying content collections comes down to two functions and ordinary JavaScript. getCollection() returns every entry, optionally filtered by data, id or environment, and getEntry() returns one entry or undefined. Because Astro doesn't promise any order, you sort the results yourself, by date, title, update time or a custom order field, then slice(), group or paginate them as needed.
The habit that pays off most is a single helper such as getPublishedPosts() that owns the draft filter and the sort. Every listing, route, feed and archive then shows the same posts in the same order, and changing the rule later means editing one function rather than hunting through every page.


