Building a custom MDX read time plugin (because why not)

Why I spent 2 hours writing 83 lines of code to avoid installing a 50KB package. Spoiler: It was totally worth it. Learn how to build a custom Remark/Recma plugin for MDX.

Read time is about 13 minutes

Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.

Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.

Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.

Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.

Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.

Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.

The Problem

I wanted to add read time estimates to my blog posts to increase SEO visibility. You know, those little "5 min read" tags you see everywhere? Simple feature, right?

So I did what any reasonable developer would do: I googled "mdx read time npm" and found like 10 different packages that could solve this in 30 seconds.

But then I looked at my package.json.

It already looked like a receipt you would get at CVS ๐Ÿ˜ฑ. Did I really need another dependency for something that should be relatively this simple?

Narrator: He did not.

The "One More Dependency" Problem

Look, I get it. Modern JavaScript development is all about standing on the shoulders of giants (and their giants, and their giants' giants). But sometimes you look at your node_modules folder and wonder if you've accidentally downloaded the entire internet like I do. And from previous experience where installing dependencies requires what seems like an act of Congress I decided to audit my dependency usage.

$ du -sh node_modules/ 306M node_modules/

So instead of installing reading-time, remark-reading-time, or mdx-reading-time-for-cool-people (okay, I made that last one up), I decided to build my own.

The Two-Plugin Approach

Here's the thing about MDX: it's a magical transformer that turns markdown into React components. The transformation happens in five stages:

  1. Markdown โ†’ Parse into MDAST (Markdown Abstract Syntax Tree)
  2. MDAST โ†’ Transform with Remark plugins
  3. MDAST โ†’ Convert to HAST (HTML AST)
  4. HAST โ†’ Convert to JavaScript
  5. JavaScript โ†’ Modify with Recma plugins

For read time calculation, we need to work at two stages:

Stage 1: Remark Plugin (Count the Words)

First, we need to count words while we still have access to the markdown structure:

export function remarkCalculateWordCount() { return (tree: Root, file: VFile) => { let wordCount = 0; // Visit all text nodes visit(tree, "text", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += words.length; }); // Count code blocks at 50% weight // (because nobody really "reads" code) visit(tree, "code", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += Math.floor(words.length * 0.5); }); // Stash the count for later file.data.wordCount = wordCount; }; }

Stage 2: Recma Plugin (Inject the Read Time)

Then, we use a Recma plugin to inject the calculated read time into the frontmatter export in the final JavaScript:

export function recmaInjectReadTime() { return (tree: any, file: VFile) => { const wordCount = (file.data?.wordCount as number) || 0; const WORDS_PER_MINUTE = 100; const readTime = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); // Find and modify: export const frontmatter = { ... } visitEstree(tree, (node: any) => { if ( node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration" ) { const declaration = node.declaration.declarations?.[0]; if ( declaration?.id?.name === "frontmatter" && declaration.init?.type === "ObjectExpression" ) { // Add readTime property to frontmatter declaration.init.properties.push({ type: "Property", key: { type: "Identifier", name: "readTime" }, value: { type: "Literal", value: readTime }, kind: "init", method: false, shorthand: false, computed: false, }); } } }); }; }

Why This Approach?

You might be thinking: "Alex, you just wrote 83 lines of code to avoid installing a package. That's not efficient."

And you'd be right! But here's why I did it anyway:

1. Zero Dependencies

No new packages to audit, update, or worry about getting deprecated. The only dependencies are ones I already had (unist-util-visit and estree-util-visit) and those were only to parse the tree.

2. Full Control

Want to count code blocks at 30% instead of 50%? Change one number. Want to exclude certain sections? Add a filter. No waiting for upstream maintainers or worrying about breaking changes.

3. Learning AST Manipulation

This was a great excuse to learn how MDX transforms work under the hood. AST manipulation used to be "witchcraft" to me, then I wrote some code to bend AST manipulation to my will. I mean its still "witchcraft" but my knowledge has expanded on it exponentially.

4. Performance

This runs during build time, not runtime. Zero impact on bundle size or performance ๐Ÿ˜Ž and we all know how much I love web performance.

5. It's Actually Fun

There's something deeply satisfying about solving a problem with exactly the code you need and nothing more.

Wiring It Up

To use these plugins in your MDX setup (I'm using Rsbuild with @rsbuild/plugin-mdx):

// rsbuild.config.ts import { remarkCalculateWordCount, recmaInjectReadTime, } from "./src/lib/recma-read-time"; export default defineConfig({ plugins: [ pluginMdx({ mdxOptions: { remarkPlugins: [ remarkCalculateWordCount, // Stage 1: Count words ], recmaPlugins: [ recmaInjectReadTime, // Stage 2: Inject readTime ], }, }), ], });

Now every MDX file automatically gets a readTime property in its frontmatter that is automatically injected (how cool is that):

import { frontmatter } from "./my-post.mdx"; console.log(frontmatter.readTime); // 5 (5 minutes)

The Math Behind Read Time

I went with 100 words per minute for the base reading speed. According to research, the average reading speed is 200-250 WPM, but I did some research and found that technical content is always read a little more carefully:

  • Technical content is slower (~100-150 WPM)
  • People skim blog posts
  • Code examples slow you down (hence the 50% weight)

So 100 WPM feels about right for technical blog posts.

Also, I set a minimum of 1 minute because "0 min read" would be pretty insane ๐Ÿ˜…

Results

// Before import readingTime from "reading-time"; const stats = readingTime(content); // + 1 dependency, + 50KB, runtime processing // After import { frontmatter } from "./post.mdx"; const readTime = frontmatter.readTime; // + 0 dependencies, + 0KB, build-time processing โœจ

Was It Worth It?

Let's do the math:

  • Time spent writing custom solution: ~2 hours
  • Time saved not debugging dependency issues: โˆž hours
  • Dependencies avoided: 1
  • Sense of accomplishment: Priceless

Okay, maybe it wasn't the most economically rational decision. But I learned something new, have full control over the implementation, and my node_modules folder is 50KB lighter. Not really all that much but every bit counts (pun intended).

Plus, now I have a blog post about it. So really, this was a 4D chess move for content creation.

Key Takeaways

  1. โœ… Not every problem needs an npm package - Sometimes 83 lines of custom code is better
  2. โœ… Learn the tools you use - Understanding Remark/Recma plugins unlocks superpowers
  3. โœ… Build-time > Runtime - Calculate expensive things during build, not in the browser
  4. โœ… AST manipulation is approachable - It's just visiting nodes and modifying objects
  5. โœ… Use the right tool for the job - Remark for markdown, Recma for JavaScript

When Should You Do This?

Build a custom solution when:

  • โœ… The problem is simple and well-scoped
  • โœ… You already have the dependencies you need
  • โœ… You want full control over the implementation
  • โœ… It's a good learning opportunity

Use an existing package when:

  • โœ… The problem is complex (don't reinvent OAuth)
  • โœ… The package is well-maintained and widely used
  • โœ… You need the feature right now
  • โœ… The package does way more than you could build

The Complete Code

The full implementation is here:

recma-read-time.ts
import { visit } from "unist-util-visit"; import { visit as visitEstree } from "estree-util-visit"; import type { Root } from "mdast"; import type { VFile } from "vfile"; /** * Remark plugin to calculate word count from MDX content * Stores the result in file.data for use by recma plugin */ export function remarkCalculateWordCount() { return (tree: Root, file: VFile) => { let wordCount = 0; // Visit all text nodes in the MDX AST visit(tree, "text", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += words.length; }); // Also count words in code blocks (at 50% weight) visit(tree, "code", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += Math.floor(words.length * 0.5); }); // Store word count in file data for recma plugin if (!file.data) { file.data = {}; } file.data.wordCount = wordCount; }; } /** * Recma plugin to inject readTime into the frontmatter export * Reads word count from file.data and modifies the JavaScript AST */ export function recmaInjectReadTime() { return (tree: any, file: VFile) => { const wordCount = (file.data?.wordCount as number) || 0; const WORDS_PER_MINUTE = 100; const minutes = Math.ceil(wordCount / WORDS_PER_MINUTE); const readTime = Math.max(1, minutes); // Visit all nodes in the JavaScript AST visitEstree(tree, (node: any) => { // Look for: export const frontmatter = { ... } if ( node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration" ) { const declaration = node.declaration.declarations?.[0]; if ( declaration?.id?.name === "frontmatter" && declaration.init?.type === "ObjectExpression" ) { // Found the frontmatter export, add readTime property declaration.init.properties.push({ type: "Property", key: { type: "Identifier", name: "readTime", }, value: { type: "Literal", value: readTime, }, kind: "init", method: false, shorthand: false, computed: false, }); } } }); }; }
rsbuild.config.ts
import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; import { pluginMdx } from "@rsbuild/plugin-mdx"; import remarkGfm from "remark-gfm"; import { remarkCalculateWordCount, recmaInjectReadTime, } from "./src/lib/recma-read-time"; export default defineConfig({ plugins: [ pluginReact(), pluginMdx({ mdxLoaderOptions: { providerImportSource: "@mdx-js/react", remarkPlugins: [remarkGfm, remarkCalculateWordCount], recmaPlugins: [recmaInjectReadTime], }, }), ], // rest of Rsbuild config options });

Related Posts: