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.
<head> tags render with the document shellhead.push()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)
The client automatically processes queued entries from the stream:
import { createStreamableHead } from 'unhead/stream/client'
const head = createStreamableHead()
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.
Creates a streaming-aware head instance:
import { createStreamableHead } from 'unhead/stream/server'
const { head, wrapStream } = createStreamableHead(options?: {
streamKey?: string // Custom key for stream identification
})
Wraps a ReadableStream with head injection:
import { wrapStream } from 'unhead/stream/server'
const wrappedStream = wrapStream(
head: Unhead,
stream: ReadableStream<Uint8Array>,
template: string
): ReadableStream<Uint8Array>
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.