Live content collections represent the next evolution of content in Astro, bringing real-time data capabilities to the familiar content collections API you already know and love.

Content collections in Astro have been through several stages of evolution. They were first launched as an incredibly easy and powerful way to manage structured content from files on disk. Initially supporting Markdown, MDX and JSON files, they allow you to build blogs, documentation sites, and more with great developer experience and type-safe data.

With Astro 5.0, content collections were expanded into a full-fledged Content Layer that supported pluggable loaders for all kinds of data sources, including APIs, CMSs, and more.

In Astro 5.10, content collections take their next step, with experimental support for live content collections. With these, you can now fetch content at runtime instead of build time, opening up entirely new possibilities for dynamic, personalized, and real-time content experiences.

Whether you’re building an e-commerce site with frequently changing inventory, a news site with breaking updates, or a dashboard with live metrics, Astro’s new live content collections provide the flexibility you need while maintaining the type safety and developer experience that makes Astro special.

The foundations of Live Content Collections

Before diving into live content collections, it’s worth understanding the foundation they’re built upon: loaders. Astro content collections use loaders to manage structured data and content in your projects. Each content collection relies on its loader to define how entries are populated. During astro build, these loaders run to fetch data and populate a local data store. Your pages then query this immutable snapshot using the getCollection() and getEntry() functions.

Live content collections take this concept one step further: instead of fetching data at build time, they fetch it at request time, giving you access to the freshest possible data. Sometimes you want the speed and reliability of static content, but other times you need the flexibility and dynamism of live data. Just as you can choose between static and on-demand rendered pages in Astro, you can now choose between build-time and live content collections.

Whichever choice you make, you get the same, familiar API from your existing content collections. If you know how to use getCollection() and getEntry(), you already know most of what you need to use getLiveCollection() and getLiveEntry().

The architecture of live collections

Unlike build-time content collections that populate a static data store during the build process, live content collections work fundamentally differently under the hood:

When a page using live content collections is requested:

  1. The page calls getLiveCollection() or getLiveEntry() to fetch data.
  2. Data is fetched from the external source (API, database, etc.).
  3. Results are processed and validated against your schema.
  4. Data is returned to your page component.

This architecture means you’re always working with fresh data, but it also means each request involves network calls to your data sources. This trade-off is perfect for use cases where data freshness is more important than absolute performance. You can mitigate performance concerns with page caching, and live collections help by providing cache hints that you can use to optimize this. As this experimental feature develops, Astro will eventually handle more of this for you. For now, you can use the Cache-Control and other headers to control how long the data is cached in the browser and on CDNs.

Setting up Live Content Collections

Getting started with live content collections requires enabling the experimental flag and creating a live collection configuration:

export default defineConfig({

experimental: {

liveContentCollections: true,

},

// Live collections require an adapter for on-demand rendering

adapter: node({

mode: 'standalone',

}),

});

Next, create a src/live.config.ts file to define your live collections, specifying type: 'live' and the collection’s loader.

In this example I’m using two live loader packages that I created, but you will probably need to create your own loaders for your own live data sources by following our documentation. We look forward to more community loaders becoming available, so be sure to share what you build! (It took me a few hours to add live loader support to my existing feed loader and Bluesky loader packages for build-time collections, so hopefully it’s not too hard to get started.)

import { defineLiveCollection } from 'astro:content';

import { liveFeedLoader } from '@ascorbic/feed-loader';

import { liveBlueskyLoader } from '@ascorbic/bluesky-loader';

export const astroNews = defineLiveCollection({

type: 'live',

loader: liveFeedLoader({

url: 'https://astro.build/rss.xml',

}),

});

export const socialPosts = defineLiveCollection({

type: 'live',

loader: liveBlueskyLoader({

identifier: 'astro.build',

limit: 10,

}),

});

Fetching live data in your pages

Once you’ve defined your live collections, using them in your pages is very similar to existing build-time content collections, with a few key differences. In particular, you will want to add some error handling since your data is being loaded live from an external source:

---

export const prerender = false;

import { getLiveCollection } from 'astro:content';

// Fetch the latest Astro blog posts

const { entries: blogPosts, error } = await getLiveCollection('astroNews');

if (error) {

console.error('Failed to load news:', error);

}

---

<h1>Latest Astro News</h1>

{

error ? (

<p>Unable to load news at this time. Please try again later.</p>

) : (

<div class="news-grid">

{blogPosts.map((post) => (

<article class="news-card">

<h2>

<a href={post.data.url}>{post.data.title}</a>

</h2>

{post.data.description && (

<p class="summary">{post.data.description}</p>

)}

</article>

))}

</div>

)

}

