---
title: "Streaming SSR"
description: "Stream head tags as Suspense boundaries resolve during React SSR"
canonical_url: "https://unhead.unjs.io/docs/react/head/guides/core-concepts/streaming"
last_updated: "2026-06-30T06:54:08.181Z"
---

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:

```ts
// 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()`.

### Server Entry

```tsx
// entry-server.tsx
import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
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

```tsx
// 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:

```tsx
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.
