Custom Tags & Advanced Components
Use this page when Markdown needs to contain trusted, component-like tags such as thinking, answer-box, or other domain-specific blocks.
The recommended app-level path is:
- install
VueRendererMarkdownwith an app-scopedcomponentsmap - render with
MarkdownRender - keep global
setCustomComponentsonly for compatibility or one-off client apps
Use tag-like names for custom components, for example thinking, answer-box, or my_component. Namespaced forms like foo:bar are ignored. Built-in override keys such as code_block stay reserved for node renderer overrides and are not treated as custom tags. Non-reserved component keys registered through either VueRendererMarkdown or setCustomComponents are included in the renderer's custom-tag set.
Reach for parser hooks only after this flow stops being enough.
If these tags live inside a docs site or VitePress theme, pair this page with Docs Site & VitePress so the theme-level registration and CSS order stay in one place.
1. The simplest custom-tag setup
import MarkdownRender, { VueRendererMarkdown } from 'markstream-vue'
import { createApp } from 'vue'
import App from './App.vue'
import ThinkingNode from './ThinkingNode.vue'
createApp(App)
.use(VueRendererMarkdown, {
components: {
thinking: ThinkingNode,
},
})
.component('MarkdownRender', MarkdownRender)
.mount('#app')<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
const markdown = '<thinking>Step by step</thinking>'
</script>
<template>
<MarkdownRender
:content="markdown"
/>
</template>When non-reserved tag components are registered through VueRendererMarkdown or setCustomComponents, their keys are included in the renderer's custom-tag set. The parser emits a custom node whose type is the tag name itself.
For older integrations, the scoped global API still works:
import type { Component } from 'vue'
import { setCustomComponents } from 'markstream-vue'
declare const ThinkingNode: Component
setCustomComponents('chat', { thinking: ThinkingNode })<MarkdownRender
custom-id="chat"
:content="markdown"
/>2. A practical Vue component for nested content
Custom tags usually contain Markdown inside them. The easiest way to preserve that nested Markdown is to render the tag body with another MarkdownRender.
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
const props = defineProps<{
node: {
type: 'thinking'
content?: string
loading?: boolean
}
customId?: string
isDark?: boolean
}>()
</script>
<template>
<section class="thinking-box" :data-loading="props.node.loading || undefined">
<header class="thinking-box__title">
Thinking
</header>
<MarkdownRender
:content="String(props.node.content ?? '')"
:custom-id="props.customId"
:is-dark="props.isDark"
:custom-html-tags="['thinking']"
:typewriter="false"
:viewport-priority="false"
:defer-nodes-until-visible="false"
:max-live-nodes="0"
:batch-rendering="false"
/>
</section>
</template>That nested-renderer pattern is also what keeps repeated and nested custom tags predictable.
3. What the parser gives you
For a trusted custom tag, the emitted node typically includes:
type: the tag name, for examplethinkingtag: the original tag namecontent: the inner Markdown or text contentraw: the original tag fragment when availableattrs: extracted tag attributes when availableloading: whether the tag is still in a streaming mid-stateautoClosed: whether the parser temporarily auto-closed the tag during streaming
For declared custom tags, use content/raw for source payloads such as JSON, YAML, or tool-call data. Use children or a nested renderer when the tag body should render as Markdown; those child nodes still follow the normal inline parsing pipeline.
Because custom tags still use HTML-like delimiters, a literal closing tag such as </custom-data> inside a machine payload closes the custom node. Escape < in that payload, for example as \u003c, when the delimiter text must be preserved as data.
The exact attrs shape can vary, so treat it as raw attribute data that your component normalizes for its own needs.
Renderer node reuse compares common custom object fields such as attrs, data, props, and payload structurally. If a parser hook attaches other object fields to custom nodes, replace those objects when their contents change.
4. Repeated and nested custom tags
This flow is designed to support:
- repeated tags in the same document
- nested custom tags
- streaming mid-states while the closing tag has not arrived yet
Tips that help in practice:
- pass the same
custom-html-tagslist into nested renderers - disable batching and viewport deferral inside small nested shells when you want the most predictable streaming behavior
- keep the outer renderer scoped with
custom-id
5. When custom-html-tags is enough, and when it is not
Use custom-html-tags plus setCustomComponents when:
- the syntax is already tag-like
- you trust the source
- you mainly need a different renderer, not a different grammar
Move to Advanced Parser Hooks when:
- tags need token rewriting before they become stable nodes
- you must merge, split, or reshape nodes after parsing
- the source format is not well represented by a tag-like wrapper
6. Scope and cleanup still matter
For SSR, Nuxt, multi-tenant servers, and test runners, prefer app.use(VueRendererMarkdown, { components }). That map is stored on the Vue app instance and does not leak between SSR requests.
Even for custom tags, prefer scoped mappings:
import type { Component } from 'vue'
import { removeCustomComponents, setCustomComponents } from 'markstream-vue'
declare const ThinkingNode: Component
setCustomComponents('chat', { thinking: ThinkingNode })
// later, when the scoped renderer is no longer needed
removeCustomComponents('chat')This keeps custom behavior local to the page, route, or app surface that actually needs it.