Streaming SSR
Vue can send the document shell while async components are still rendering. Unhead sends their later head entries with the Vue stream.
This guide uses Vue's renderToWebStream().
How It Works
- Shell: Initial tags render with the document shell.
- Suspense: An async component resolves and calls
useHead(). - Patch: Unhead writes its head update as an inline script.
- Hydration: The client instance takes over the streamed state.
Where Tags Render
Create the Vue stream before calling wrapStream(). Vue runs its synchronous pass during renderToWebStream().
A useHead() call reaches the initial head when both conditions are true:
- It runs before its component's first
await. - Every ancestor remains synchronous.
An async ancestor defers its whole subtree. A <Suspense> boundary does not change this cutoff.
Later head-only tags become inline patch scripts.
Late JSON-LD, noscript, and body-positioned tags become streamed body tags before </body>.
Setup
Vite Plugin
Enable the streaming option:
// vite.config.ts
import { Unhead } from '@unhead/vue/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
vue(),
Unhead({ streaming: true }),
],
})
For webpack projects, import Unhead from @unhead/vue/bundler and call Unhead({ streaming: true }).webpack().
Pass each response's CSP nonce to createStreamableHead({ nonce }).
Unhead applies it to its bootstrap and patch scripts.
Use the same nonce in the response CSP header and application scripts.
See the core CSP example for request setup.
Server Entry
// entry-server.ts
import { renderToWebStream } from 'vue/server-renderer'
import { createStreamableHead, type PreparedTemplate } from '@unhead/vue/stream/server'
import { createApp } from './main'
export async function render(url: string, template: string | PreparedTemplate, nonce?: string) {
const { app, router } = createApp()
const { head, wrapStream } = createStreamableHead({ nonce })
app.use(head)
router.push(url)
await router.isReady()
const vueStream = renderToWebStream(app)
return wrapStream(vueStream, template)
}
Reuse a Production Template
prepareTemplate() and PreparedTemplate are experimental. Their API may change in a future minor release.If index.html stays unchanged, prepare it once before calling render():
import { readFile } from 'node:fs/promises'
import { prepareTemplate } from '@unhead/vue/stream/server'
const template = prepareTemplate(await readFile('dist/client/index.html', 'utf8'))
Keep template at process scope. Create a new head for every request.
Sharing head can expose one request's tags to another.
Unhead parses raw templates on each request. If production HTML stays unchanged, reuse a prepareTemplate() result.
Each request still resolves head tags. Prepared templates do not cache rendered head output.
In development, prepare the result of transformIndexHtml() for each request.
Prepare again after any HTML change.
Client Entry
// entry-client.ts
import { createStreamableHead } from '@unhead/vue/stream/client'
import { createApp } from './main'
const { app, router } = createApp()
const head = createStreamableHead()
if (!head)
throw new Error('Unhead streaming bootstrap state is missing')
app.use(head)
router.isReady().then(() => {
app.mount('#app')
})
If this returns undefined, the response has no stream bootstrap.
Check the server renderer and HTML template.
Usage
Call useHead() inside the async component:
<script setup lang="ts">
const data = await fetch('/api/page').then(response => response.json())
useHead({
title: data.title,
meta: [
{ name: 'description', content: data.description }
]
})
</script>
When to Skip
If you have no async components, use standard SSR.