---
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-10-08T09:42:40.108Z"
---

A streaming server may send the document shell before all head entries exist.
Unhead puts known entries in the shell. It sends later entries with the application stream.

The core API accepts a [Web `ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).

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

## Where Tags Render

`wrapStream()` captures the entries available when it starts. Create the application stream first:

```ts
const appStream = renderToWebStream(app)
return wrapStream(head, appStream, template)
```

Tags follow three paths:

<table>
<thead>
  <tr>
    <th>
      Registration time
    </th>
    
    <th>
      Output
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      before <code>
        wrapStream()
      </code>
      
       starts
    </td>
    
    <td>
      initial document shell
    </td>
  </tr>
  
  <tr>
    <td>
      after the shell
    </td>
    
    <td>
      inline patch script
    </td>
  </tr>
  
  <tr>
    <td>
      after the shell, when valid in the body
    </td>
    
    <td>
      streamed body tag before <code>
        </body>
      </code>
    </td>
  </tr>
</tbody>
</table>

Late JSON-LD, `noscript`, and explicit body positions become streamed body tags.
Late `bodyOpen` tags move to the body close because the opening body has already left the server.

`flushChunk` only changes patch output. `renderStreamEnd()` writes streamed body tags.

<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

### Server

Use `wrapStream()` to add head updates to the response:

```ts
import { createStreamableHead, prepareTemplate, 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)
return new Response(fullStream)
```

For CSP, pass each response's nonce when creating its head:

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

// Generate a new nonce for each response. Include it in that response's CSP header.
declare const nonce: string
const { head } = createStreamableHead({ nonce })
return new Response(wrapStream(head, appStream, template))
```

Unhead stamps its bootstrap and patch scripts with this nonce.
Apply the same nonce to application scripts through tag props or your request hooks.
If you supply `flushChunk` or write scripts yourself, include the nonce in those script tags.
Keep nonce values out of shared production templates and build configuration.

### Reuse a Production Template

<note>

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

</note>

`prepareTemplate()` parses stable HTML once. Reuse the result across requests:

```ts
import { readFile } from 'node:fs/promises'
import { createStreamableHead, prepareTemplate, 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)
}
```

Keep `template` at process scope. Create a new `head` for every request.
Sharing `head` can expose one request's tags to another.

A prepared value belongs to its source HTML. Prepare again after any template change.

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

If this returns `undefined`, the response has no stream bootstrap.
Check the server renderer and HTML template.

## Manual Stream Control

If you control each write, use `prepareStreamingTemplate()` and `renderSSRHeadSuspenseChunk()`:

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

const { head } = createStreamableHead({ writesBodyTags: true })

// The full template retains its closing HTML and body-positioned tags.
const parts = prepareStreamingTemplate(head, template)
res.write(parts.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>window.__unhead__&&(${update});document.currentScript.remove()</script>`)
}

// Flush entries registered after the final app chunk, then close the template.
const finalUpdate = renderSSRHeadSuspenseChunk(head)
if (finalUpdate)
  res.write(`<script>window.__unhead__&&(${finalUpdate});document.currentScript.remove()</script>`)
res.write(renderStreamEnd(head, parts))
```

Use this flow only when your renderer exposes chunk boundaries.

For CSP, create the head with `createStreamableHead({ nonce, writesBodyTags: true })`.
Add the same nonce to each script tag you write.

## Template-Free Integration

If you build HTML in code, use `renderShell()` and `createBootstrapScript()` directly:

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

const streamKey = '__unhead__'
const { head } = createStreamableHead({ streamKey, writesBodyTags: true })

// 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(streamKey, 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}">window.${streamKey}&&(${update});document.currentScript.remove()</script>`)
}

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

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

The bootstrap and updates are inline scripts.
For CSP, create a nonce per response. Use it for the bootstrap and every update.
See [MDN's CSP guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#nonces).

## API Reference

### createStreamableHead

Creates a streaming-aware head instance:

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

declare const nonce: string

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

The core return value exposes shell callbacks. Use the standalone `wrapStream()` with Web Streams.

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

By default, `wrapStream()` writes a guarded patch after each application chunk.
Set `nonce` in `createStreamableHead()` to stamp these scripts.
Use `flushChunk` to replace the default output.

### prepareTemplate

`prepareTemplate()` and `PreparedTemplate` are experimental.

It parses stable HTML into an immutable, reusable value:

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

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

The value contains no request state. Pass it to the server renderers below.

### renderSSRHeadShell

Renders the current head into a full template, then clears the entries:

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

const shell = renderSSRHeadShell(head, template)
```

It also adds the bootstrap script.

### prepareStreamingTemplate

Splits a template around the application content:

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

const parts = prepareStreamingTemplate(head, template)
response.write(parts.shell)
// ... stream application content ...
response.write(renderStreamEnd(head, parts))
```

It prefers `<!--app-html-->` or `<!--ssr-outlet-->`. Otherwise, it splits after `<body>`.

### renderSSRHeadSuspenseChunk

Serializes and clears entries registered since the last flush:

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

const chunk = renderSSRHeadSuspenseChunk(head)
if (chunk) {
  response.write(`<script>window.__unhead__&&(${chunk});document.currentScript.remove()</script>`)
}
```

It returns an empty string when no entries are pending.
If you set `streamKey`, use the same key in this guard.

### renderStreamEnd

Builds closing HTML and inserts streamed body tags:

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

response.write(renderStreamEnd(head, parts))
```

Use it instead of writing `parts.end`.

### renderStreamBodyTags

Renders and clears streamed body tags without a prepared template:

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

response.write(renderStreamBodyTags(head))
```

For manual drivers, set `writesBodyTags: true`. Otherwise, these tags stay in the patch.

### renderShell

Renders and clears the current head state in one operation:

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

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

### createBootstrapScript

Creates the stream queue script. Write it before any 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
```

Use a JavaScript identifier for `streamKey`. `myKey` and `__unhead__` are valid.
`my-key` and `1x` throw in server APIs.

The bundler plugin does not pass its key to the server. Configure both sides with the same key.

### Finding tags bots will not see

Browsers apply streamed patches. Most bots only read server HTML. Tags registered after the shell may be invisible to them.

[`ValidatePlugin`](/docs/typescript/head/guides/tooling/validate-plugin) reports these tags during development as `streamed-tag-hidden-from-bots`. Vite adds the full validator to development server heads made by `createHead()`.

Vite does not add it to production builds. Default production builds pay no classification cost.

Add a focused server instance when a framework owns head creation or you call `createStreamableHead()`:

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

const { head } = createStreamableHead({
  plugins: import.meta.dev
    ? [ValidatePlugin({
        // Cover the server-only rule.
        only: ['streamed-tag-hidden-from-bots'],
        // Keep this instance beside a full validator.
        key: 'validate:streaming',
        onReport(rules) {
          for (const rule of rules)
            myLogger.warn(rule.message, rule.source)
        },
      })]
    : [],
})
```

Each report carries the `tag`. It also includes the `useHead()` source location when available.

Turn the rule off or change its severity like any other:

```ts
ValidatePlugin({ rules: { 'streamed-tag-hidden-from-bots': 'off' } })
```

A tag is reported when it is not body-positioned and matches one of these:

<table>
<thead>
  <tr>
    <th>
      Tag
    </th>
    
    <th>
      Reported when
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        title
      </code>
      
      , <code>
        titleTemplate
      </code>
    </td>
    
    <td>
      always
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        base
      </code>
    </td>
    
    <td>
      always
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        meta
      </code>
    </td>
    
    <td>
      <code>
        name
      </code>
      
       is <code>
        description
      </code>
      
      , <code>
        robots
      </code>
      
      , <code>
        googlebot
      </code>
      
      , <code>
        bingbot
      </code>
      
      , <code>
        slurp
      </code>
      
      , or <code>
        keywords
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        meta
      </code>
    </td>
    
    <td>
      <code>
        http-equiv
      </code>
      
       is <code>
        refresh
      </code>
      
       or <code>
        content-language
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        meta
      </code>
    </td>
    
    <td>
      <code>
        property
      </code>
      
       (falling back to <code>
        name
      </code>
      
      ) starts with <code>
        og:
      </code>
      
      , <code>
        twitter:
      </code>
      
      , <code>
        article:
      </code>
      
      , <code>
        book:
      </code>
      
      , <code>
        profile:
      </code>
      
      , <code>
        fb:
      </code>
      
      , <code>
        al:
      </code>
      
      , <code>
        music:
      </code>
      
      , <code>
        video:
      </code>
      
      , <code>
        place:
      </code>
      
      , or <code>
        product:
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        link
      </code>
    </td>
    
    <td>
      any <code>
        rel
      </code>
      
       token is <code>
        canonical
      </code>
      
      , <code>
        alternate
      </code>
      
      , <code>
        amphtml
      </code>
      
      , <code>
        prev
      </code>
      
      , <code>
        next
      </code>
      
      , <code>
        author
      </code>
      
      , or <code>
        license
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        script
      </code>
    </td>
    
    <td>
      <code>
        type
      </code>
      
       is <code>
        application/ld+json
      </code>
      
      , at any position, unless the driver writes streamed body tags
    </td>
  </tr>
</tbody>
</table>

`useHeadSafe()` strips some tags, such as a `rel="canonical"` link. The rule skips them. Nothing renders them, so a bot warning would mislead.

JSON-LD can leave the stream as streamed body tags. `wrapStream()` always writes them. Manual drivers opt in with `writesBodyTags: true`.

The rule stays quiet when the driver writes streamed body tags. Otherwise, patch-only JSON-LD stays hidden from bots and the rule reports it.

Search engines read served JSON-LD anywhere in the document. Every other reported tag only works from the head.

The rule ignores resource hints, stylesheets, ordinary scripts, attributes, `templateParams`, and body-positioned tags. Browsers can apply these later.

`useSeoMeta()` pushes one internal entry. Normalization expands it into real meta tags. The rule reports each tag separately.

Matching ignores case. A `link` matches if any `rel` token is listed. Tokens split on ASCII whitespace.

## When to Skip

If every head tag is ready during render, use standard SSR.