You can also use getLiveEntry() to fetch a single entry by its ID, or using filter parameters:

---

export const prerender = false;

import { getLiveEntry, render } from 'astro:content';

const postId = Astro.params.id;

const { entry: post, error } = await getLiveEntry('socialPosts', postId);

if (error) {

console.error('Failed to load post:', error);

return Astro.rewrite('/404');

}

const { Content } = await render(post);

---

<div class="post">

<Content />

<div class="engagement-stats">

<span>❤️ {post.data.likeCount}</span>

<span>🔄 {post.data.repostCount}</span>

<span>💬 {post.data.replyCount}</span>

{post.data.quoteCount > 0 && <span>📝 {post.data.quoteCount}</span>}

</div>

</div>

Building a live content loader

Creating custom live loaders allows you to connect to any API or data source, giving you complete control over how data is fetched and processed. The API is designed to be simple, flexible and type-safe, so you can build loaders that suit your specific needs, while keeping the ease-of-use and type safety that Astro is known for. We’d love to see people trying out the experimental API and giving feedback on it, so we can improve it before it becomes stable.

Creating a Custom API Loader

Here’s an example of a live loader for an e-commerce API:

import type { LiveLoader } from 'astro:content';

interface Product {

id: string;

name: string;

price: number;

category: string;

inStock: boolean;

description?: string;

}

interface ProductFilter {

category?: string;

inStock?: boolean;

}

export function createStoreLoader(

baseUrl: string,

): LiveLoader<Product, ProductFilter> {

return {

loadCollection: async (filter) => {

try {

const url = new URL(`${baseUrl}/products`);

if (filter?.category) {

url.searchParams.set('category', filter.category);

}

if (filter?.inStock !== undefined) {

url.searchParams.set('inStock', filter.inStock.toString());

}

const response = await fetch(url);

if (!response.ok) {

return {

error: new Error(

`Failed to fetch products: ${response.statusText}`,

),

};

}

const data = await response.json();

return {

entries: data.map((product: Product) => ({

id: product.id,

data: product,

})),

};

} catch (error) {

return {

error: error instanceof Error ? error : new Error('Unknown error'),

};

}

},

loadEntry: async (id) => {

try {

const response = await fetch(`${baseUrl}/products/${id}`);

if (response.status === 404) {

return { entry: null };

}

if (!response.ok) {

return {

error: new Error(`Failed to fetch product: ${response.statusText}`),

};

}

const product = await response.json();

return {

entry: {

id: product.id,

data: product,

},

};

} catch (error) {

return {

error: error instanceof Error ? error : new Error('Unknown error'),

};

}

},

};

}

Then use it in your live collections configuration:

import { defineLiveCollection, z } from 'astro:content';

import { createStoreLoader } from './loaders/store-loader';

export const products = defineLiveCollection({

type: 'live',

loader: createStoreLoader('https://store.example.com'),

schema: z.object({

id: z.string(),

name: z.string(),

price: z.number(),

category: z.string(),

inStock: z.boolean(),

description: z.string().optional(),

}),

});

Loader with rendered content

Live content loaders can support rendered content, making it easy for users to display HTML content fetched from an API. Here’s an example of a blog post loader that fetches posts from a CMS and renders the content as HTML:

import type { LiveLoader } from 'astro:content';

interface BlogPost {

title: string;

author: string;

publishDate: Date;

content: string;

excerpt: string;

tags: string[];

}

interface BlogFilter {

status?: 'published' | 'draft';

author?: string;

}

export function createBlogLoader(

baseUrl: string,

): LiveLoader<BlogPost, BlogFilter> {

return {

loadCollection: async (filter) => {

try {

const url = new URL(`${baseUrl}/posts`);

if (filter?.status) {

url.searchParams.set('status', filter.status);

}

if (filter?.author) {

url.searchParams.set('author', filter.author);

}

const response = await fetch(url);

if (!response.ok) {

return {

error: new Error(`Failed to fetch posts: ${response.statusText}`),

};

}

const posts = await response.json();

return {

entries: posts.map((post: any) => ({

id: post.slug,

data: {

title: post.title,

author: post.author,

publishDate: new Date(post.publishDate),

content: post.content,

excerpt: post.excerpt,

tags: post.tags || [],

},

rendered: post.html ? { html: post.html } : undefined,

})),

};

} catch (error) {

return {

error: error instanceof Error ? error : new Error('Unknown error'),

};

}

},

loadEntry: async (slug) => {

try {

const response = await fetch(`${baseUrl}/posts/${slug}`);

if (response.status === 404) {

return { entry: null };

}

if (!response.ok) {

return {

error: new Error(`Failed to fetch post: ${response.statusText}`),

};

}

const post = await response.json();

return {

entry: {

id: post.slug,

data: {

title: post.title,

author: post.author,

publishDate: new Date(post.publishDate),

content: post.content,

excerpt: post.excerpt,

tags: post.tags || [],

},

rendered: post.html ? { html: post.html } : undefined,

},

};

} catch (error) {

return {

error: error instanceof Error ? error : new Error('Unknown error'),

};

}

},

};

}

