Vue Reactivity and Async Context
Pass refs and computed values directly to useHead(); Vue tracks them for you. Capture injectHead() before asynchronous work when a later call may run outside component context.
Vue Integration
useHead() accepts refs, computed refs, and getter functions. The adapter resolves them in a watchEffect() and patches the same entry when a dependency changes.
Unhead uses Vue's provide/inject system to retrieve the instance registered on the application.
Component Lifecycle
For VDOM components, the adapter follows Vue's component lifecycle:
- When a component is unmounted, any head entries created by that component are automatically removed
- When a component is deactivated with keep-alive, its entries will be deactivated
- When a component is activated with keep-alive, its entries will be reactivated
Vapor components use effect scopes for cleanup and have different KeepAlive limits. See the Vapor Mode guide before converting components that own head entries.
Client and Server Reactivity
Reactivity behaves differently depending on the rendering context:
- Server-Side Rendering (SSR): Values are resolved only when the tags are being rendered, usually after the app has finished rendering.
- Client-Side Rendering (CSR): Any ref changes trigger a DOM update, making the head tags reactive after hydration.
Reactive Values
Unhead works with all Vue reactive primitives:
import { useHead } from '@unhead/vue'
import { computed, ref } from 'vue'
// Create reactive state
const title = ref('My Site')
const description = ref('Welcome to my website')
const product = ref({ name: 'widget' })
// Use reactive values in head tags
useHead({
// Direct ref
title,
meta: [
// Computed getter (recommended for derived values)
{ name: 'description', content: () => description.value },
// Using refs directly in objects
{ property: 'og:title', content: title }
],
// Computed ref
link: [computed(() => ({
rel: 'canonical',
href: `https://example.com/products/${product.value.name}`
}))]
})
Async Context
The inject() function keeps track of your Vue component instance, but after async operations within lifecycle hooks or nested functions, Vue can lose track of this context.
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted } from 'vue'
onMounted(async () => {
await someAsyncOperation()
// This will throw an error
useHead({
title: 'My Title'
})
})
</script>
When trying to inject once Vue has lost the context, you'll receive an error from Unhead:
Choose the pattern that matches where the asynchronous work runs.
Top-Level Await
Vue's <script setup> compiler preserves component context across top-level await through a compile-time transform.
At the top level of script setup, context is automatically preserved:
<script setup lang="ts">
import { useHead } from '@unhead/vue'
// The compiler transforms this to preserve context
await someAsyncOperation()
useHead({
title: 'My Title'
})
</script>
Outside compiler-transformed top-level code, create the head entry before awaiting and patch it when the data arrives. This keeps its watcher and cleanup attached to the component scope.
Create the Entry Before Awaiting
Create entries synchronously during setup, then patch those entries from asynchronous callbacks:
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted } from 'vue'
const pageHead = useHead({
title: 'My Site'
})
const analyticsHead = useHead({})
async function updatePageHead(id: string) {
const data = await fetchPage(id)
pageHead.patch({
title: data.title,
meta: [
{
name: 'description',
content: data.description
}
]
})
}
onMounted(async () => {
const analyticsUrl = await loadAnalyticsUrl()
analyticsHead.patch({
script: [
{
// Only use URLs from an allowlisted, trusted origin.
src: analyticsUrl
}
]
})
})
</script>
Do not place strings from APIs or users in a script's innerHTML or textContent; both execute as JavaScript. Vue's security guide recommends avoiding untrusted JavaScript and URLs.
Reactive State
Define the head entry during setup and update reactive state when the request completes:
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { computed, ref } from 'vue'
// Initialize your reactive state
const page = ref({
title: 'Loading...',
description: '',
image: '/placeholder.jpg'
})
// Define head once with computed properties
useHead({
// Title will automatically update when page.value.title changes
title: computed(() => page.value.title),
meta: [
{
name: 'description',
content: computed(() => page.value.description)
},
{
property: 'og:image',
content: computed(() => page.value.image)
}
]
})
// Async operations update the reactive state
async function loadPage(id: string) {
const data = await fetchPage(id)
// Head updates automatically when we update the ref
page.value = {
title: data.title,
description: data.description,
image: data.image
}
}
// Works great with watchers too
watch(route, async () => {
await loadPage(route.params.id)
})
</script>
Pinia
The same pattern works with a Pinia store:
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { storeToRefs } from 'pinia'
import { usePageStore } from '@/stores/page'
const store = usePageStore()
// Destructure with storeToRefs to maintain reactivity
const { title, description } = storeToRefs(store)
useHead({
title, // Reactive store state automatically works
meta: [
{
name: 'description',
content: description
}
]
})
// Now your store actions can update the head
await store.fetchPage(id)
</script>
Common Patterns
Refs and Computed Getters
Prefer direct refs and computed getters:
// ✅ Good approach
const title = ref('Product Page')
const product = ref({ name: 'Widget' })
useHead({
// Direct ref
title,
// Computed getter
meta: [
{ property: 'og:title', content: () => `${product.value.name} - ${title.value}` }
]
})
Translated Titles
Call translation functions inside getters so locale changes update the existing head entry:
const { locale, t } = useI18n()
useHead({
htmlAttrs: {
lang: () => locale.value,
},
title: () => t('pages.login.title'),
titleTemplate: title => title
? `${title} | ${t('brand.name')}`
: t('brand.name'),
})
Calling t() before useHead() stores only its current result:
// Does not update when the locale changes
useHead({ title: t('pages.login.title') })
Pass a functional titleTemplate directly. If you wrap it in computed(), Vue treats its parameter as the previous computed value rather than the page title from Unhead:
// The callback parameter is not the page title
useHead({
titleTemplate: computed(title => `${title} | ${t('brand.name')}`),
})
Once Unhead owns the title, avoid writing to document.title directly. Unhead will overwrite that value on its next render.
Avoid Creating Entries in Watchers
Avoid useHead() calls in watchers, as this creates new entries on each update:
// ❌ Bad approach: Creates multiple entries
watch(title, (newTitle) => {
useHead({
title: newTitle
})
})
// ✅ Good approach: Updates existing entry
useHead({
title // ref value updates automatically
})
KeepAlive
Vue's <KeepAlive> preserves component state across deactivation.
For VDOM components, Unhead deactivates and restores head entries at the same time.
Vapor components have a KeepAlive limitation.
<!-- VDOM example: KeepAlive preserves the component and its head entry. -->
<KeepAlive>
<component :is="currentView" />
</KeepAlive>
Examples
Dynamic SEO Metadata
import { useSeoMeta } from '@unhead/vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
export default {
setup() {
const route = useRoute()
const product = ref(null)
// Fetch data based on route
fetchProduct(route.params.id).then((data) => {
product.value = data
})
// SEO tags update automatically when product data is loaded
useSeoMeta({
title: () => product.value?.name || 'Loading...',
description: () => product.value?.description || '',
ogImage: () => product.value?.image || '/default.jpg',
})
}
}
Multiple Entries
You can use multiple useHead() calls in different components, and Unhead will handle merging them correctly:
// BaseLayout.vue
useHead({
titleTemplate: '%s | My Site',
meta: [
{ name: 'theme-color', content: '#ff0000' }
]
})
// ProductPage.vue
useHead({
title: () => product.value?.name || '',
meta: [
{ name: 'description', content: () => product.value?.description || '' }
]
})
Nuxt Async Context
Nuxt preserves application context across supported transformed scopes, including component setup, plugins, and middleware. Arbitrary callbacks are not automatically transformed, so the reactive-state pattern remains the safest approach when asynchronous work completes later.
await expressions.Adapter Internals
The Vue adapter gets the head instance through provide/inject, unwraps refs and computed values in watchEffect(), and registers activation and cleanup hooks on the current component. Vue's <script setup> API documents the top-level await transform used in the async examples above.