| |

Vue.js 67 🟢 Cross-Platform Render Targets with Custom Renderers (createRenderer)

⏱️ Estimated read: 15 min | |

Vue’s core runtime is platform-agnostic. The DOM-specific parts—document.createElement, element.setAttribute, element.appendChild—live in a separate package, @vue/runtime-dom, which is itself built on the same createRenderer API that is exposed to application developers . This means the component model, the reactivity system, the virtual DOM diffing, and the lifecycle hooks are not tied to the browser. They are tied to an abstraction layer that the renderer implements. createRenderer is the function that takes a platform-specific implementation of that abstraction and returns a createApp function that uses it .

The abstraction is defined by RendererOptions, an interface with a set of required and optional methods. The required methods are the operations the runtime needs to perform on the host environment: create an element, create a text node, insert a node into a parent, remove a node, set the text content, patch a prop, and navigate the tree (parentNode, nextSibling). The optional methods—querySelector, setScopeId, cloneNode, insertStaticContent—are optimizations or DOM-specific features that a custom renderer can omit . Once these methods are implemented, the renderer can target any environment: a terminal cell buffer, a canvas, a PDF document, or a native window.

Key point: createRenderer takes a RendererOptions object with platform-specific implementations of createElement, insert, remove, patchProp, createText, createComment, setText, setElementText, parentNode, and nextSibling, and returns a createApp function that renders Vue components to that platform. The same component code that renders to the DOM can render to a terminal, a canvas, or a PDF, because the runtime only calls the operations the renderer provides.


Why the renderer is abstracted

The platform problem. A Vue component describes its output as a tree of virtual nodes. The runtime’s job is to turn that tree into something the user can see and interact with. In a browser, that something is a DOM element. In a terminal, it is a cell in a buffer. In a canvas, it is a draw call. The runtime does not know or care which; it only knows the operations that the renderer exposes . The abstraction is what makes the component portable.

The @vue/runtime-dom reference implementation. The DOM renderer is not a special case in the runtime. It is a package that calls createRenderer with a RendererOptions object whose methods call the DOM APIs . The Vue documentation points to @vue/runtime-dom as the reference implementation and to @vue/runtime-test, a private package used for Vue’s own unit testing, as a simpler example . A custom renderer is not a second-class citizen; it is the same mechanism the official renderer uses.

The ecosystem proof. The custom renderer API is used in production by several packages. vue3-pixi is a renderer that creates PixiJS objects instead of HTML elements, allowing Vue components to drive a 2D WebGL scene . @vui-rs/vue is a renderer for a terminal cell buffer, allowing Vue components to build terminal user interfaces . @i2d/pdf-frame-vue is a renderer that produces PDF and canvas graphics from Vue templates . Each of these is a different RendererOptions implementation, and each reuses the same component model.

The testing problem. A custom renderer that produces a plain object tree instead of DOM nodes is easier to assert on than a DOM tree. @vue/runtime-test exists for this reason: the test renderer’s createElement returns a simple object, and the test can inspect that object directly . A custom renderer for a design system can do the same thing, producing a serializable description of the output that is easier to snapshot than the DOM.


a. The RendererOptions interface and the required methods

The RendererOptions interface has ten required methods and four optional ones. The required methods are the minimum a platform must implement to be a valid render target.

// The createRenderer function from @vue/runtime-core
import { createRenderer } from '@vue/runtime-core'

// The RendererOptions interface, as documented
// The generic parameters are the node type and the element type
interface RendererOptions<HostNode, HostElement> {
  // Patch a prop on an element
  // prevValue is the old value, nextValue is the new value
  patchProp(
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
  ): void

  // Insert a node into a parent, optionally before an anchor
  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void

  // Remove a node from its parent
  remove(el: HostNode): void

  // Create an element of the given type
  createElement(
    type: string,
    namespace?: ElementNamespace,
    isCustomizedBuiltIn?: string,
    vnodeProps?: (VNodeProps & { [key: string]: any }) | null,
  ): HostElement

  // Create a text node
  createText(text: string): HostNode

  // Create a comment node (used for v-if placeholders)
  createComment(text: string): HostNode

  // Set the text content of a text node
  setText(node: HostNode, text: string): void

  // Set the text content of an element
  setElementText(node: HostElement, text: string): void

  // Get the parent of a node
  parentNode(node: HostNode): HostElement | null

  // Get the next sibling of a node
  nextSibling(node: HostNode): HostNode | null
}

