Type something to search...
How to calculate reading time for Astro blog posts?

How to calculate reading time for Astro blog posts?

To calculate reading time for Astro blog posts, count the words in each post and divide by an average reading speed, usually around 200 words per minute. The approach the Astro docs recommend is a Markdown processor plugin that runs the text through the reading-time package and adds a minutesRead value to the post's frontmatter, which you then read from remarkPluginFrontmatter after calling render(entry). In Astro 7 the default Markdown processor is Sätteri, so that plugin is written with defineMdastPlugin() from satteri; old remark plugins only work if you switch to the unified() processor. For a dependency-free alternative, you can also compute reading time directly from entry.body in a small utility function.

Most reading-time tutorials online were written for Astro's old remark pipeline, and they'll quietly stop working after an upgrade to Astro 7. In this article you'll learn all three approaches, tested against the same posts: a Sätteri mdast plugin, a remark plugin on the unified processor, and a plain function. You'll also see how to display the result in post pages and listings, why the numbers can differ slightly between methods, and how to choose. Everything was built with Astro 7.4, @astrojs/markdown-satteri 0.4 and reading-time 1.5.

How Reading Time Is Calculated

Every method follows the same steps:

  1. Get the text: strip Markdown syntax, HTML and, ideally, code blocks.
  2. Count the words: split on whitespace.
  3. Divide by a reading speed: commonly 200 to 250 words per minute for prose.
  4. Round up: a 30-second read still shows as "1 min read".

The reading-time package does steps 2 to 4 and returns an object with text (such as "5 min read"), minutes, time in milliseconds and words. Its default speed is 200 words per minute. The difference between the methods below is mostly about step 1: where the text comes from and when the calculation runs.

What Changed in Astro 7

Astro 7 made Sätteri, a Rust-based Markdown compiler, the default processor for .md and .mdx files. It replaced the remark and rehype pipeline, and @astrojs/markdown-remark is no longer installed by default. For reading time that means:

  • remark plugins don't run with the default processor. A remarkPlugins array alone won't do anything useful.
  • You can port the plugin to a Sätteri mdast plugin, which is what the current docs recipe does.
  • Or you can stay on unified by installing @astrojs/markdown-remark and setting markdown.processor: unified().

The deprecated top-level markdown.remarkPlugins option still exists but also requires @astrojs/markdown-remark. New code should pass plugins to a processor instead.

Method 1: A Sätteri mdast Plugin

This is the recommended approach for Astro 7. Install three packages:

npm install reading-time @astrojs/markdown-satteri satteri
  • reading-time does the calculation.
  • @astrojs/markdown-satteri lets you configure the Sätteri processor and add plugins. Sätteri works without it, but you need it to register a plugin.
  • satteri provides defineMdastPlugin().

Write the plugin:

// src/mdast/mdast-reading-time.ts
import getReadingTime from "reading-time";
import { defineMdastPlugin } from "satteri";

export const mdastReadingTimePlugin = defineMdastPlugin({
  name: "mdast-reading-time",
  after(root, context) {
    const text = context.textContent(root);
    const stats = getReadingTime(text);

    if (context.data.astro !== undefined) {
      context.data.astro.frontmatter.minutesRead = stats.text;
      context.data.astro.frontmatter.wordCount = stats.words;
    }
  },
});

The after hook runs once the Markdown tree is built. context.textContent(root) extracts the text of the whole document, and anything you add to context.data.astro.frontmatter becomes available to your pages. The undefined check matches the docs recipe, which guards it in case the plugin runs outside Astro.

Register it in your config:

// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import { satteri } from "@astrojs/markdown-satteri";
import { mdastReadingTimePlugin } from "./src/mdast/mdast-reading-time";

export default defineConfig({
  markdown: {
    processor: satteri({
      mdastPlugins: [mdastReadingTimePlugin],
    }),
  },
  integrations: [mdx()],
});

