Rendering Collection Entries
Querying a collection gives you an entry’s frontmatter data and its raw body string, but the body isn’t HTML yet — it’s unprocessed Markdown or MDX. To display the actual content on a page, you call the asynchronous render() function, which compiles the body and returns a Content component you drop into your template. This keeps rendering lazy and opt-in: Astro only does the work of compiling an entry when you explicitly ask for it, preserving the zero-JS-by-default output for purely content-driven pages.
The render() function
In Astro 4 and 5, render() is imported from astro:content and takes a single entry as its argument. It returns an object containing the Content component, a headings array, and a remarkPluginFrontmatter object. Because compilation is async, you must await it inside the component script (the --- fence), which runs only at build or request time on the server.
---
import { getEntry, render } from 'astro:content';
const entry = await getEntry('blog', 'hello-world');
if (!entry) {
return Astro.redirect('/404');
}
const { Content, headings } = await render(entry);
---
<article>
<h1>{entry.data.title}</h1>
<Content />
</article>
The <Content /> component renders the entry’s compiled body exactly where you place it. It accepts no required props and emits clean HTML — no client-side JavaScript is shipped unless the body itself contains interactive islands.
Note:
render()replaced the olderentry.render()method. If you are migrating from Astro 3, changeconst { Content } = await entry.render()toimport { render } from 'astro:content'and callawait render(entry).
What render() returns
| Property | Type | Description |
|---|---|---|
Content | Component | The compiled body, rendered with <Content />. |
headings | MarkdownHeading[] | All headings in the body, with depth, slug, and text. |
remarkPluginFrontmatter | Record<string, any> | Frontmatter mutated by remark/rehype plugins at build time. |
The headings array is ideal for building a table of contents without re-parsing the document yourself.
---
import { getEntry, render } from 'astro:content';
const entry = await getEntry('docs', Astro.params.slug);
const { Content, headings } = await render(entry!);
---
<aside>
<nav aria-label="On this page">
<ul>
{headings.map((h) => (
<li style={`margin-left: ${(h.depth - 1) * 12}px`}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ul>
</nav>
</aside>
<article>
<Content />
</article>
Rendering in a dynamic route
The most common pattern is a dynamic route that generates one page per entry. Combine getCollection() for getStaticPaths() with render() in the page body.
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<html lang="en">
<head>
<title>{post.data.title}</title>
</head>
<body>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
<Content />
</body>
</html>
Output:
/blog/hello-world → fully static HTML, 0 KB of JavaScript
/blog/second-post → fully static HTML, 0 KB of JavaScript
Tip: In Astro 5 the legacy
slugfield was unified intoid. For glob-loaded Markdown,post.idis the slugified file path — use it as your route param for clean URLs.
Passing components to MDX
When an entry uses the .mdx extension, you can override the HTML elements it renders by passing a components prop to <Content />. This lets you swap plain tags for styled Astro or framework components.
---
import { getEntry, render } from 'astro:content';
import Callout from '../components/Callout.astro';
import CodeBlock from '../components/CodeBlock.astro';
const entry = await getEntry('blog', 'using-mdx');
const { Content } = await render(entry!);
---
<Content components={{ Callout, pre: CodeBlock }} />
Any component exported by the MDX file, plus standard element names like pre, h2, or a, can be remapped this way. The components prop only applies to MDX entries; plain Markdown ignores it.
Best Practices
- Always
await render()inside the component script — calling it in the template will not work. - Guard against missing entries with
if (!entry)(or non-null!only whengetStaticPathsguarantees existence) before rendering. - Use the returned
headingsarray to build tables of contents instead of parsing the body yourself. - Place
<Content />where the body belongs in your layout; wrap it in semantic elements like<article>for accessibility. - Reach for the
componentsprop on MDX entries to inject design-system components without editing content. - Keep content pages free of unnecessary
client:*directives so they stay zero-JS by default.