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@latestto scaffold one.
Rendering an entry: contentlayer vs Astro
Section titled “Rendering an entry: contentlayer vs Astro”// 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> );}---// src/pages/blog/[slug].astroimport { 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.
Full blog post page
Section titled “Full blog post page”---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>Listing entries
Section titled “Listing entries”// 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> );}---// src/pages/blog/index.astroimport { 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>Markdown pipeline: Sätteri (Astro 7)
Section titled “Markdown pipeline: Sätteri (Astro 7)”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:
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.