
I've been using Nuxt2 quite a bit for my sites (including this one) and am now starting to use Nuxt3 for a few new ones and am finding the docs lacking in many places or confusing in others so hope to post a few more tips in the coming weeks.
Updated October 2026: Rewritten for Nuxt Content v3 (this site now runs Nuxt 4 with
@nuxt/content3.14). The original Content v2 version used a Nitro plugin, walked the markdown AST withunist-util-visitand checkedfile._id; it's kept below for anyone still on v2.
Today we have the "reading time" popularised by sites like Medium.
Nuxt Content v3
Content v3 parses your markdown at build time and stores the result in a SQLite database, so the place to add extra properties is the content:file:afterParse hook which fires after a file is parsed but before it is saved. It's a Nuxt (build-time) hook now rather than a Nitro one, so it goes in nuxt.config.ts or a local module rather than /server/plugins.
There are two catches compared to v2:
- Declare the new fields in your collection schema or they won't come back from
queryCollection(undeclared fields get tucked away inmeta). - The body is compressed "minimark" by default rather than the old
root/childrenAST. Each node is either a string (text) or an array of[tag, props, ...children].
First add the fields to content.config.ts:
import { defineCollection, defineContentConfig, z } from "@nuxt/content"
export default defineContentConfig({
collections: {
content: defineCollection({
type: "page",
source: "**/*.md",
schema: z.object({
// ...your other front matter
wordCount: z.number().optional(),
minutes: z.number().optional(),
}),
}),
},
})
Then create a small local module, e.g. /modules/reading-time.ts (Nuxt registers top-level files in /modules automatically, though listing it in modules in nuxt.config.ts doesn't hurt):
import { defineNuxtModule } from "@nuxt/kit"
const wpm = 225
type MinimarkNode = string | [string, Record<string, any>?, ...MinimarkNode[]]
export default defineNuxtModule({
meta: { name: "reading-time" },
setup(_options, nuxt) {
nuxt.hook("content:file:afterParse", (ctx) => {
const { file, content } = ctx
if (!file.id.endsWith(".md") || content.wordCount) return
let count = 0
const walk = (nodes: MinimarkNode[]) => {
for (const node of nodes) {
if (typeof node === "string") {
count += node.trim().split(/\s+/).filter(Boolean).length
} else if (Array.isArray(node)) {
walk(node.slice(2) as MinimarkNode[])
}
}
}
walk((content.body as any)?.value ?? [])
content.wordCount = count
content.minutes = Math.ceil(count / wpm)
})
},
})
This is essentially what this site runs (alongside excerpts, a default image and comment counts) in its own content-transform module. Walking the parsed body means the front matter and markdown syntax don't get counted (code blocks still do, which seems fair enough).
If you'd rather keep it in config, the same function can live directly in nuxt.config.ts under hooks: { "content:file:afterParse"(ctx) { ... } }.
Original (Content v2)
In Nuxt3 with Content v2 we created a new file called /server/plugins/reading-time.ts with the following contents:
import { visit } from "unist-util-visit"
const wpm = 225
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("content:file:afterParse", (file) => {
if (file._id.endsWith(".md") && !file.wordCount) {
file.wordCount = 0
visit(
file.body,
(n: any) => n.type === "text",
(node) => {
file.wordCount += node.value.trim().split(/\s+/).length
}
)
file.minutes = Math.ceil(file.wordCount / wpm)
}
})
})
This is a little more convoluted than in Nuxt2 because there we were able to look at the plain text and set a property before parsing (beforeParse), however in Nuxt3 those properties do not persist all the way through to the page.
Nuxt2
For completeness in case you are still using Nuxt2 the equivalent there was to modify nuxt.config.js to add this block:
export default {
// ...
hooks: {
"content:file:beforeInsert": (document) => {
if (document.extension === ".md" && document.minutes === undefined) {
document.minutes = readingTime(document.text)
}
document.category = getCategory(document)
},
},
// ...
}
Then place this function at the end of the file:
export const readingTime = (text) => {
const wpm = 225
const words = text.trim().split(/\s+/).length
return Math.ceil(words / wpm)
}
Displaying it
Now, anywhere on your page where you are displaying content from those markdown files you can simply do:
<p v-if="post.minutes">{{ post.minutes}} minutes</p>
With v3 just make sure minutes is included if you're using .select(...) on your queryCollection call to trim down the fields.
I've chosen a reading speed of 225 words-per-minute but obviously reading speed is highly subjective.
One flexible alternative would be to record the number of words an article has and then in the front-end divide it by a user-configurable value. While it's unlikely worth the effort on a small site where somebody is hitting a single article if you're putting out a lot of related interesting content a viewer might peruse through it could be nice.
0 responses