---
title: "Streaming SSR"
description: "Stream head tags as async content resolves during server-side rendering"
canonical_url: "https://unhead.unjs.io/docs/typescript/head/guides/core-concepts/streaming"
last_updated: "2026-08-08T00:13:27.430Z"
---

A streaming server may send its document shell before all head entries exist. Unhead renders the initial head into that shell and serializes later entries as inline patches between application chunks. The framework-agnostic API accepts a standard [Web `ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), so it can plug into any renderer that exposes chunk boundaries.

## How It Works

1. **Shell:** Unhead renders the entries available before streaming starts.
2. **Application:** Async work registers another entry with `head.push()`.
3. **Patch:** The server writes that entry as an inline script between chunks.
4. **Hydration:** The client instance takes over the streamed state.

## Setup

### Server

Use `wrapStream` to inject head updates into your response stream:

```ts
import { createStreamableHead, prepareTemplate, renderSSRHeadSuspenseChunk, wrapStream } from 'unhead/stream/server'

const { head } = createStreamableHead()

head.push({
  title: 'My App',
  htmlAttrs: { lang: 'en' }
})

const template = prepareTemplate('<!DOCTYPE html><html><head></head><body></body></html>')

// Flush head entries registered while each app chunk renders.
const fullStream = wrapStream(head, appStream, template, undefined, {
  flushChunk: () => {
    const update = renderSSRHeadSuspenseChunk(head)
    return update ? `<script>${update}</script>` : ''
  },
})
return new Response(fullStream)
```

### Reuse a Production Template

<note>

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

</note>

`prepareTemplate()` parses the HTML once. Keep the result next to the production template and pass it to each request's head instance:

```ts
import { readFile } from 'node:fs/promises'
import { createStreamableHead, prepareTemplate, renderSSRHeadSuspenseChunk, wrapStream } from 'unhead/stream/server'

const template = prepareTemplate(await readFile('dist/client/index.html', 'utf8'))

export function handleRequest(appStream: ReadableStream<Uint8Array>) {
  const { head } = createStreamableHead()
  return wrapStream(head, appStream, template, undefined, {
    flushChunk: () => {
      const update = renderSSRHeadSuspenseChunk(head)
      return update ? `<script>${update}</script>` : ''
    },
  })
}
```

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.

A prepared value belongs to the exact HTML passed to `prepareTemplate()`. Prepare again after `transformIndexHtml()` runs in development, or whenever a build produces a new template. Do not replace an outlet or other template content after preparing it.

### Client

The client automatically processes queued entries from the stream:

```ts
import { createStreamableHead } from 'unhead/stream/client'

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

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.

## Manual Stream Control

For fine-grained control over write boundaries, split the full template with `prepareStreamingTemplate` and emit later head entries with `renderSSRHeadSuspenseChunk`:

```ts
import { prepareStreamingTemplate, renderSSRHeadSuspenseChunk } from 'unhead/stream/server'

// The full template retains its closing markup and body-positioned tags.
const { shell, end } = prepareStreamingTemplate(head, template)
res.write(shell)

// After each async chunk, emit head updates
for await (const chunk of appStream) {
  res.write(chunk)
  const update = renderSSRHeadSuspenseChunk(head)
  if (update)
    res.write(`<script>${update}</script>`)
}

// Flush entries registered after the final app chunk, then close the template.
const finalUpdate = renderSSRHeadSuspenseChunk(head)
if (finalUpdate)
  res.write(`<script>${finalUpdate}</script>`)
res.write(end)
```

Use these manual functions when your renderer already controls the precise write boundary between chunks.

`prepareStreamingTemplate()` currently creates its bootstrap script without a nonce. If an enforced Content Security Policy requires nonces on inline scripts, use the template-free approach below so you can pass a nonce to `createBootstrapScript()` and add it to each update block.

## Template-Free Integration

For frameworks that construct HTML programmatically (like Nuxt) rather than using an HTML template, use `renderShell` and `createBootstrapScript` directly:

```ts
import { createBootstrapScript, renderShell, renderSSRHeadSuspenseChunk } from 'unhead/stream/server'

// Render and consume all current head entries atomically
const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = renderShell(head)

// Generate this per response and include it in the response's CSP header.
declare const nonce: string

// Get the bootstrap script that creates the client-side stream queue
const bootstrap = createBootstrapScript('__unhead__', nonce)

// Build shell HTML programmatically
const shell = '<!DOCTYPE html>'
  + `<html${htmlAttrs}>`
  + `<head>${bootstrap}${headTags}</head>`
  + `<body${bodyAttrs}>`
  + (bodyTagsOpen || '')

res.write(shell)

// Stream app content, injecting head updates between chunks
for await (const chunk of appStream) {
  res.write(chunk)
  const update = renderSSRHeadSuspenseChunk(head)
  if (update)
    res.write(`<script nonce="${nonce}">${update}</script>`)
}

// Flush any entries registered after the final app chunk
const finalUpdate = renderSSRHeadSuspenseChunk(head)
if (finalUpdate)
  res.write(`<script nonce="${nonce}">${finalUpdate}</script>`)

res.write(`${bodyTags}</body></html>`)
```

The bootstrap and update blocks are inline scripts. If your response uses a Content Security Policy, generate a nonce for each response, pass it to `createBootstrapScript()`, and add the same nonce to every update `<script>`. MDN's [CSP guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#nonces) explains why nonces must be unpredictable and unique per response.

## API Reference

### createStreamableHead

Creates a streaming-aware head instance:

```ts
import { createStreamableHead } from 'unhead/stream/server'

const { head, onShellReady, shellReady } = createStreamableHead({
  streamKey: '__unhead__', // Optional custom stream identifier
})
```

The framework-agnostic return value exposes shell-coordination helpers. Import the standalone `wrapStream()` function shown below when using a Web `ReadableStream`.

### wrapStream

Wraps a `ReadableStream` with head injection:

```ts
import type { SSRHeadPayload, Unhead } from 'unhead/types'
import type { PreparedTemplate } from 'unhead/stream/server'

declare function wrapStream(
  head: Unhead,
  stream: ReadableStream<Uint8Array>,
  template: string | PreparedTemplate,
  preRenderedState?: SSRHeadPayload,
  options?: { flushChunk?: () => string }
): ReadableStream<Uint8Array>
```

Without `flushChunk`, `wrapStream()` writes the initial shell, application stream, and closing HTML but does not serialize entries registered after the shell. Framework wrappers provide their own update mechanism; framework-agnostic integrations can use the callback above or take manual control of the stream.

### prepareTemplate

`prepareTemplate()` and its `PreparedTemplate` return type are experimental.

Parses a stable HTML template once and returns an immutable value that contains no request or head state:

```ts
import { prepareTemplate, type PreparedTemplate } from 'unhead/stream/server'

declare function prepareTemplate(html: string): PreparedTemplate
```

Pass the result to `transformHtmlTemplate()`, `transformHtmlTemplateRaw()`, `renderSSRHeadShell()`, `prepareStreamingTemplate()`, or `wrapStream()`. It is also exported from `unhead/parser`, `unhead/server`, and each framework's server and streaming server entry points.

### renderShell

Renders the current head state and clears entries atomically. Use this instead of manually calling `head.render()` followed by `head.entries.clear()`:

```ts
import { renderShell } from 'unhead/stream/server'

const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = renderShell(head)
```

### createBootstrapScript

Generates the inline `<script>` tag that creates the streaming queue on the window object. Must run before any streaming updates:

```ts
import { createBootstrapScript } from 'unhead/stream/server'

declare const nonce: string

const script = createBootstrapScript() // uses default key '__unhead__'
const script2 = createBootstrapScript('myKey') // custom stream key
const scriptWithNonce = createBootstrapScript('__unhead__', nonce) // CSP nonce
```

## When to Skip

If your SSR is fully synchronous (no async data fetching during render), stick with standard SSR. The streaming setup adds complexity for no benefit when all head tags are available at initial render.
