To add reading time to Astro posts, write a small remark plugin that counts words with the reading-time package and stores the result in data.astro.frontmatter.minutesRead. Register it in markdown.remarkPlugins, then read it from remarkPluginFrontmatter, which render(post) returns. It works for both Markdown and MDX in content collections.
Key Takeaways
- Two packages:
reading-timeto count,mdast-util-to-stringto extract text.- The plugin adds
minutesReadto frontmatter at render time, not to your schema.- Access it with
const { remarkPluginFrontmatter } = await render(post).- For index pages, compute it from
post.bodyinstead, to avoid rendering every post.
1. Install
npm install reading-time mdast-util-to-string
2. Write the plugin
// remark-reading-time.mjs
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime() {
return function (tree, { data }) {
const text = toString(tree);
const readingTime = getReadingTime(text);
// e.g. "4 min read"
data.astro.frontmatter.minutesRead = readingTime.text;
};
}
Remark plugins receive the Markdown syntax tree. Astro exposes data.astro.frontmatter, and anything you add there comes back as remarkPluginFrontmatter.
3. Register it
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { remarkReadingTime } from './remark-reading-time.mjs';
export default defineConfig({
markdown: {
remarkPlugins: [remarkReadingTime],
},
integrations: [mdx()],
});
MDX inherits markdown config by default, so .mdx posts get it too.
4. Show it on the post page
---
// 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, remarkPluginFrontmatter } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<p class="meta">{remarkPluginFrontmatter.minutesRead}</p>
<Content />
</article>
Restart the dev server after changing astro.config.mjs; config changes aren’t hot-reloaded.
Reading time on index pages
remarkPluginFrontmatter only exists after render(). Calling render() for every post on a listing page works but does more than you need. A lighter option is to count words from the raw body:
// src/utils/reading-time.ts
export function readingTime(body = '') {
const words = body.trim().split(/\s+/).length;
return `${Math.max(1, Math.round(words / 200))} min read`;
}
---
import { getCollection } from 'astro:content';
import { readingTime } from '../../utils/reading-time';
const posts = await getCollection('blog');
---
{posts.map((post) => (
<li>{post.data.title} · {readingTime(post.body)}</li>
))}
The raw body includes code blocks and Markdown syntax, so it overestimates slightly on technical posts. For a listing page, that’s usually fine. If you want the two numbers to match exactly, use the body-based function everywhere and skip the remark plugin.
Code blocks
mdast-util-to-string includes code inside fences. Readers skim code faster than prose, so tutorial-heavy blogs may want to exclude it: visit the tree with unist-util-visit, skip code nodes, and count the rest. Most blogs don’t bother.
If you’d rather start from a blog that already has post layouts, see the blog and editorial themes. Reading time pairs naturally with an auto-generated table of contents, which also reads data returned by render(). The Astro content collections guide explains render() and collections in full, and the official reading time recipe is the reference version of this plugin.
FAQ
Can I add reading time to the content collection schema?
Not directly; the schema validates frontmatter you write. The remark plugin adds minutesRead at render time, and you read it from remarkPluginFrontmatter.
Why is remarkPluginFrontmatter empty?
Check the plugin is in markdown.remarkPlugins, restart the dev server, and make sure you’re reading it from the object returned by render(post).