The patchProp method is the most complex. It receives the element, the prop key, the previous value, and the next value. For a DOM renderer, this method handles attributes, properties, classes, styles, and events. For a custom renderer, it can do whatever the platform requires: setting a property on a PixiJS object, writing a value to a terminal cell, or storing a prop in a plain object . The key is that the runtime does not know what a “prop” means on the platform; it only knows that when a prop changes, it calls patchProp.

The insert and remove methods are the tree manipulation operations. The runtime calls insert when a new node is mounted, and remove when an old node is unmounted. The anchor parameter in insert is used for inserting a node before a specific sibling, which is how the runtime maintains the correct order during diffing.

The createElement method creates a host element. The type parameter is the tag name from the template—div, button, or, in a custom renderer, box, text, or sprite. The vnodeProps parameter is optional and is used by the DOM renderer for optimized props handling; a custom renderer can ignore it.


b. The optional methods and the minimal renderer

The optional methods are not required for correctness, but they enable optimizations and DOM-specific features. A minimal custom renderer can omit all of them.

// A minimal renderer that targets a plain JavaScript object tree
// This is the pattern that @vue/runtime-test uses
interface TestNode {
  type: string
  props: Record<string, any>
  children: TestNode[]
  text?: string
}

// The options object
const options: RendererOptions<TestNode, TestNode> = {
  // Create an element: a plain object
  createElement(type) {
    return { type, props: {}, children: [] }
  },
  // Create a text node: an object with a text field
  createText(text) {
    return { type: '#text', props: {}, children: [], text }
  },
  // Create a comment node: a distinct type
  createComment(text) {
    return { type: '#comment', props: {}, children: [], text }
  },
  // Set the text of a text node
  setText(node, text) {
    node.text = text
  },
  // Set the text of an element
  setElementText(node, text) {
    node.children = [{ type: '#text', props: {}, children: [], text }]
  },
  // Insert a child into a parent
  insert(child, parent, anchor) {
    if (anchor) {
      const index = parent.children.indexOf(anchor)
      parent.children.splice(index, 0, child)
    } else {
      parent.children.push(child)
    }
  },
  // Remove a child from its parent
  remove(child) {
    // The parent reference is not available here
    // A real renderer tracks the parent or uses the DOM's removeChild
  },
  // Patch a prop: store it on the object
  patchProp(el, key, prev, next) {
    el.props[key] = next
  },
  // Get the parent: not tracked in this minimal example
  parentNode(node) {
    return null
  },
  // Get the next sibling: not tracked in this minimal example
  nextSibling(node) {
    return null
  },
}

// Create the renderer
const { createApp } = createRenderer(options)

// Use the createApp function with a Vue component
const App = {
  template: '<div><p>Hello</p></div>',
}

// Mount the app to the root object
const root = { type: '#root', props: {}, children: [] }
createApp(App).mount(root)

The querySelector optional method is used by the DOM renderer for v-show and for template refs that use a selector string. A custom renderer that does not support selectors can omit it, and the runtime will not call it. The setScopeId method is used for scoped CSS in the DOM; a custom renderer without scoped CSS can omit it. The cloneNode method is used for v-once and static content caching; omitting it means those optimizations are not available. The insertStaticContent method is used for static hoisting, where the compiler emits a string of HTML that the renderer inserts directly. A custom renderer that cannot parse HTML can omit it.

The minimal renderer is not a toy. It is the foundation of the testing strategy for Vue itself, and it is the pattern that a design system’s renderer would use to produce a serializable tree for snapshot testing. The required methods are the contract; the optional methods are the performance features.


c. Building a terminal renderer: the @vui-rs/vue example

The @vui-rs/vue package is a production example of a custom renderer that targets a terminal cell buffer. It uses the same Vue component model and reactivity, but the host elements are boxes and text cells, not DOM nodes.

// The package's usage example, from its documentation
import { createApp, h, ref } from "@vui-rs/vue";

const App = {
  setup() {
    // A reactive counter
    const n = ref(0);
    // The render function returns a vui-rs element tree
    return () => h("box", { border: "rounded", padding: 1 },
      h("text", {}, `count: ${n.value}`)
    );
  },
};

// The createApp comes from the package's renderer
// It is the same createApp that createRenderer returns
createApp(App).mount();

The h function is Vue’s hyperscript, and the box and text elements are the host elements of the terminal platform. The renderer’s createElement returns a cell buffer structure, patchProp writes the border and padding to the buffer, and insert places the text cells in the correct position. The component’s reactivity drives the re-render, and the renderer’s patchProp updates the buffer when the count changes . The developer writes Vue components, and the renderer handles the terminal.

