Migration Guide

Migrate to v3

Last updated by Harlan Wilton in doc: clean up.

Unhead v3 changes several core defaults and removes v2 compatibility behavior. This guide covers the breaking changes and the deprecated compatibility APIs that remain temporarily available.

Automated Migration Checks

Add ValidatePlugin during the upgrade to report v2 patterns:

import { ValidatePlugin } from 'unhead/plugins'

const head = createHead({
  plugins: [
    ValidatePlugin() // Detects deprecated props, missing plugins, and more
  ]
})

The plugin will warn you about:

  • Missing TemplateParamsPlugin: template params like %siteName are now opt-in and will appear literally without the plugin
  • Missing AliasSortingPlugin: before:/after: tag priorities are now opt-in and will be silently ignored without the plugin
  • Deprecated property names: children, hid, vmid, body: true are no longer auto-converted
  • Removed mode option: { mode: 'server' } on head.push() is silently ignored

All rules use ESLint-style config and can be individually disabled:

ValidatePlugin({
  rules: {
    'missing-template-params-plugin': 'off',
  }
})

The unified Vite plugin injects ValidatePlugin in development unless validate is disabled.

Remove ValidatePlugin once your migration is complete, or keep it for ongoing validation.


@unhead/addons โ†’ @unhead/bundler

๐Ÿšฆ Impact Level: High (only if you import build plugins manually)

The @unhead/addons package has been renamed to @unhead/bundler. The old package still works as a deprecation shim that re-exports from @unhead/bundler, but logs a runtime warning.

- pnpm add -D @unhead/addons
+ pnpm add -D @unhead/bundler

The default export has also been replaced with a named Unhead export:

- import unhead from '@unhead/addons/vite'
+ import { Unhead } from '@unhead/bundler/vite'

export default defineConfig({
- plugins: [unhead()],
+ plugins: [Unhead()],
})
Most users should import from their framework's vite subpath instead, which forwards to @unhead/bundler and wires up framework-specific runtime plugins:
import { Unhead } from '@unhead/vue/vite'
// or @unhead/react/vite, @unhead/svelte/vite, @unhead/solid-js/vite

webpack consumers should use the framework bundler entry:

- import unhead from '@unhead/addons/webpack'
+ import { Unhead } from '@unhead/vue/bundler'

- plugins: [unhead()]
+ plugins: Unhead().webpack()

The minify backend subpaths have moved too:

- import { createJSMinifier } from '@unhead/addons/minify/rolldown'
- import { createCSSMinifier } from '@unhead/addons/minify/lightningcss'
+ import { createJSMinifier } from '@unhead/bundler/minify/rolldown'
+ import { createCSSMinifier } from '@unhead/bundler/minify/lightningcss'

See the Build Plugins overview for the new options table.


Framework Vite Plugins: Named Unhead Export

๐Ÿšฆ Impact Level: High

Every framework Vite plugin now exports a named Unhead symbol instead of a default export. Update your vite.config.ts:

// Vue
- import unhead from '@unhead/vue/vite'
+ import { Unhead } from '@unhead/vue/vite'

// React
- import unhead from '@unhead/react/vite'
+ import { Unhead } from '@unhead/react/vite'

// Svelte
- import unhead from '@unhead/svelte/vite'
+ import { Unhead } from '@unhead/svelte/vite'

// Solid
- import unhead from '@unhead/solid-js/vite'
+ import { Unhead } from '@unhead/solid-js/vite'

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

The plugin call remains the same after the import change. Nuxt users do not configure this plugin directly.


Legacy Property Names

๐Ÿšฆ Impact Level: High

The current createHead() entry points omit DeprecationsPlugin. Rename these properties before upgrading; the deprecated plugin is still available for staged migrations.

children โ†’ innerHTML

useHead({
  script: [{
-   children: 'console.log("hello")',
+   innerHTML: 'console.log("hello")',
  }]
})

hid / vmid โ†’ key

useHead({
  meta: [{
-   hid: 'description',
+   key: 'description',
    name: 'description',
    content: 'My description'
  }]
})
useHead({
  meta: [{
-   vmid: 'og:title',
+   key: 'og:title',
    property: 'og:title',
    content: 'My Title'
  }]
})