Pass mdastReadingTimePlugin itself, not mdastReadingTimePlugin(). In satteri 0.10, defineMdastPlugin() returns the plugin definition object unchanged, so there's nothing to call. One example in the Astro docs shows a plugin being called with parentheses; that only applies to plugin packages that export a factory function taking options.

MDX inherits the markdown config by default, so this one plugin covers both .md and .mdx posts. In my test, MDX posts got a minutesRead value too.

Displaying It

With content collections, the plugin's values are returned by render() as remarkPluginFrontmatter. The name is a holdover from the remark days, but it works with any processor:

---
// src/pages/blog/[...id].astro
import { getCollection, render } from "astro:content";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content, remarkPluginFrontmatter } = await render(post);
---
<article>
  <h1>{post.data.title}</h1>
  <p>{remarkPluginFrontmatter.minutesRead}</p>
  <Content />
</article>

For a test post with about 1,000 words of prose and one short code block, remarkPluginFrontmatter contained the original frontmatter fields plus the new ones:

"minutesRead":"5 min read","wordCount":1000

remarkPluginFrontmatter is typed as any, so TypeScript won't catch a misspelt property. If you use it in several places, wrap it in a small typed helper.

If you use Markdown layouts with the layout frontmatter property instead of collections, the value is on Astro.props.frontmatter.minutesRead in the layout.

Method 2: A Remark Plugin With unified()

If you already have remark plugins, or you depend on the remark ecosystem, keep the unified pipeline. Install:

npm install reading-time mdast-util-to-string @astrojs/markdown-remark

The plugin is the classic remark version:

// remark-reading-time.mjs
import getReadingTime from "reading-time";
import { toString } from "mdast-util-to-string";

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree);
    const readingTime = getReadingTime(textOnPage);
    data.astro.frontmatter.minutesRead = readingTime.text;
  };
}

And the config switches the processor to unified():

// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import { unified } from "@astrojs/markdown-remark";
import { remarkReadingTime } from "./remark-reading-time.mjs";

export default defineConfig({
  markdown: {
    processor: unified({
      remarkPlugins: [remarkReadingTime],
    }),
  },
  integrations: [mdx()],
});

Here the plugin is a function that returns the transformer, so it's passed without calling it, and unified calls it for you. Pages read the value exactly the same way, from remarkPluginFrontmatter.minutesRead.

Why the Numbers Differed

On the same 1,000-word test post, the Sätteri plugin produced "5 min read" while the unified plugin produced "6 min read". The post had exactly 1,000 words of prose plus a one-line fenced code block. At 200 words per minute, 1,000 words is exactly five minutes, so a handful of extra words from the code is enough to round up to six. The Sätteri plugin reported wordCount: 1000, which suggests its text extraction left the code block out, while mdast-util-to-string included it. For short posts the difference vanishes; for long technical posts it can be a minute or so. Neither is wrong, but pick one method and stick with it so your numbers are consistent.

Method 3: A Simple Function Using entry.body

You don't need a plugin at all. Content collection entries loaded by glob() include the raw source in entry.body, so you can count words directly:

// src/lib/reading-time.ts
const WORDS_PER_MINUTE = 200;

