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 (229d3)
useHead()type narrowing - by @harlan-zw in #627 (e8086)- Sync
renderDOMHead()- by @harlan-zw in #628 (627eb) - Sync
renderSSRHead()- by @harlan-zw in #629 (44d71) - Pluggable
render()function - by @harlan-zw in #630 (baaa7) - bundler:
- Named Unhead export, ctx-based transforms, dev-mode validate - by @harlan-zw in #733 (3a607)
- types:
- Enforce strict type narrowing for Link and Script unions - by @harlan-zw in #729 (f846a)
- unhead:
- Sync resolve tag engine - by @harlan-zw in #619 (436a0)
- Composable
resolveTags()- by @harlan-zw in #622 (b6a52)
🚀 Features
- Streaming support - by @harlan-zw in #537 (bcfc4)
- ValidatePlugin for head tag validation - by @harlan-zw in #690 (3fa0c)
- Access resolved head with
onRendered- by @harlan-zw and Claude Opus 4.6 (1M context) in #712 (55166) - Add
MinifyPluginfor inline script/style minification - by @harlan-zw and Claude Opus 4.6 (1M context) in #705 (f1f5d) - V2 migration rules in ValidatePlugin + DOM hook deprecations - by @harlan-zw and Claude Opus 4.6 (1M context) in #722 (f5edf)
@unhead/bundlerand/vitesubpath exports - by @harlan-zw in #726 (df9c8)- canonical:
- Query param filtering, trailing slash & hash stripping - by @harlan-zw and Claude Opus 4.6 (1M context) in #713 (8369d)
- devtools:
- Vite devtools integration - by @harlan-zw in #731 (e29d4)
- react:
- Add @unhead/react/helmet compat export - by @harlan-zw and Claude Opus 4.6 (1M context) in #719 (417da)
- unhead:
- Add
tagWeightoption andmeta-beyond-1mbvalidation rule - by @harlan-zw and Claude Opus 4.6 (1M context) in #716 (c58be)
- Add
- validate:
- Add performance validation rules - by @harlan-zw in #691 (0abb6)
- Add 7 performance/web vitals validation rules - by @harlan-zw in #725 (093a2)
- vue,streaming:
- Lower level stream APIs - by @harlan-zw in #674 (c67fb)
🐞 Bug Fixes
- Switch to
HookableCore- by @harlan-zw in #631 (cc794) - Pure unhead core - by @harlan-zw in #632 (26937)
- Hydration race condition with deferred patches - by @harlan-zw in #634 (3a36e)
- Sync
transformHtmlTemplate- by @harlan-zw (f696d) - Stricter type narrowing for head schema - by @harlan-zw in #665 (0c857)
- Type render() return across all framework integrations - by @harlan-zw in #672 (a7e76)
- Cherry-pick fixes from main - by @harlan-zw (0a5f9)
- Add
fediverse:creatormeta tag support - by @harlan-zw in #703 (545c9) - Avoid mutating cached titleTemplate tag in resolveTitleTemplate - by @harlan-zw and Claude Opus 4.6 (1M context) in #715 (977db)
- addons:
- Respect user-provided twitter:card in InferSeoMetaPlugin - by @harlan-zw in #681 (77ace)
- client:
- Process pending patches even when dirty is false - by @harlan-zw and Claude Opus 4.6 (1M context) in #530 and #636 (37db2)
- react:
- Ssr regression - by @harlan-zw (6adff)
- schema-org:
- Avoid crashing from 3+ duplicate nodes - by @harlan-zw (e68e6)
- Allow null to opt out of default values - by @harlan-zw and harlan-zw in #680 (01a7a)
- Clear graph nodes on each normalization cycle - by @harlan-zw in #701 (f665e)
- Dedupe typed arrays by @type during node merge - by @harlan-zw in #702 (b2caf)
- Normalize
targetto array before merging potentialAction - by @harlan-zw and Claude Opus 4.6 (1M context) in #709 (125ef) - Add missing Vue exports for schemaAutoImports entries - by @harlan-zw in #728 (caf8c)
- types:
- Enforce required as attribute for preload links - by @harlan-zw in #683 (beeb5)
- Make templateParams extensible via module augmentation - by @harlan-zw and harlan-zw in #679 (b0627)
- unhead:
- Avoid explicit vite dependency - by @harlan-zw (646a3)
- Deduplicate matching tags inside same render cycle - by @harlan-zw (1c258)
- Broken
book:release_datemeta - by @harlan-zw (e413c)
- validate:
- False positives for warmup preloads and charset position - by @harlan-zw in #732 (9c839)
- vue:
- Expose
@unhead/vue/stream/iifewith correct types - by @harlan-zw in #707 (a8f1a) - Restore Style component inside Head and children prop - by @harlan-zw and Claude Opus 4.6 (1M context) in #686 (f88c8)
- Expose
🏎 Performance
- Migrate unplugins to oxc-walker - by @harlan-zw in #663 (35610)
- client: Reduce bundle size - by @harlan-zw in #635 (4c4a7)
- unhead: Client only capo sorting - by @harlan-zw in #626 (4bc60)