"5 min read" next to a title. You can get that number from the Text Analyzer API: it returns reading_time_min and speaking_time_min with every word count. This page shows where those two numbers come from, how to swap in your own reading speed, and how to store the result so you never compute it on a page view.
Where the number comes from
curl -X POST "https://apixies.io/api/v1/analyze-text" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Paste the plain text of your post here."}'
The fields that matter here:
{
"status": "success",
"data": {
"words": 8,
"reading_time_min": 0,
"speaking_time_min": 0.1
}
}
No magic behind them:
reading_time_miniswords / 200, rounded to one decimalspeaking_time_miniswords / 130, rounded to one decimal
Both are minutes as a decimal, so 2.5 is two and a half minutes. There's no parameter to change the speed. You don't need one, because words is right there. More on that in a moment.
I sent runs of plain words to see the rounding:
| Words | reading_time_min |
speaking_time_min |
|---|---|---|
| 9 | 0 | 0.1 |
| 10 | 0.1 | 0.1 |
| 200 | 1 | 1.5 |
| 250 | 1.3 | 1.9 |
| 1,250 | 6.3 | 9.6 |
Anything under 10 words reads as 0, so handle that case in your badge.
Is 200 words a minute right?
It's a cautious figure. Studies of adults reading English silently tend to land somewhere between 200 and 250, and Medium built its "min read" on 265. Technical writing goes slower. People stop at code, go back, try things.
If you'd like another speed, work it out from words:
const minutes = stats.words / 238;
Same for talks. 130 words a minute is a calm presenting pace. If you know you speak faster, divide by your own number.
What you send matters more than the speed
The estimate is only as good as the word count, and the API counts whatever you give it. HTML tags, Markdown markers and code all count as words.
I ran the Markdown source of one of our guides through it. 1,123 words, 5.6 minutes. Then again without the code blocks: 880 words, 4.4 minutes. Rounded up for a badge, that's "6 min read" against "5 min read". Whether code counts as reading is your call. Just make it on purpose.
So strip the markup first. strip_tags() in PHP, element.textContent in the browser, or whatever your editor offers for plain text.
One more limit: words are split on spaces. Text in Chinese, Japanese or Thai comes back as one or two "words", and the reading time with it as 0. For those languages, count characters yourself and divide by a characters-per-minute figure.
Compute it once, when the post is saved
A reading time only changes when the text does. Work it out in the save handler, store it next to the post, and print the stored value in your template. That's one API call per edit, which fits the free tier of 75 requests a day without trying. It also keeps your API key on the server. Don't call the API from a script on the page: your key would be in the page source, and every visitor would spend a request.
PHP (Laravel)
function readingStats(string $html): array
{
$text = trim(html_entity_decode(strip_tags($html)));
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-API-Key: " . getenv('APIXIES_API_KEY'),
'content' => json_encode(['text' => $text]),
'ignore_errors' => true,
]]);
$body = json_decode(file_get_contents('https://apixies.io/api/v1/analyze-text', false, $context), true);
if (($body['status'] ?? '') !== 'success') {
throw new RuntimeException(($body['code'] ?? 'ERROR') . ': ' . ($body['message'] ?? ''));
}
return [
'word_count' => $body['data']['words'],
'reading_minutes' => max(1, (int) ceil($body['data']['reading_time_min'])),
];
}
In a model observer or controller:
$post->fill(readingStats($post->content))->save();
And in the Blade template:
<span>{{ $post->reading_minutes }} min read</span>
<span>{{ number_format($post->word_count) }} words</span>
Python (Django or anything else)
import math
import os
import requests
def reading_stats(plain_text):
res = requests.post(
"https://apixies.io/api/v1/analyze-text",
json={"text": plain_text},
headers={"X-API-Key": os.environ["APIXIES_API_KEY"]},
timeout=15,
)
body = res.json()
if body["status"] != "success":
raise RuntimeError(f"{body['code']}: {body['message']}")
data = body["data"]
return {
"word_count": data["words"],
"reading_minutes": max(1, math.ceil(data["reading_time_min"])),
}
JavaScript (Node, in a build step or a CMS hook)
async function readingStats(plainText) {
const res = await fetch("https://apixies.io/api/v1/analyze-text", {
method: "POST",
headers: {
"X-API-Key": process.env.APIXIES_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: plainText }),
});
const body = await res.json();
if (body.status !== "success") {
throw new Error(`${body.code}: ${body.message}`);
}
return {
wordCount: body.data.words,
readingMinutes: Math.max(1, Math.ceil(body.data.reading_time_min)),
};
}
If the call fails, save the post anyway and leave the badge off. A missing "min read" is fine. A failed save because a word counter timed out isn't.
Showing it
Round up, and never show zero:
function readLabel(minutes) {
return `${Math.max(1, Math.ceil(minutes))} min read`;
}
function talkLabel(minutes) {
const total = Math.round(minutes * 60);
const m = Math.floor(total / 60);
const s = total % 60;
return m === 0 ? `${s}s` : s === 0 ? `${m}m` : `${m}m ${s}s`;
}
readLabel(5.6); // "6 min read"
talkLabel(9.6); // "9m 36s"
speaking_time_min is the handy one for scripts. Paste the text of a talk or a podcast intro and you know roughly how long it runs before you've rehearsed it once. It only has one decimal, so expect it to be off by a few seconds either way.
Next steps
- Text Analysis API tutorial: every field in the response and how it's counted
- Build a word counter: live counts in the browser, limits checked on the server
- Text Analyzer API reference: parameters and error codes
- Text Analyzer tool: paste some text and see the numbers
- All guides