skip to content

When a Vue 3 template is written directly in a page's HTML, which browser parsing caveats apply and how do you work around each?

level: middleimportance: should knowfreq 32%

answer

  1. the browser parses before Vue
  2. HTML lowercases names
  3. slash ignored on non-void elements
  4. table children get hoisted out
  5. is with a vue: prefix

basics

~20 s

Vue compiles an in-DOM template only after the browser has parsed it, so names are lowercased, self-closing custom tags stay open and invalid children are hoisted. Use kebab-case, explicit closing tags and is="vue:component-name" on a valid native element.

solid answer

~40 s

An in-DOM template is parsed by the browser's HTML parser before Vue reads its `innerHTML`, so three spec behaviours leak in. **Case insensitivity**: tags and attributes are lowercased, so PascalCase components, camelCase props and camelCase `v-on` events must be written in kebab-case (`post-title`, `@update-post`). **Self-closing tags**: `/>` is ignored on non-void elements, so `<my-comp />` swallows its siblings; always write `<my-comp></my-comp>`. **Placement restrictions**: elements like `<table>` and `<select>` only accept certain children, and the parser moves an unknown tag out of a table, so use a valid native element with `is="vue:blog-post-row"`; the `vue:` prefix distinguishes it from a native customized built-in element. None of this applies to SFCs, inline string templates or `text/x-template` scripts.

code

html · 9 lines
html
<div id="app">
  <!-- kebab-case: the browser would lowercase postTitle to posttitle -->
  <blog-post post-title="Hello" @update-post="onUpdate"></blog-post>

  <table>
    <!-- a bare <blog-post-row> would be hoisted out of the table -->
    <tr is="vue:blog-post-row" v-for="row in rows" :key="row.id" :row="row"></tr>
  </table>
</div>

go deeper

for a junior

Recall the three caveats by name and the matching fix for each: kebab-case, explicit closing tags, and is with the vue: prefix.

for a middle

Explain that the browser parses and normalizes the markup before Vue reads it back, and which template sources avoid that step entirely.

for a senior

Recognize the symptoms in a no-build page, such as siblings turning into slot content or rows escaping a table, and decide whether to move templates out of the DOM.

for a principal

Judge whether in-DOM templates belong in a codebase at all, weighing their parsing traps and trust requirements against the cost of a build step.

