Streaming SSR
React can send the document shell while <Suspense> content is still rendering.
Unhead sends later head entries with each resolved boundary.
This guide uses React's renderToPipeableStream().
renderToReadableStream() for Web Streams.How It Works
- Shell: React calls
onShellReady()and Unhead renders the initial tags. - Suspense: An async component resolves and calls
useHead(). - Patch:
HeadStreamserializes that entry into the React stream. - Hydration: The client instance takes over the streamed state.
Where Tags Render
onShellReady marks React's initial shell.
Entries registered before this callback reach the initial head.
Entries from later <Suspense> content follow two paths:
- Head-only tags become inline patch scripts.
- JSON-LD,
noscript, and body-positioned tags become streamed body tags before</body>.
The build plugin adds HeadStream inside each transformed component that calls useHead().
Keep the plugin enabled or late entries will not reach the response.
Setup
Vite Plugin
Enable streaming transforms:
// vite.config.ts
import { Unhead } from '@unhead/react/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
react(),
Unhead({ streaming: true }),
],
})
For webpack projects, import Unhead from @unhead/react/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.
Pass it to renderToPipeableStream() too, as shown below.
See the core CSP example for request setup.
Server Entry
// entry-server.tsx
import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import { createStreamableHead, type PreparedTemplate, UnheadProvider } from '@unhead/react/stream/server'
import App from './App'
export function render(url: string, template: string | PreparedTemplate, nonce?: string) {
const { head, onShellReady, wrap } = createStreamableHead({ nonce })
const { pipe, abort } = renderToPipeableStream(
<UnheadProvider value={head}>
<StaticRouter location={url}>
<App />
</StaticRouter>
</UnheadProvider>,
{ onShellReady, nonce },
)
return {
abort,
pipe: wrap(pipe, 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/react/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.tsx
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { createStreamableHead, UnheadProvider } from '@unhead/react/stream/client'
import App from './App'
const head = createStreamableHead()
if (!head)
throw new Error('Unhead streaming bootstrap state is missing')
hydrateRoot(
document.getElementById('app')!,
<UnheadProvider value={head}>
<BrowserRouter>
<App />
</BrowserRouter>
</UnheadProvider>,
)
If this returns undefined, the response has no stream bootstrap.
Check the server renderer and HTML template.
Usage
With the Vite plugin enabled, call useHead() inside <Suspense>:
import { Suspense, use } from 'react'
import { useHead } from '@unhead/react'
function AsyncPage({ pagePromise }) {
// React requires the same cached Promise instance across render retries.
const data = use(pagePromise)
useHead({
title: data.title,
meta: [
{ name: 'description', content: data.description }
]
})
return <div>{data.content}</div>
}
function App({ pagePromise }) {
return (
<Suspense fallback={<Loading />}>
<AsyncPage pagePromise={pagePromise} />
</Suspense>
)
}
Create pagePromise before rendering. React retries <Suspense> with the same cached promise.
See the use() reference.
When to Skip
If you have no async <Suspense> content, use standard SSR.