Streaming SSR
Standard SSR waits for everything to render before sending HTML. Streaming sends the document shell immediately, then streams content as Suspense boundaries resolve.
The problem: components inside <Suspense> using useHead() set head tags after the initial render. Without streaming support, those tags never reach the client's <head>.
Unhead's streaming integration solves this by injecting <script> patches into the stream as each Suspense boundary resolves, updating the <head> in real-time.
How It Works
- Shell ready - React calls
onShellReady, initial<head>tags render - Suspense resolves - Async components call
useHead() - Patches stream - Unhead injects DOM updates as inline scripts
- Client hydrates - The client head instance picks up the final state
Setup
Vite Plugin
The plugin transforms your components to enable streaming head updates:
// vite.config.ts
import { unheadReactPlugin } from '@unhead/react/stream/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
react(),
unheadReactPlugin(),
],
})
Server Entry
// entry-server.tsx
import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { createStreamableHead, UnheadProvider } from '@unhead/react/stream/server'
import App from './App'
export function render(url: string, template: string) {
const { head, onShellReady, wrap } = createStreamableHead()
const { pipe, abort } = renderToPipeableStream(
<UnheadProvider value={head}>
<StaticRouter location={url}>
<App />
</StaticRouter>
</UnheadProvider>,
{ onShellReady },
)
return {
abort,
pipe: wrap(pipe, template),
}
}
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()
hydrateRoot(
document.getElementById('app')!,
<UnheadProvider value={head}>
<BrowserRouter>
<App />
</BrowserRouter>
</UnheadProvider>,
)
Usage
Use useHead() normally in your components. Tags from components inside Suspense boundaries stream automatically:
import { Suspense } from 'react'
import { useHead } from '@unhead/react'
function AsyncPage() {
const data = use(fetchPageData()) // React 19 use()
useHead({
title: data.title,
meta: [
{ name: 'description', content: data.description }
]
})
return <div>{data.content}</div>
}
function App() {
return (
<Suspense fallback={<Loading />}>
<AsyncPage />
</Suspense>
)
}
When to Skip
If you're not using Suspense with async data fetching, stick with standard SSR. The streaming setup adds complexity for no benefit when all head tags are synchronous.