Adding reading times to an Astro blog
The reading-time package and flowing the value through blog cards and post pages
- Published
- 24 September 2026
- Read time
- 4 min read
Was this useful?
Reading times set expectations before someone commits to an article. A “2 min read” label on a blog card tells you it’s a quick technique post. “14 min read” tells you to save it for later. Both are useful signals — and they’re trivial to add once you know which package to reach for.
This post covers adding reading times to both blog listing cards and individual
post pages in Astro, using the reading-time npm package from the
official Astro recipe.
The package
Astro’s documentation recommends the reading-time
package. It accepts a string of text, counts the words, and returns a result
object including a .text property formatted as "5 min read".
pnpm add reading-timeUsage is a single function call:
import readingTime from 'reading-time';
const result = readingTime(post.body ?? '');// result.text → "5 min read"// result.minutes → 4.7// result.words → 940The .text property is what gets displayed — it rounds to the nearest minute
and appends “min read”.
Where reading times appear
This blog shows reading time in two places:
- Blog cards — after the publication date, separated by a middle dot:
14 Apr 2026 · 5 min read - Post page hero — in the meta bar between the date and the tag pills
Blog cards
The computation happens in the listing components — BlogGrid.astro for paginated
blog pages and index.astro for the homepage recent-posts grid. Both import
the package and pass the result down to BlogPost.astro:
---import readingTime from 'reading-time';---
{posts.map((post) => ( <BlogPost title={post.data.title} description={post.data.description} url={`/blog/${post.id}`} cover={post.data.cover} pubDate={post.data.pubDate} readingTime={readingTime(post.body ?? '').text} />))}BlogPost.astro receives readingTime as an optional prop and appends it to
the existing date string:
---const { description, title, subTitle, url, cover, pubDate, draft, readingTime } = Astro.props;---
{formattedDate && ( <p class="card-meta"> {formattedDate}{readingTime && ` · ${readingTime}`} </p>)}Making readingTime optional means the card degrades gracefully in any context
where it isn’t passed — no template changes needed elsewhere.
Post pages
For individual post pages, the computation lives in blog/[id].astro alongside
the existing render() call:
---import readingTime from 'reading-time';
const { post } = Astro.props;const { Content, headings } = await render(post);const postReadingTime = readingTime(post.body ?? '').text;---
<MarkdownPostLayout frontmatter={post.data} postId={post.id} headings={headings} readingTime={postReadingTime}> <Content /></MarkdownPostLayout>MarkdownPostLayout.astro destructures the new prop and passes it straight
through to PageHero:
const { frontmatter, postId, headings = [], readingTime } = Astro.props;
// ...
<PageHero title={frontmatter.title} date={formattedDate ?? undefined} readingTime={readingTime} tags={frontmatter.tags} {/* ... */}/>In PageHero.astro, the reading time sits between the date and the tags divider.
The condition that guards the meta bar is extended to include readingTime, and
the divider condition is updated to account for either date or reading time being
present:
{(author || date || readingTime || (tags && tags.length > 0)) && ( <div class="page-hero__meta"> {author && <span class="page-hero__author">By {author}</span>} {date && <span class="page-hero__date">{date}</span>} {readingTime && ( <span class="page-hero__reading-time">{readingTime}</span> )} {(date || readingTime) && tags && tags.length > 0 && ( <span class="page-hero__divider" aria-hidden="true" /> )} {/* tags... */} </div>)}With that in place, every post page shows its reading time in the hero meta bar next to the date, and the divider between the meta bar and the tags appears whenever either one is present, so tags never end up floating with no separator when a post has no author or date set.
The reading-time package does the hard part: counting words and formatting
the label. The rest of the work was just making sure that one value flowed
cleanly from post.body, through the listing pages and the post-page render,
down to the two places a reader actually sees it: the card and the hero.
Working on something similar?
Need help raising the bar?
I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.
- Engineering practice review
- Hands-on delivery
- Team mentoring
If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.
Free · Practical · One email per post
Get practical engineering notes
One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.

Comments
Loading comments…
Leave a comment