---
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-31T02:54:26.074Z"
---

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()`](https://react.dev/reference/react-dom/server/renderToPipeableStream).

<note>

This example uses Node.js streams.
Use [`renderToReadableStream()`](https://react.dev/reference/react-dom/server/renderToReadableStream) for Web Streams.

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

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

<warning>

Many bots and link previews do not run patch scripts.
Register canonical links, robots, descriptions, and Open Graph tags before the shell.

Google can read [JSON-LD from the head or body](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data#supported-formats).

</warning>

## Setup

### Vite Plugin

Enable streaming transforms:

```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 does not add CSP nonces to its inline scripts.
For a nonce-only policy, use the [manual core flow](/docs/typescript/head/guides/core-concepts/streaming#template-free-integration).
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 `index.html` stays unchanged, 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. Create a new `head` for every request.
Sharing `head` can expose one request's tags to another.

In development, prepare the result of `transformIndexHtml()` for each request.
Prepare again after any HTML change.

### 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>,
)
```

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

```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` before rendering. React retries `<Suspense>` with the same cached promise.
See the [`use()` reference](https://react.dev/reference/react/use#use-promise).

## When to Skip

If you have no async `<Suspense>` content, use standard SSR.