The vue3-pixi package follows the same pattern with PixiJS objects. The template uses <sprite>, <container>, and <graphics> instead of <div> and <span>. The renderer’s createElement creates a PixiJS object, patchProp sets the texture and position properties, and insert adds the object to the PixiJS scene graph . The events are wired through patchProp as well: a @click on a sprite becomes an event listener on the PixiJS object, and the component’s handler runs when the sprite is clicked .

The @i2d/pdf-frame-vue package uses the renderer to produce PDF documents. The template uses SVG-like tags, and the renderer’s methods write to the PDFKit document. The same reactivity and lifecycle hooks that drive a browser component drive the PDF generation, which means a component can be tested in the DOM and then rendered to a PDF without changing its logic .


Complete Example Session

The following session builds a minimal custom renderer for a plain object tree, mounts a component, and verifies the output structure.

// ============================================
// PART 1: THE RENDERER OPTIONS
// ============================================
import { createRenderer } from '@vue/runtime-core'

// A plain object tree node
// The renderer produces this instead of a DOM node
function createNode(type) {
  return { type, props: {}, children: [] }
}

const options = {
  // Create an element node
  createElement(type) {
    return createNode(type)
  },
  // Create a text node
  createText(text) {
    const node = createNode('#text')
    node.text = text
    return node
  },
  // Create a comment node
  createComment(text) {
    const node = createNode('#comment')
    node.text = text
    return node
  },
  // Set the text of a text node
  setText(node, text) {
    node.text = text
  },
  // Set the text of an element
  setElementText(node, text) {
    node.children = [{ type: '#text', text, props: {}, children: [] }]
  },
  // Insert a node into a parent
  insert(child, parent, anchor) {
    if (anchor) {
      const index = parent.children.indexOf(anchor)
      parent.children.splice(index, 0, child)
    } else {
      parent.children.push(child)
    }
  },
  // Remove a node (simplified)
  remove(child) {
    // The parent is not tracked; a real renderer would remove from the parent
  },
  // Patch a prop
  patchProp(el, key, prev, next) {
    el.props[key] = next
  },
  // Parent node (not tracked)
  parentNode(node) {
    return null
  },
  // Next sibling (not tracked)
  nextSibling(node) {
    return null
  },
}

// ============================================
// PART 2: THE RENDERER
// ============================================
const { createApp } = createRenderer(options)

// ============================================
// PART 3: THE COMPONENT
// ============================================
const App = {
  // The template uses Vue's template syntax
  template: `
    <div class="app">
      <h1>Hello</h1>
      <p>Custom renderer</p>
    </div>
  `,
}

// ============================================
// PART 4: THE MOUNT
// ============================================
// The root is a plain object
const root = { type: '#root', props: {}, children: [] }

// Mount the app to the root
createApp(App).mount(root)

// The root now contains the object tree
console.log(JSON.stringify(root, null, 2))
// Output:
// {
//   "type": "#root",
//   "props": {},
//   "children": [
//     {
//       "type": "div",
//       "props": { "class": "app" },
//       "children": [
//         { "type": "h1", "props": {}, "children": [{ "type": "#text", "text": "Hello" }] },
//         { "type": "p", "props": {}, "children": [{ "type": "#text", "text": "Custom renderer" }] }
//       ]
//     }
//   ]
// }

The four parts cover the options object with the required methods, the renderer creation, the component with a template, and the mount with the resulting object tree. What the session demonstrates is that the runtime does not know what a div is; it only knows that it called createElement('div') and received a host element, and that it called insert to place the children.


Quick Reference

Required RendererOptions Methods

MethodPurpose
createElementCreate a host element
createTextCreate a text node
createCommentCreate a comment node
setTextSet text on a text node
setElementTextSet text on an element
insertInsert a node into a parent
removeRemove a node
patchPropUpdate a prop
parentNodeGet the parent
nextSiblingGet the next sibling

Optional Methods

MethodPurpose
querySelectorSelector-based lookup (DOM)
setScopeIdScoped CSS (DOM)
cloneNodeStatic content caching
insertStaticContentStatic hoisting

Renderer Packages

PackageTarget
@vue/runtime-domDOM (reference)
@vue/runtime-testPlain object (testing)
vue3-pixiPixiJS scene graph
@vui-rs/vueTerminal cell buffer
@i2d/pdf-frame-vuePDF / canvas

Best Practices

✅ Do This:

// Implement all required methods
const options = {
  createElement: (type) => createNode(type),
  insert: (child, parent, anchor) => { /* ... */ },
  patchProp: (el, key, prev, next) => { el.props[key] = next },
  // ...
}

