The Reader Who Doesn’t Know How Long This Will Take
You’ve written a 5,000-word technical deep dive. A reader lands on your blog and sees no reading time estimate. They’re on a ten-minute coffee break. They don’t know whether to start now or bookmark it for later. They bounce. Medium, Dev.to, and every major content platform shows reading time because it reduces exactly this friction — but adding it manually to every post and keeping it accurate as posts get edited is tedious and error-prone.
The right fix is a Remark plugin that calculates reading time automatically at build time and injects it into each post’s frontmatter. No manual updates, no hardcoded values. The specific file is remark-plugins/remark-reading-time.mjs — about 10 lines of code that run during the build for every Markdown file in the project.
What You’ll Need Installed
From the project’s package.json:
{
"type": "module",
"dependencies": {
"astro": "^5.5.4",
"reading-time": "^1.5.0",
"mdast-util-to-string": "^4.0.0"
}
}
npm install reading-time mdast-util-to-string
node --version # ES modules require Node 14+
reading-time: Calculates reading time from text using average reading speed (200-250 words/min)mdast-util-to-string: Extracts plain text from a Markdown AST — handles edge cases like ignoring link URLs and including image alt text
What Remark Is Actually Doing to Your Markdown
Remark is a Markdown processor that transforms source text into an Abstract Syntax Tree (AST), runs a chain of plugins against that tree, and then renders HTML. The AST is a nested JavaScript object — each heading, paragraph, and code block becomes a node with a type, a children array, and text content.
For example, this Markdown:
# Hello
This is **bold** text.
Becomes this AST (simplified):
{
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: 'Hello' }]
},
{
type: 'paragraph',
children: [
{ type: 'text', value: 'This is ' },
{ type: 'strong', children: [{ type: 'text', value: 'bold' }] },
{ type: 'text', value: ' text.' }
]
}
]
}
Your plugin runs during the build phase, when the content is in this structured form — which is why it can count words reliably across all the different formatting constructs.
The Plugin: Ten Lines, One Pattern
Every Remark plugin follows a factory pattern. The outer function runs once when the plugin is registered. It returns the transformer function that runs for each Markdown file. This is the part that trips most people up the first time — if you forget the inner return, the plugin loads but never processes anything.
export function remarkReadingTime() {
// Outer function: runs once at build start
return function (tree, { data }) {
// Inner function: runs for EACH Markdown file
// tree = the AST for this file
// data = metadata we can inject into frontmatter
// Step 1: Convert AST to plain text
const textOnPage = toString(tree);
// Step 2: Calculate reading time
const readingTime = getReadingTime(textOnPage);
// Step 3: Inject into frontmatter
data.astro.frontmatter.minutesRead = readingTime.text;
};
}
The full implementation with imports:
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;
};
}
mdast-util-to-string handles the edge cases you’d forget to handle in a manual traversal: it includes code block content in the word count, ignores HTML comments, includes image alt text, and ignores link URLs. reading-time returns an object with text (the formatted “3 min read” string), minutes (the raw float), and words (the word count).
The path data.astro.frontmatter is Astro-specific. If you’re using Remark with Gatsby or Next.js, the injection point differs — check your framework’s documentation.
Wiring It Into Astro
In your Astro config:
// astro.config.mjs
import { remarkReadingTime } from './remark-plugins/remark-reading-time.mjs';
export default defineConfig({
markdown: {
remarkPlugins: [remarkReadingTime]
}
});
In your blog post layout:
---
const { frontmatter } = Astro.props;
---
<article>
<h1>{frontmatter.title}</h1>
<p class="text-gray-500">{frontmatter.minutesRead}</p>
<slot />
</article>
Performance and Some Gotchas
This runs at build time, not at runtime. For a 5,000-word article, the AST traversal takes roughly 2ms and the word count another 1ms. With 1,000 blog posts, that’s 3 seconds added to your build — negligible compared to image optimization.
The reading-time library assumes English word boundaries (spaces between words). Languages like Chinese and Japanese don’t use spaces, so the library undercounts reading time for CJK content. Pass a custom wordsPerMinute to compensate:
const readingTime = getReadingTime(textOnPage, {
wordsPerMinute: 500
});
Code blocks are included in the word count by default. A post with 500 words of explanation and 2,000 words of code samples will show a much longer reading time than the actual prose justifies. I haven’t addressed this in the current implementation — the tradeoff is that code-heavy posts slightly overestimate reading time, which sets conservative expectations rather than frustrating ones.
Plugin order matters when you have multiple plugins. Content-analysis plugins like this one should run before anything that transforms the AST (like syntax highlighting), or the word count might include HTML wrapper tags that the highlighter injected.
If an author manually sets minutesRead in their frontmatter, the plugin currently overwrites it. A small guard fixes this:
if (!data.astro.frontmatter.minutesRead) {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
data.astro.frontmatter.minutesRead = readingTime.text;
}
The broader skill here is understanding that Markdown isn’t opaque text — it’s a structured data format that can be programmatically analyzed. Once you’re comfortable with the unified ecosystem’s plugin pattern, writing ESLint rules (JavaScript AST), Babel plugins (code transformation), and custom Webpack loaders all follow the same mental model.