Original post: Adding reading times to an Astro blog
Series: Part 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".
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
Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/reading-time-astro
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.
Top comments (0)