Skip to content

Rendering Markdown

In Next.js with contentlayer you import the MDX component directly. In Astro you call render(entry) (the only API since Astro 5 — the old entry.render() method was removed when entries became plain serializable objects) to get a Content component, then drop <Content /> into your template. The frontmatter fields live on entry.data, the rendered body comes from Content.

Needs a full Astro project to run. Use npm create astro@latest to scaffold one.

React
// app/blog/[slug]/page.tsx (Next.js App Router + contentlayer)
import { allPosts } from 'contentlayer/generated';
import { useMDXComponent } from 'next-contentlayer/hooks';
export default function PostPage({ params }) {
const post = allPosts.find(p => p.slug === params.slug);
const MDXContent = useMDXComponent(post.body.code);
return (
<article>
<h1>{post.title}</h1>
<MDXContent />
</article>
);
}
Astro
---
// src/pages/blog/[slug].astro
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, headings } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>

render(entry) returns { Content, headings, remarkPluginFrontmatter }. Content is a component you render directly — no hooks, no client-side MDX compilation.

src/pages/blog/[slug].astro
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<BaseLayout title={post.data.title}>
<article>
<header>
<h1>{post.data.title}</h1>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString('en-US', { dateStyle: 'long' })}
</time>
</header>
<Content />
</article>
</BaseLayout>
React
// app/blog/page.tsx (Next.js App Router + contentlayer)
import { allPosts } from 'contentlayer/generated';
export default function BlogIndex() {
const sorted = allPosts.sort(
(a, b) => new Date(b.date) - new Date(a.date)
);
return (
<ul>
{sorted.map(post => (
<li key={post.slug}>
<a href={post.url}>{post.title}</a>
</li>
))}
</ul>
);
}
Astro
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
---
<ul>
{sorted.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
</li>
))}
</ul>

Astro 7 renders .md and .mdx with Sätteri, a native Markdown pipeline written in Rust, instead of the remark/rehype (unified) pipeline. Content Collections, render(entry), headings, and frontmatter all work the same — builds are just faster (Astro reports 15–61% faster builds overall across its real-world benchmark sites). Two things to know when you migrate a Next.js/contentlayer project:

  • Plain Markdown, GFM tables, code fences with Shiki, and headings IDs need nothing.
  • If you rely on custom remark/rehype plugins (e.g. rehype-autolink-headings), opt back into the unified pipeline explicitly:
astro.config.mjs
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
export default defineConfig({
markdown: {
processor: unified(), // remark/rehype pipeline
rehypePlugins: [/* ... */],
},
});

MDX still goes through @astrojs/mdx; only the default processor for the Markdown body changed.

What function do you call to get a renderable component from a collection entry?
How do you output the rendered content in the Astro template?
What does `getStaticPaths()` return for a dynamic collection route?