github unjs/unhead v3.0.0

6 hours ago

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, useSeoMetauseHead 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).

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

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-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 '@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

  • init hook removed
  • dom:renderTag, dom:rendered hooks deprecated (will be removed in v4)
  • dom:beforeRender is 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 / 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

   🏎 Performance

    View changes on GitHub

Don't miss a new unhead release

NewReleases is sending notifications on new releases.