---
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-08-11T00:40:46.295Z"
---

With [`renderToPipeableStream()`](https://react.dev/reference/react-dom/server/renderToPipeableStream), React can send the document shell before a component inside `<Suspense>` calls `useHead()`. The Unhead transform inserts `HeadStream` beside that component so its later entry is written as an inline patch.

<note>

This example targets Node.js streams. React provides [`renderToReadableStream()`](https://react.dev/reference/react-dom/server/renderToReadableStream) for runtimes that use Web Streams, including many edge environments.

</note>

## How It Works

1. **Shell:** React calls `onShellReady()` and Unhead renders the initial tags.
2. **Suspense:** An async component resolves and calls `useHead()`.
3. **Patch:** `HeadStream` serializes that entry into the React stream.
4. **Hydration:** The client instance takes over the streamed 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()`.

<warning>

The automatic wrapper currently emits its server bootstrap and `HeadStream` update scripts without nonce attributes, so a nonce-only Content Security Policy will block them. If your renderer exposes reliable chunk boundaries, use the [template-free core flow](/docs/typescript/head/guides/core-concepts/streaming#template-free-integration) to apply the same fresh, per-response nonce to the bootstrap and every update script, as described in [MDN's CSP guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#nonces). Otherwise, use standard SSR.

</warning>

### Server Entry

```tsx
// 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) {
  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),
  }
}
```

### Reuse a Production Template

<note>

`prepareTemplate()` and `PreparedTemplate` are experimental. Their API may change in a future minor release.

</note>

If the production server reads the same built `index.html` for every request, prepare it once before calling `render()`:

```ts
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, but create a new `head` for every request. Sharing a head instance can retain entries and expose one request's tags to another.

In development, prepare the result of `transformIndexHtml()` for each request. Run `prepareTemplate()` again whenever the HTML changes, and never replace an outlet after preparation.

### 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()
if (!head)
  throw new Error('Unhead streaming bootstrap state is missing')

hydrateRoot(
  document.getElementById('app')!,
  <UnheadProvider value={head}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </UnheadProvider>,
)
```

An undefined result means the server response did not initialize the stream queue. Check that the server used the streaming renderer and that its bootstrap script was not removed from the template.

## Usage

With the Vite plugin enabled, call `useHead()` in the component inside `<Suspense>`:

```tsx
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` in a route loader or another cache before rendering. React's [`use()` reference](https://react.dev/reference/react/use#use-promise) warns that constructing a new Promise during render recreates it on every Suspense retry.

## 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.
