Release: Unhead v3
Streaming SSR drove the Unhead v3 rendering rewrite. Frameworks such as Nuxt, SolidStart, and SvelteKit can stream HTML as data resolves, while earlier Unhead versions resolved head tags once per response. The v3 renderer is synchronous and supports head updates during a stream.
๐ฃ Highlights
๐ Streaming SSR
Head tags can update as suspense boundaries resolve. When a boundary registers another entry, Unhead serializes it to a client-side queue instead of waiting for the full response.
// 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()
if (!head)
throw new Error('Unhead streaming bootstrap state is missing')
app.use(head)
Before the main JavaScript bundle loads, a queue stub (window.__unhead__) collects incoming head entries. The client head instance drains that queue during initialization, then handles subsequent updates.
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, a streaming plugin, and framework glue. By default, it enables tree-shaking, the useSeoMeta โ useHead transform, development validation, and Vite DevTools integration. The panel appears when Vite DevTools is enabled. Inline minification and streaming SSR are available through options.
import { Unhead } from 'unhead/vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue(), Unhead({ streaming: true })],
})
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(unheadInstance, {
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
The optional ValidatePlugin inspects resolved head output. Its rules cover missing titles, duplicate resource hints, conflicting preload priorities, render-blocking scripts, late <meta charset> tags, excessive fetchpriority="high" hints, and preconnects without crossorigin. Migration rules detect deprecated properties (children, hid/vmid, and body: true) and missing TemplateParamsPlugin or AliasSortingPlugin registrations. The unified Vite plugin registers validation during development, and 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
The new built-in CanonicalPlugin resolves relative URLs in existing canonical links and supported URL meta tags, including og:image, twitter:image, and og:url. For canonical links and og:url, it can filter query parameters, normalize trailing slashes, and strip hash fragments. It does not create a canonical link when one is missing.
import { CanonicalPlugin } from 'unhead/plugins'
createHead({
plugins: [
CanonicalPlugin({
canonicalHost: 'https://mysite.com',
trailingSlash: true,
queryWhitelist: ['page', 'sort'],
})
]
})
๐๏ธ MinifyPlugin
The optional MinifyPlugin minifies inline <script> and <style> content during SSR using JavaScript-only minifiers with no native dependencies. MinifyTransform in @unhead/bundler handles static innerHTML literals at build time. The underlying minifyJS, minifyCSS, and minifyJSON utilities are exported from unhead/minify.
import { MinifyPlugin } from 'unhead/plugins'
createHead({
plugins: [MinifyPlugin()]
})
See PR #705.
๐ฆ Rendering and Bundle Optimizations
The release preparation recorded smaller bundles and faster render benchmarks, but the original comparison environment and baselines were not retained. The implementation changes are preserved in the linked pull requests:
- Full CAPO weighting moved to SSR, while the client uses lightweight priority aliases (#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/helmetcompatibility 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 the lighterHookableCore; rendering hooks now run in the synchronous render pipeline (#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/dynamic-import/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
For the full migration guide, see Migrate to v3.
Summary
| Old | New |
|---|---|
children | innerHTML |
hid / vmid | key |
body: true | tagPosition: 'bodyClose' |
useServerHead / useServerSeoMeta | useHead / useSeoMeta |
createHeadCore | createHead from the client or server entry point |
@unhead/dynamic-import/legacy | @unhead/dynamic-import/client or @unhead/dynamic-import/server |
mode option on entries | Use client/server createHead imports |
renderDOMHead()andrenderSSRHead()are now synchronous (removeawait)- CJS removed, all packages are ESM-only
TemplateParamsPluginandAliasSortingPluginare no longer included by default- The
inithook was removed;dom:renderTagis deprecated and no longer called;dom:renderedis deprecated but still emitted;dom:beforeRenderis now synchronous @unhead/addonsrenamed to@unhead/bundler; framework Vite plugins now use a namedUnheadexport instead of a default exportLink/Scriptunions are strict: use the newdefineLink/defineScripthelpers for customrel/typevalues, and metacontentis now required (usecontent: nullto remove)