body: true โ†’ tagPosition: 'bodyClose'

useHead({
  script: [{
    src: '/script.js',
-   body: true,
+   tagPosition: 'bodyClose',
  }]
})

Quick Reference

Old PropertyNew Property
childreninnerHTML
hidkey
vmidkey
body: truetagPosition: 'bodyClose'

Schema.org Plugin

๐Ÿšฆ Impact Level: High

The PluginSchemaOrg and SchemaOrgUnheadPlugin exports have been removed. Use UnheadSchemaOrg instead.

- import { PluginSchemaOrg } from '@unhead/schema-org'
+ import { UnheadSchemaOrg } from '@unhead/schema-org'

const head = createHead({
  plugins: [
-   PluginSchemaOrg()
+   UnheadSchemaOrg()
  ]
})

For Vue users:

- import { PluginSchemaOrg } from '@unhead/schema-org/vue'
+ import { UnheadSchemaOrg } from '@unhead/schema-org/vue'

Schema.org Config Options

The following config options have been removed:

Removed OptionReplacement
canonicalHosthost
canonicalUrlpath + host
positiontagPosition
defaultLanguageinLanguage
defaultCurrencycurrency
UnheadSchemaOrg({
- canonicalHost: 'https://example.com',
- canonicalUrl: 'https://example.com/page',
+ host: 'https://example.com',
+ path: '/page',
})

Server Composables Removed

๐Ÿšฆ Impact Level: Medium-High

The useServerHead, useServerHeadSafe, and useServerSeoMeta composables have been removed. Use the standard composables instead.

- import { useServerHead, useServerSeoMeta } from 'unhead'
+ import { useHead, useSeoMeta } from 'unhead'

- useServerHead({ title: 'My Page' })
+ useHead({ title: 'My Page' })

- useServerSeoMeta({ description: 'My description' })
+ useSeoMeta({ description: 'My description' })

If you need server-only head management, use conditional logic:

if (import.meta.server) {
  useHead({ title: 'Server Only' })
}

Core API Changes

๐Ÿšฆ Impact Level: Medium

createHeadCore โ†’ Platform createHead

- import { createHeadCore } from 'unhead'
+ import { createHead } from 'unhead/client'

- const head = createHeadCore()
+ const head = createHead()

For SSR, import createHead from unhead/server. The low-level createUnhead() API now requires a renderer and is intended for adapter authors.

headEntries() โ†’ entries Map

- const entries = head.headEntries()
+ const entries = [...head.entries.values()]

mode Option Removed

The mode option on head entries has been removed. Runtime mode detection is no longer supported.

head.push({
  title: 'My Page',
- }, { mode: 'server' })
+ })

Use the appropriate createHead function instead:

// Client-side
import { createHead } from 'unhead/client'

// Server-side
import { createHead } from 'unhead/server'

Vue Legacy Exports

๐Ÿšฆ Impact Level: Medium

/legacy Export Path Deprecated

The @unhead/vue/legacy import still works but is deprecated and scheduled for removal in v4. Update to the explicit client or server import:

- import { createHead } from '@unhead/vue/legacy'
+ import { createHead } from '@unhead/vue/client'
// or for SSR
+ import { createHead } from '@unhead/vue/server'

createHeadCore Removed

- import { createHeadCore } from '@unhead/vue'
+ import { createHead } from '@unhead/vue/server'
// or for client
+ import { createHead } from '@unhead/vue/client'

Server Utilities

๐Ÿšฆ Impact Level: Low

extractUnheadInputFromHtml โ†’ parseHtmlForUnheadExtraction

The function has been moved from unhead/server to unhead/parser.

- import { extractUnheadInputFromHtml } from 'unhead/server'
+ import { parseHtmlForUnheadExtraction } from 'unhead/parser'

- const { input } = extractUnheadInputFromHtml(html)
+ const { input } = parseHtmlForUnheadExtraction(html)

Hooks

๐Ÿšฆ Impact Level: Low

The init hook was removed. dom:renderTag remains in the type definitions for compatibility but is deprecated and no longer called internally. dom:rendered is also deprecated but is still emitted; prefer the onRendered entry option for entry-specific work.

