React
You're viewing Unhead v3 beta documentation. Install with unhead@beta
Core Concepts

Streaming SSR

v3 Only - Streaming support is only available in Unhead v3 (currently in beta). This API is experimental and may change.

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

  1. Shell ready - React calls onShellReady, initial <head> tags render
  2. Suspense resolves - Async components call useHead()
  3. Patches stream - Unhead injects DOM updates as inline scripts
  4. 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.

Did this page help you?