Skip to main content
DamienG
Code editor beside a diagram of connected services and databases

Nuxt3 has been my stack of choice for a while now and it was time to port my site over from Nuxt2 - an exercise in itself I should blog about—but more concretely is the idea of excerpts.

Updated October 2026: Rewritten for Nuxt Content v3. The original v2 code used a Nitro plugin, ParsedContent and the root/children markdown AST; v3 runs the hook at build time, stores bodies in the compact "minimark" format and needs the excerpt field declared in your collection schema. The v2 code is kept at the end.

Basically when you have a list of articles you want to show a short snippet of the article to entice the reader to click on it. This can either be the description if it's a simple one-liner used in a card for example, or it can be the start of the article itself. Nuxt Content (v2 and still v3) supports generating excerpts for you by taking the content up to the <!--more--> marker in the text as the excerpt.

This is great if you've done it but if you haven't you get absolutely nothing!

We can address that with a short snippet of code and the trusty content:file:afterParse hook in much the same way as we did for the reading time plugin.

Declare the field

First up, v3 only returns fields that are in your collection's schema, so add excerpt to it in content.config.ts (this applies to <!--more--> excerpts too):

schema: z.object({
  // ...your other front matter
  excerpt: z.any().optional(),
}),

Generate the excerpt

Now create a local module (e.g. content-excerpts.ts in your modules folder) with the following contents. It takes the first two paragraphs of the body, and flattens any links in them down to their text. Excerpts usually end up inside a card that's already a link and nested links are invalid HTML.

import { defineNuxtModule } from "@nuxt/kit"

type MinimarkElement = [string, Record<string, any>?, ...MinimarkNode[]]
type MinimarkNode = string | MinimarkElement

const isElement = (n: MinimarkNode): n is MinimarkElement => Array.isArray(n)
const childrenOf = (el: MinimarkElement) => el.slice(2) as MinimarkNode[]

export default defineNuxtModule({
  meta: { name: "content-excerpts" },
  setup(_options, nuxt) {
    nuxt.hook("content:file:afterParse", (ctx) => {
      const { file, content } = ctx
      if (file.id.endsWith(".md") && content.body) {
        addExcerpt(content)
      }
    })
  },
})

const addExcerpt = (content: any) => {
  if (content.excerpt) return // respect <!--more--> if it was used

  const paragraphs: MinimarkElement[] = []
  for (const node of (content.body?.value as MinimarkNode[]) ?? []) {
    if (paragraphs.length >= 2) break
    if (isElement(node) && node[0] === "p") {
      paragraphs.push(withRemovedLinks(node))
    }
  }
  content.excerpt = { type: "minimark", value: paragraphs }
}

const withRemovedLinks = (el: MinimarkElement): MinimarkElement => [
  el[0],
  el[1] ?? {},
  ...flattenLinks(childrenOf(el)),
]

const flattenLinks = (children: MinimarkNode[]): MinimarkNode[] =>
  children.flatMap((c) => {
    if (!isElement(c)) return [c]
    if (c[0] === "a") return flattenLinks(childrenOf(c))
    return [withRemovedLinks(c)]
  })

This is a trimmed down version of what this site runs in its content-transform module.

Displaying it

The excerpt is the same shape as a body so you can hand it to <ContentRenderer> by swapping it in for the body:

<ContentRenderer
  v-if="article.excerpt"
  :value="{ ...article, body: article.excerpt }"
/>

If you're using .select(...) on queryCollection to keep your list pages lean, remember to include excerpt.

Original (Content v2)

For reference, this was the v2 version as a Nitro plugin in server\plugins. Note it dropped links (and their text) entirely rather than flattening them.

import type { MarkdownNode, MarkdownRoot, ParsedContent } from "@nuxt/content"

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook("content:file:afterParse", (file) => {
    if (file._id.endsWith(".md") && file.body) {
        addExcerpt(file)
      }
    }
  })
})

const addExcerpt = (file: ParsedContent) => {
  if (file.excerpt) return

  const excerpt: MarkdownRoot = { type: "root", children: [] }
  let paragraphsToInclude = 2

  visit(file.body!.children, (n) => {
    if (paragraphsToInclude == 0) return
    if (n?.tag == "p") {
      paragraphsToInclude--
      excerpt.children.push(withRemovedLinks(n))
    }
  })

  file.excerpt = excerpt
}

const withRemovedLinks = (n: MarkdownNode): MarkdownNode => {
  const node: MarkdownNode = {
    type: n.type,
    value: n.value,
    tag: n.tag,
    props: n.props,
    attributes: n.attributes,
    children: [] as MarkdownNode[],
  }

  visit(n.children!, (c) => {
    if (c.tag != "a") {
      node.children?.push(c)
    }
  })

  return node
}

0 responses

  1. Information is only used to show your comment. See my Privacy Policy.