@unhead/vue
Tested with @unhead/vue 3.4.2, unhead 3.4.2, Vue 3.5, Vite 8, and TypeScript 6.0.3 (vue-tsc 3.3.12) and 7.0.2.
Import from @unhead/vue and its subpaths, which re-export unhead.
In Nuxt, Nuxt creates and installs the head. Skip Setup and use the composables.
Setup
Create one head in the browser. On the server, create a new head for each request; a shared one leaks tags between requests.
// src/entry-client.ts
import { createHead } from '@unhead/vue/client'
import { createApp } from './main'
const { app } = createApp()
app.use(createHead())
app.mount('#app')// src/entry-server.ts
import { createHead, transformHtmlTemplate } from '@unhead/vue/server'
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(template: string) {
const { app } = createApp()
const head = createHead()
app.use(head)
const html = await renderToString(app)
return transformHtmlTemplate(head, template.replace('<!--app-html-->', html))
}- If the template has no
<head>,transformHtmlTemplatesilently drops every tag. - A client head on the server makes
transformHtmlTemplatethrowCannot read properties of undefined (reading 'htmlAttrs'). - The Options API
head()option needsapp.mixin(VueHeadMixin)in both entries. Without it, Vue ignoreshead().
Automatic behaviour
- The server head adds
<meta charset="utf-8">, the viewport meta, and<html lang="en">. Override them withuseHeadordisableDefaults: true. The client head adds none. - Tags dedupe on
title,name,property, canonicalrel, andkey. The last entry wins.htmlAttrsandbodyAttrsmerge. - Matching tags in
index.htmlare replaced, never duplicated. When the replacing entry goes away, they stay gone, except<title>, which reverts to the first document title. For site fallbacks, calluseHead(fallback, { tagPriority: 'low' })in the root component. - The client writes the DOM one macrotask after a change, so
document.titleis still old afterawait nextTick(). To read the result, use theonRenderedentry option. - On the server, refs and getters resolve at the first
transformHtmlTemplateorhead.render()call. The result is then cached. Unhead()from@unhead/vue/viteis optional. In dev it addsValidatePlugin, which logs[unhead]warnings. In dev and build it rewrites staticuseSeoMetainput touseHead. Optionsvalidate,transformSeoMeta, andtreeshaketakefalse.
Common tasks
Reactive and async values
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted, ref } from 'vue'
const product = ref<{ name: string, summary: string }>()
useHead({
title: () => product.value?.name ?? 'Loading',
meta: [{ name: 'description', content: () => product.value?.summary ?? 'Loading product details.' }],
})
onMounted(() => {
fetch('/api/product').then(r => r.json()).then((data) => {
product.value = data
})
})
</script>- Pass the ref or a getter.
- Call
useHeadsynchronously in setup. After anawaitin a callback, or in an asyncsetup()outside<script setup>, it throwsuseHead() was called without provide context. Update a ref instead, or callpatch()on an entry made in setup. Top-levelawaitin<script setup>keeps the context.
Titles
<script setup lang="ts">
import { useHead } from '@unhead/vue'
useHead({ title: 'Pricing', titleTemplate: title => (title ? `${title} | Acme` : 'Acme') })
</script>- A string template
'%s | Acme'needs no plugin. If no entry sets a title, it renders| Acme. titleTemplate: computed(title => ...)gets the previous computed value and rendersundefined | Acme, with no type error.titleTemplate: nullon a page removes an inherited template.%separator,%siteName, and othertemplateParamsneedTemplateParamsPlugin. Without it they print as text. Register it in the client entry and in each servercreateHead:
import { createHead } from '@unhead/vue/client'
import { TemplateParamsPlugin } from '@unhead/vue/plugins'
export const head = createHead({ plugins: [TemplateParamsPlugin] })SEO meta
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'
useSeoMeta({
title: 'Pricing',
description: 'Compare plans.',
ogTitle: 'Pricing',
ogImage: [{ url: 'https://example.com/og.png', width: 1200, height: 630, alt: 'Acme' }],
robots: { noindex: true, nofollow: true },
})
</script>robots: { index: false, follow: false }renders no robots tag. Usenoindexandnofollow, or a string.content: nullremoves an inherited meta tag.useHeaddoes not sanitize. For values from users or APIs, useuseHeadSafe. It keeps only JSON scripts. It dropson*,http-equiv,javascript:anddata:URLs, and canonical, preload, prefetch, preconnect, and manifest links; set a canonical withuseHead.
Third-party scripts
<script setup lang="ts">
import { useScript } from '@unhead/vue'
interface Analytics { track: (event: string) => void }
const analytics = useScript<Analytics>('https://example.com/analytics.js', {
use: () => (window as unknown as { analytics: Analytics }).analytics,
})
analytics.proxy.track('pageview')
analytics.onLoaded(api => api.track('loaded'))
</script>- In a component, the script loads on mount with
deferandfetchpriority="low". An absolute URL also getscrossorigin="anonymous"andreferrerpolicy="no-referrer". SSR renders nothing for it unless you passtrigger: 'server'. - If the script host sends no CORS headers, the browser blocks the load. Pass
useScript({ src, crossorigin: false }). trigger: 'manual'waits forload(). ARef<boolean>or getter trigger loads when it becomes true.- Unmount does not remove the script.
proxyexists only withuseorresolve.
Streaming SSR
On the server, createStreamableHead() from @unhead/vue/stream/server returns { head, wrapStream }; return wrapStream(renderToWebStream(app), template). On the client, use createStreamableHead() from @unhead/vue/stream/client, and add Unhead({ streaming: true }) to the Vite config.
- Without the stream bootstrap, the client call returns
undefined. Throw then; otherwise eachuseHeadthrows the provide-context error. - A
useHeadcall reaches the shell<head>only if noawaitran before it, in its component or any ancestor. Later calls become inline patch scripts, which most bots ignore. Set canonical, robots, description, and Open Graph in the root or layout. wrapStreamkeeps the<title>,<meta>, and charset tags of the template, so the shell has two of each. Remove them fromindex.html.- On the client, streamed patch tags stay after their component unmounts. Set title and description on every page.
Traps
- v2 names are not converted:
hid,vmid,children, andbody: truerender as literal attributes, such as<meta hid="description">or an empty<script children="...">. The table below has the new names. - Promise values are not awaited. A promise title is dropped; a promise
contentrenders[object Promise].PromisesPluginresolves them on the client, but on the server it drops every entry that holds a promise. Await the data first. tagPriority: 'before:...'and'after:...'needAliasSortingPlugin, or Unhead ignores them.useServerHead,useServerHeadSafe, anduseServerSeoMetaare deprecated aliases that also run in the browser. WithUnhead(), only a production client build removes them. For server-only tags, useif (import.meta.env.SSR). The upgrade guide'simport.meta.serverexists only in Nuxt.- Type errors name the wrong property.
Type '"description"' is not assignable to type 'ResolvableValue<undefined>'meanscontentis missing or a v2 key is present. A font preload withoutcrossoriginfails onreloras, not oncrossorigin. Wrap a non-standardrelor scripttypeindefineLink()ordefineScript(). - The install guide says
@unhead/vue@next, which is 3.0.0-beta.9. Install@unhead/vue.
Version limits
In 3.4.2, a templateParams value of 0 renders empty. A numeric templateParams value used in a JSON script with processTemplateParams: true throws in a hook: the HTML comes back unprocessed, then Node exits on an unhandled rejection that try/catch misses. Pass strings. unhead#1002 fixes both after 3.4.2.
Upgrading from v1, v2, or @vueuse/head
| Old | 3.x |
|---|---|
createHead from @unhead/vue or @vueuse/head |
createHead from @unhead/vue/client or @unhead/vue/server |
createServerHead() |
createHead() from @unhead/vue/server |
Head from @vueuse/head |
Head from @unhead/vue/components |
hid, vmid / children / body: true |
key / innerHTML or textContent / tagPosition: 'bodyClose' |
await renderSSRHead(head) |
head.render(), synchronous |
useHead(input, { mode: 'server' }) |
the server entry, or import.meta.env.SSR |
disableCapoSorting: true |
tagWeight(tag) on the server createHead; lower sorts first |
import unhead from '@unhead/vue/vite' |
import { Unhead } from '@unhead/vue/vite' |
script.then(), script.track() |
script.onLoaded(), script.proxy.track() |
Debug
Without the Vite plugin, add ValidatePlugin() from @unhead/vue/plugins when import.meta.env.DEV is true; it also logs in production. It flags a missing TemplateParamsPlugin only if templateParams is set.