Then use it in your live collections configuration:

import { defineLiveCollection, z } from 'astro:content';

import { createBlogLoader } from './loaders/blog-loader';

export const blogPosts = defineLiveCollection({

type: 'live',

loader: createBlogLoader('https://cms.example.com'),

schema: z.object({

title: z.string(),

author: z.string(),

publishDate: z.date(),

content: z.string(),

excerpt: z.string(),

tags: z.array(z.string()),

}),

});

Live collections vs build-time collections

Understanding when to use live collections versus traditional build-time collections is crucial for building performant applications:

Use live collections when:

  • Data changes frequently: Inventory levels, user-generated content, live metrics
  • Personalization is required: User-specific recommendations, dashboard data
  • Real-time accuracy is critical: News feeds, social media content, live scores
  • Dynamic filtering is needed: Search results, filtered product catalogs

Use build-time collections when:

  • Content is relatively static: Blog posts, documentation, marketing pages
  • Performance is paramount: High-traffic sites where every millisecond counts
  • You need image transformations or MDX rendering: Live collections do not support image transformations or MDX rendering

Hybrid approaches

You can combine both approaches in the same project, and even within the same page. For example, you might use build-time collections for static content like blog posts, while using live collections for dynamic features like comments or user profiles.

---

export const prerender = false;

import { getEntry, getLiveCollection } from 'astro:content';

// Blog post content is fetched at build time and cached in the data store. The site is rebuilt when new posts are added

const post = await getEntry('blog', Astro.params.slug);

// Live comments are fetched at request time, so they always show the latest comments

const { entries: comments } = await getLiveCollection('comments', {

postId: Astro.params.slug,

});

---

<!-- Static blog post content -->

<article>

<h1>{post.data.title}</h1>

<Content />

</article>

<!-- Live comments section -->

<section class="comments">

<h2>Comments ({comments.length})</h2>

{

comments.map((comment) => (

<div class="comment">

<strong>{comment.data.author}</strong>

<p>{comment.data.content}</p>

</div>

))

}

</section>

Hybrid patterns like these pair well with server islands, allowing you to create truly hybrid pages where the main content is static but specific components fetch live data. This gives you the best of both worlds: fast static content delivery with dynamic, real-time sections.

---

// This page can be prerendered because the main content is static

import { getEntry, getCollection } from 'astro:content';

import Comments from '../components/Comments.astro';

export const getStaticPaths = async () => {

const posts = await getCollection('blog');

return posts.map((post) => ({

params: { slug: post.id },

}));

};

const post = await getEntry('blog', Astro.params.slug);

---

<!-- Static blog post content -->

<article>

<h1>{post.data.title}</h1>

<div class="content">

<Content />

</div>

</article>

<!-- Dynamic comments loaded via server island -->

<Comments server:defer postId={Astro.params.slug} />

---

export const prerender = false;

import { getLiveCollection } from 'astro:content';

interface Props {

postId: string;

}

const { postId } = Astro.props;

// This component runs as a server island, fetching live data

const { entries: comments, error } = await getLiveCollection('comments', {

postId,

status: 'approved',

});

// Cache in the CDN for 10 minutes

Astro.response.headers.set('Cache-Control', 'public, s-maxage=600');

---

<section>

<h2>Comments</h2>

{error ? (

<p>Unable to load comments at this time.</p>

) : comments.length === 0 ? (

<p>No comments yet. Be the first to comment!</p>

) : (

<div class="comments-list">

{comments.map((comment) => (

<div class="comment">

<div class="comment-header">

<strong>{comment.data.author}</strong>

<time>{comment.data.createdAt.toLocaleDateString()}</time>

</div>

<p>{comment.data.content}</p>

</div>

))}

</div>

)}

</section>

This approach provides several benefits. This gives you a fast initial page load with static content, while still allowing specific components to fetch live data as needed. It also allows you to cache the live data effectively, improving performance and reducing load on your APIs.

Error handling and resilience

Live content collections provide explicit error handling that makes your application more resilient:

---

export const prerender = false;

import { getLiveCollection } from 'astro:content';

// Fetch multiple live collections with individual error handling

const [metricsResult, alertsResult, reportsResult] = await Pr