// Use the createApp from the returned renderer
const { createApp } = createRenderer(options)
createApp(App).mount(root)

❌ Don’t Do This:

// Use the DOM createApp in a non-DOM environment
import { createApp } from 'vue' // assumes document

// Omit a required method
const options = { createElement: ... } // runtime fails on mount

// Assume the renderer tracks the parent
// A minimal renderer must track it or use a DOM-like API

Common Pitfalls

PitfallWhy It HappensFix
document is not definedUsed vue instead of @vue/runtime-coreImport from @vue/runtime-core
Mount failsMissing required methodImplement all ten
Tree order wronginsert ignores anchorHandle the anchor in insert
Props not updatedpatchProp ignores nextStore the new value
Text not setsetText not implementedSet the text field

Real-World Examples

1. Create a renderer

import { createRenderer } from '@vue/runtime-core'
const { createApp } = createRenderer(options)

2. Create an element

createElement(type) { return { type, props: {}, children: [] } }

3. Create a text node

createText(text) { return { type: '#text', text, children: [] } }

4. Insert a node

insert(child, parent, anchor) { parent.children.splice(anchor ? parent.children.indexOf(anchor) : parent.children.length, 0, child) }

5. Patch a prop

patchProp(el, key, prev, next) { el.props[key] = next }

6. Mount

createApp(App).mount(root)

7. PixiJS element

createElement(type) { return new PIXI[capitalize(type)]() }

8. Terminal element

createElement(type) { return { type, style: {}, children: [] } }

9. Testing renderer

// @vue/runtime-test produces plain objects for snapshot testing

10. Re-export core

export * from '@vue/runtime-core'

Visual

The Renderer Abstraction

┌──────────────────────────────────────────────┐
│  Vue Component                               │
│  (template, reactivity, lifecycle)           │
│         │                                    │
│         ▼                                    │
│  Virtual DOM diff                            │
│         │                                    │
│         ▼                                    │
│  RendererOptions                             │
│  createElement, insert, patchProp, ...       │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│    ▼         ▼                               │
│  DOM      Terminal                          │
│  node     cell                              │
└──────────────────────────────────────────────┘

The RendererOptions Contract

┌──────────────────────────────────────────────┐
│  createElement(type) → HostElement           │
│  createText(text)    → HostNode              │
│  insert(el, parent, anchor)                  │
│  remove(el)                                  │
│  patchProp(el, key, prev, next)              │
│  setText(node, text)                         │
│  setElementText(node, text)                  │
│  parentNode(node) → HostElement              │
│  nextSibling(node)  → HostNode               │
└──────────────────────────────────────────────┘

Platform Implementations

┌──────────────────────────────────────────────┐
│  DOM: document.createElement, appendChild    │
│  PixiJS: new Sprite(), addChild              │
│  Terminal: cell buffer write                 │
│  Test: plain object                          │
└──────────────────────────────────────────────┘

Summary

ItemValue
FunctioncreateRenderer(options)
Package@vue/runtime-core
Returns{ render, createApp }
Required methods10
Optional methods4
DOM implementation@vue/runtime-dom
Test implementation@vue/runtime-test
Custom examplesvue3-pixi, @vui-rs/vue

Key takeaways:

  • The runtime is platform-agnostic. The DOM-specific code lives in @vue/runtime-dom, which is itself a createRenderer call. The component model, reactivity, and diffing are shared across all platforms .
  • createRenderer takes a RendererOptions object with ten required methods. The methods are the operations the runtime needs: create, insert, remove, patch, navigate. A platform that implements them can render Vue components .
  • The optional methods are optimizations. querySelector, setScopeId, cloneNode, and insertStaticContent are DOM-specific or performance-related. A minimal renderer can omit them .
  • The ecosystem uses the API in production. vue3-pixi renders to PixiJS, @vui-rs/vue renders to a terminal, and @i2d/pdf-frame-vue renders to PDF. Each is a different RendererOptions implementation .
  • A custom renderer is not a second-class citizen. The official DOM renderer uses the same API. The test renderer uses the same API. The abstraction is the mechanism, not a workaround .

Remember: createRenderer is the function that separates Vue’s component model from the browser. It takes the platform-specific operations—create an element, insert it, patch its props, remove it—and returns a createApp that uses them. The component code does not change; the renderer changes. A component that renders a <div> in the browser can render a <box> in a terminal or a <sprite> in a canvas, because the runtime only knows the operations the renderer provides. The abstraction is not a leaky one; it is the same abstraction the official DOM renderer uses, and the custom renderers in the ecosystem prove that it is complete enough to target any environment that can represent a tree of nodes.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!