TypeScript
You're viewing Unhead v3 beta documentation.
Core Concepts

Streaming SSR

v3 Only - Streaming support is only available in Unhead v3 (currently in beta). This API is experimental and may change.

Standard SSR waits for everything to render before sending HTML. Streaming sends the document shell immediately, then streams content as async operations complete.

The problem: when content loads asynchronously during SSR, any head tags set after the initial render never reach the client's <head>. The shell has already been sent.

Unhead's streaming integration solves this by injecting <script> patches into the stream as content resolves, updating the <head> in real-time.

How It Works

  1. Shell renders - Initial <head> tags render with the document shell
  2. Async content resolves - New head tags are registered via head.push()
  3. Patches stream - Unhead injects DOM updates as inline scripts
  4. Client hydrates - The client head instance picks up the final state

Setup

Server

Use wrapStream to inject head updates into your response stream:

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

const { head } = createStreamableHead()

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

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

// Wrap your app stream with head injection
const fullStream = wrapStream(head, appStream, template)
return new Response(fullStream)

Client

The client automatically processes queued entries from the stream:

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

const head = createStreamableHead()

Manual Stream Control

For fine-grained control over when head updates are injected, use renderSSRHeadShell and renderSSRHeadSuspenseChunk directly:

import { renderSSRHeadShell, renderSSRHeadSuspenseChunk } from 'unhead/stream/server'

// Render initial shell with current head state
res.write(renderSSRHeadShell(head, template))

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

res.write('</body></html>')

This approach is useful when integrating with custom streaming frameworks or when you need to control exactly when head patches are emitted.

API Reference

createStreamableHead

Creates a streaming-aware head instance:

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

const { head, wrapStream } = createStreamableHead(options?: {
  streamKey?: string  // Custom key for stream identification
})

wrapStream

Wraps a ReadableStream with head injection:

import { wrapStream } from 'unhead/stream/server'

const wrappedStream = wrapStream(
  head: Unhead,
  stream: ReadableStream<Uint8Array>,
  template: string
): ReadableStream<Uint8Array>

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.

Did this page help you?