---
title: "Streaming SSR"
description: "Stream head tags as Suspense boundaries resolve during Solid.js SSR"
canonical_url: "https://unhead.unjs.io/docs/solid-js/head/guides/core-concepts/streaming"
last_updated: "2026-08-11T00:41:13.286Z"
---

With [`renderToStream()`](https://docs.solidjs.com/reference/rendering/render-to-stream), Solid can send the document shell before a resource inside `<Suspense>` registers its tags. The Unhead transform inserts `HeadStream` beside components that call a head composable. The resolved chunk then carries those entries.

Solid's stream exposes `pipe()` for Node.js responses and `pipeTo()` for Web `WritableStream` targets. The example below uses `pipeTo()` with a `TransformStream`.

## How It Works

1. **Shell:** Solid calls `onCompleteShell()` and Unhead captures the initial tags.
2. **Suspense:** A resource settles and its component calls `useHead()`.
3. **Patch:** `HeadStream` serializes that entry into the Solid 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/solid-js/vite'
import solid from 'vite-plugin-solid'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    solid(),
    Unhead({ streaming: true }),
  ],
})
```

For webpack projects, import `Unhead` from `@unhead/solid-js/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 { renderToStream } from 'solid-js/web'
import { createStreamableHead, type PreparedTemplate, UnheadContext } from '@unhead/solid-js/stream/server'
import App from './App'

export function render(url: string, template: string | PreparedTemplate) {
  const { head, onCompleteShell, wrapStream } = createStreamableHead()

  const { readable, writable } = new TransformStream()

  renderToStream(() => (
    <UnheadContext.Provider value={head}>
      <App url={url} />
    </UnheadContext.Provider>
  ), { onCompleteShell }).pipeTo(writable)

  return wrapStream(readable, 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/solid-js/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 { hydrate } from 'solid-js/web'
import { createStreamableHead, UnheadContext } from '@unhead/solid-js/stream/client'
import App from './App'

const head = createStreamableHead()
if (!head)
  throw new Error('Unhead streaming bootstrap state is missing')

hydrate(() => (
  <UnheadContext.Provider value={head}>
    <App url={window.location.pathname} />
  </UnheadContext.Provider>
), document.getElementById('app')!)
```

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 that reads the resource:

```tsx
import { Suspense, createResource } from 'solid-js'
import { useHead } from '@unhead/solid-js'

function AsyncPage() {
  const [data] = createResource(fetchPageData)

  useHead({
    get title() { return data()?.title },
    meta: [
      { name: 'description', get content() { return 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 resources, stick with standard SSR. The streaming setup adds complexity for no benefit when all head tags are synchronous.