export function getReadingTime(body: string | undefined): number {
  if (!body) return 1;

  const text = body
    .replace(/^---[\s\S]*?---/, "")            // frontmatter, if present
    .replace(/```[\s\S]*?```/g, " ")            // fenced code blocks
    .replace(/^\s*(import|export)\s.*$/gm, "") // MDX import/export lines
    .replace(/<[^>]+>/g, " ")                   // HTML and component tags
    .replace(/[#>*_`~\[\]()!-]/g, " ");         // Markdown punctuation

  const words = text.split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
}

Use it anywhere you have an entry, with no render() call needed:

---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
import { getReadingTime } from "../../lib/reading-time";

const posts = await getCollection("blog");
---
<ul>
  {posts.map((post) => (
    <li>{post.data.title}: {getReadingTime(post.body)} min read</li>
  ))}
</ul>

For my test collection this listed the long post at 5 minutes and every short post at 1 minute, matching the Sätteri plugin. The regular expressions are deliberately simple; they won't perfectly parse every edge case of Markdown, but for a word count that's rounded to the nearest minute they're accurate enough.

This method has three caveats:

  • It needs entry.body: if you set retainBody: false on the glob() loader to shrink the data store, body is undefined and the function falls back to 1.
  • It's only for collections: Markdown imported with import.meta.glob() doesn't have body.
  • It doesn't run the Markdown pipeline: it works on raw source, so it can't see content that components add at render time.

Showing Reading Time in Post Listings

Listing pages are where the plugin approach becomes awkward. remarkPluginFrontmatter only exists after render(), so showing reading time for twenty posts on an index page means rendering twenty posts. That works, and Astro caches rendered Markdown, but it's wasteful when you only need a number.

The function from Method 3 avoids that entirely, since it only reads post.body. A reasonable split is:

WhereSuggested method
Single post pagePlugin, via remarkPluginFrontmatter
Blog index, tag pages, related postsgetReadingTime(post.body)
Both, with identical numbersThe function everywhere

If you want one number used everywhere, the function alone is the simplest choice. A shared query helper like the one in how to query and sort content collections is a good place to attach it, for example by returning { post, minutes } pairs.

Choosing a Method

  • Use the Sätteri plugin if you're on Astro 7's default processor and want reading time available in every rendered Markdown and MDX file, including Markdown layouts.
  • Use the remark plugin if you've switched to unified() for other remark or rehype plugins already, or for MDX recma plugins.
  • Use the function if you want no extra dependencies, need reading time on listing pages, or want the same number everywhere without rendering.

All three run at build time for static sites, so readers download no extra JavaScript.

Making the Output Friendlier

A few small touches improve the display:

  • Format consistently: reading-time returns "5 min read". If you compute minutes yourself, use a template like `${minutes} min read`.
  • Add the word count for long posts: "12 min read · 2,400 words" sets expectations.
  • Use a semantic element: wrap it in a <p> or <span> next to the <time> element showing the publish date.
  • Mind the whitespace: Astro 7 defaults compressHTML to 'jsx', which strips whitespace between elements. When you put the date and reading time side by side, add an explicit {" "} or a separator character so they don't run together.

FAQ: Reading Time in Astro

Astro 7 uses the Sätteri processor by default, which doesn't run remark plugins. Either port the plugin to a Sätteri mdast plugin with defineMdastPlugin(), or install @astrojs/markdown-remark and set markdown.processor to unified() with your plugin in remarkPlugins.

For content collections, call render(entry) and read remarkPluginFrontmatter.minutesRead. For Markdown files using a layout, read Astro.props.frontmatter.minutesRead in the layout component.

Yes. MDX extends your markdown configuration by default, so a plugin registered on markdown.processor also runs for .mdx files. In testing, MDX posts received minutesRead just like Markdown posts.

Yes. Entries from the glob() loader include the raw source in entry.body, so a small function can strip syntax, count words and divide by a reading speed. This also avoids calling render() on listing pages.

The reading-time package defaults to 200 words per minute, which is a common choice for prose. Technical content with code is read more slowly, so some sites use a lower number or exclude code from the count.

They extract text differently. In testing, the Sätteri plugin appeared to leave a code block out of the count while the remark version included it, which pushed a 1,000-word post from 5 to 6 minutes. Use one method consistently.


Conclusion

Reading time is a word count divided by a reading speed, and Astro gives you two places to compute it. A Markdown processor plugin adds minutesRead to every post's frontmatter during rendering; in Astro 7 that's a Sätteri mdast plugin built with defineMdastPlugin(), or a classic remark plugin if you switch to the unified() processor. Alternatively, a small function can count the words in entry.body without any plugin at all.

Use the plugin when you want the value inside rendered content and layouts, and the function when you need it on listing pages or want zero dependencies. Whichever you choose, use it consistently so every page shows the same number for the same post, and remember that tutorials written for the remark pipeline need adapting before they'll work on Astro 7's default setup.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper