One-off tip

Support checkout

  1. 1Choose amount
  2. 2Payment
  3. 3Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Adding reading times to an Astro blog
Cover image: Person holding a silver pocket watch — Photo by Veri Ivanova on Unsplash
astroengineeringfrontend

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
SeriesPart of How this blog was built — twenty posts on every decision that shaped this site.

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".

Terminal window
pnpm add reading-time

Usage 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 → 940

The .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:

src/components/BlogGrid.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:

src/components/BlogPost.astro
---
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:

src/pages/blog/[id].astro
---
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:

src/layouts/MarkdownPostLayout.astro
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:

src/components/PageHero.astro
{(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
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

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.