The dom:beforeRender hook is now synchronous and renderDOMHead no longer returns a Promise:

- await renderDOMHead(head, { document })
+ renderDOMHead(head, { document })

The SSR hooks (ssr:beforeRender, ssr:render, ssr:rendered) are now synchronous and renderSSRHead no longer returns a Promise:

- const head = await renderSSRHead(head)
+ const head = renderSSRHead(head)

Type Changes

๐Ÿšฆ Impact Level: Low

Removed TypeReplacement
HeadSerializableHead
ResolvedHeadSerializableHead
MergeHeadUse generics directly
MetaFlatInputMetaFlat
ResolvedMetaFlatMetaFlat
RuntimeModeRemoved (no replacement needed)
- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead/types'
+ import type { MetaFlat, SerializableHead } from 'unhead/types'

๐Ÿšฆ Impact Level: Medium

The Link and Script types are now strict discriminated unions. Known rel and type values enforce per-tag required properties at the type level. Use the new defineLink and defineScript helpers to declare custom values without losing strictness on known ones.

Known rel values now enforce their required properties. For example, preloading a font requires crossorigin:

useHead({
  link: [{
    rel: 'preload',
    as: 'font',
    href: '/font.woff2',
+   crossorigin: 'anonymous', // now required for font preloads
  }]
})

For non-standard rel values not covered by KnownLinkRel (e.g., OpenID endpoints, RSD links), use defineLink:

import { defineLink, useHead } from 'unhead'

useHead({
  link: [
    defineLink({ rel: 'openid2.provider', href: 'https://example.com/openid' }),
  ]
})

Script Tags

Inline scripts must have textContent or innerHTML and cannot include src, async, or defer. For custom type values, use defineScript:

import { defineScript, useHead } from 'unhead'

useHead({
  script: [
    defineScript({ type: 'text/plain', textContent: '...' }),
  ]
})

Meta Content Required

Meta content is now required on name, property, and http-equiv meta tags. Use null explicitly to remove a meta tag:

- useHead({ meta: [{ name: 'description' }] }) // no longer valid
+ useHead({ meta: [{ name: 'description', content: null }] }) // removes the tag

String Variables

When rel or type comes from a variable typed as string, TypeScript cannot narrow the union. Wrap it with defineLink / defineScript or use as const for literals:

import { defineLink, useHead } from 'unhead'

const rel = getRelFromConfig() // string, not a literal
useHead({
  link: [defineLink({ rel, href: '/path' })]
})

// or use as const for literals
const link = { rel: 'canonical' as const, href: '/path' }
useHead({ link: [link] })

Other API Changes

  • resolveScriptKey : Internal utility, no longer exported
  • setHeadInjectionHandler (Vue) : Head injection is handled automatically
  • DeprecationsPlugin and Vue's resolveUnrefHeadInput remain available as compatibility helpers. Avoid them in new code.

Quick Reference: Import Changes

// Build plugins
- import unhead from '@unhead/addons/vite'
+ import { Unhead } from '@unhead/bundler/vite'
// or, recommended, from your framework subpath:
+ import { Unhead } from '@unhead/vue/vite'

// Legacy properties - update property names directly; do not depend on the compatibility plugin

// Schema.org
- import { PluginSchemaOrg, SchemaOrgUnheadPlugin } from '@unhead/schema-org'
+ import { UnheadSchemaOrg } from '@unhead/schema-org'

// Server composables
- import { useServerHead, useServerHeadSafe, useServerSeoMeta } from 'unhead'
+ import { useHead, useHeadSafe, useSeoMeta } from 'unhead'

// Core
- import { createHeadCore } from 'unhead'
+ import { createHead } from 'unhead/client'
+ import { createHead } from 'unhead/server'

// Server utilities
- import { extractUnheadInputFromHtml } from 'unhead/server'
+ import { parseHtmlForUnheadExtraction } from 'unhead/parser'

// Vue
- import { createHeadCore, setHeadInjectionHandler } from '@unhead/vue'
- import { ... } from '@unhead/vue/legacy'
+ import { createHead } from '@unhead/vue/client'
+ import { createHead } from '@unhead/vue/server'
Did this page help you?