## What an in-DOM template is An **in-DOM template** is Vue template syntax written directly in the page's HTML: inside the element you mount on, or inside an element referenced by a `template: '#id'` selector. It only works with a build that contains the runtime compiler (`vue.global.js`, `vue.esm-browser.js` or `vue.esm-bundler.js`). The catch is the order of events. The **browser's HTML parser runs first**. It builds a DOM tree from the markup according to the HTML specification, and only afterwards does Vue read that tree back as a string (`innerHTML`) and compile it. Vue therefore compiles *what the browser made of your markup*, not what you typed. Three browser behaviours cause almost every surprise. ## 1. Case insensitivity HTML tag and attribute names are case-insensitive, so the parser lowercases them. That breaks every camelCase or PascalCase name: - a component written as `<BlogPost>` arrives as `<blogpost>`; - a prop bound as `postTitle="x"` arrives as `posttitle`, which no longer matches the declared `postTitle` prop and falls through as a plain attribute; - a listener written as `@updatePost` arrives as `@updatepost`. The fix is to use the **kebab-case** equivalent everywhere in the in-DOM template: `<blog-post post-title="hello!" @update-post="onUpdatePost"></blog-post>`. Vue maps kebab-case attributes back to camelCase prop and event names, so the JavaScript side keeps `postTitle` and `updatePost`. ## 2. Self-closing tags Vue's own template parser treats `/>` as the end of *any* tag, which is why SFCs can write `<MyComponent />`. The HTML parser does not: only **void elements** such as `<input>` and `<img>` may omit a closing tag, and on any other element the slash is ignored. So ```html <my-component /> <span>hello</span> ``` is parsed as `<my-component><span>hello</span></my-component>`: the sibling silently becomes slot content. In-DOM templates must always use explicit closing tags: `<my-component></my-component>`. ## 3. Element placement restrictions Some elements only allow certain children: `<ul>`, `<ol>`, `<table>` and `<select>` restrict what may appear inside them, and `<li>`, `<tr>` and `<option>` may only appear inside specific parents. A custom tag in the wrong place can be treated as invalid content; inside a table the parser **hoists it out**, so ```html <table> <blog-post-row></blog-post-row> </table> ``` ends up with the component outside the table. The workaround is the special `is` attribute on a valid native element, with the **`vue:` prefix**: ```html <table> <tr is="vue:blog-post-row"></tr> </table> ``` The prefix is required on native elements (supported since Vue 3.1). Without it, `is` keeps its native meaning, a **customized built-in element** from the Web Components specification, and Vue leaves the element alone. On Vue's own `<component>` element, `is` needs no prefix. ## Where the caveats do not apply | Template source | Parsed by the browser first? | Caveats apply? | |---|---|---| | In-DOM markup in the mount container | yes | yes | | `template: '#id'` pointing at a normal element | yes | yes | | `<script type="text/x-template">` | no (script content is raw text) | no | | Inline string, `template: '...'` | no | no | | Single-File Component `<template>` | no (compiled by the build) | no | ## Diagnosing a broken in-DOM template When a no-build page misbehaves, inspect the **live DOM** in the browser's element panel rather than the page source, because the live DOM is what Vue compiled: 1. A prop that never arrives, or shows up as a plain attribute on the root element, usually has a lowercased camelCase name. 2. Content that appears inside a component it was never meant to be in usually follows a self-closed custom tag. 3. A component rendered above or outside its table was hoisted out by the parser. 4. An element rendered as a plain native tag despite an `is` attribute is missing the `vue:` prefix. Each symptom is visible in the parsed DOM before Vue does anything, which makes the parser, not Vue, the thing to fix. ## Related no-build concerns - **Flash of uncompiled template**: until the component mounts, users may see raw `{{ }}` mustaches. The `v-cloak` directive stays on the element until mount; pairing it with `[v-cloak] { display: none }` hides the raw markup. - **Trust**: an in-DOM template is compiled and its expressions run, so the markup must be trusted. The Vue security guide says never to mount Vue on nodes that may contain server-rendered, user-provided content, because text that is harmless as HTML can be dangerous as a template. - **Build choice**: every one of these caveats disappears once templates move into SFCs or string templates, which is one more reason bundled apps rarely use in-DOM templates.

  • Why does Vue 3 require the vue: prefix on is when used on a native element?
    On a native element, `is` already has a platform meaning: it creates a customized built-in element from the Web Components specification. Vue therefore treats `is` on native elements as native by default and only swaps in a Vue component when the value starts with `vue:`. On Vue's own `<component>` element, `is` takes the component directly.
  • How do you stop users from seeing raw mustaches before an in-DOM template mounts?
    Add `v-cloak` to the element and a CSS rule `[v-cloak] { display: none }`. Vue removes the attribute once the component instance has mounted, so the raw markup stays hidden until the rendered output replaces it. The directive is only needed in no-build setups, since precompiled templates never show raw syntax.
  • Would moving the markup into a <script type="text/x-template"> remove these caveats?
    Yes. Script content is raw text that the HTML parser does not turn into elements, so Vue receives the template exactly as written: camelCase, self-closing tags and table children all survive. It still requires the runtime compiler, and it has the same trust requirement as any in-DOM template.

saying these in an interview costs you the question

  • Believes Vue sees the original casing because it reads the raw page source
  • Thinks <my-comp /> works in HTML because it works in an SFC
  • Uses is="blog-post-row" on a <tr> without the vue: prefix
  • Assumes these caveats also apply inside .vue single-file components
  • Fixes a hoisted table row by wrapping the component in a div