Skip to content
Astro as content 3 min read

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 older entry.render() method. If you are migrating from Astro 3, change const { Content } = await entry.render() to import { render } from 'astro:content' and call await render(entry).

What render() returns

PropertyTypeDescription
ContentComponentThe compiled body, rendered with <Content />.
headingsMarkdownHeading[]All headings in the body, with depth, slug, and text.
remarkPluginFrontmatterRecord<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 slug field was unified into id. For glob-loaded Markdown, post.id is 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 when getStaticPaths guarantees existence) before rendering.
  • Use the returned headings array 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 components prop 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.
Last updated June 14, 2026
Was this helpful?