Streaming SSR
Solid can send the document shell while resources inside <Suspense> are still loading.
Unhead sends their later head entries with the resolved content.
This guide uses Solid's renderToStream().
The example connects pipeTo() to a Web TransformStream.
How It Works
- Shell: Solid calls
onCompleteShell()and Unhead captures the initial tags. - Suspense: A resource settles and its component calls
useHead(). - Patch:
HeadStreamserializes that entry into the Solid stream. - Hydration: The client instance takes over the streamed state.
Where Tags Render
onCompleteShell marks Solid's initial shell.
Entries registered before this callback reach the initial head.
Entries from later resource content follow two paths:
- Head-only tags become inline patch scripts.
- JSON-LD,
noscript, and body-positioned tags become streamed body tags before</body>.
The build plugin adds HeadStream to transformed components that use head composables.
Keep the plugin enabled or late entries will not reach the response.
Setup
Vite Plugin
Enable streaming transforms:
// 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().
Pass each response's CSP nonce to createStreamableHead({ nonce }).
Unhead applies it to its bootstrap and patch scripts.
Use the same nonce in the response CSP header and application scripts.
Pass it to renderToStream() too, as shown below.
See the core CSP example for request setup.
Server Entry
// 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, nonce?: string) {
const { head, onCompleteShell, wrapStream } = createStreamableHead({ nonce })
const { readable, writable } = new TransformStream()
renderToStream(() => (
<UnheadContext.Provider value={head}>
<App url={url} />
</UnheadContext.Provider>
), { onCompleteShell, nonce }).pipeTo(writable)
return wrapStream(readable, template)
}
Reuse a Production Template
prepareTemplate() and PreparedTemplate are experimental. Their API may change in a future minor release.If index.html stays unchanged, prepare it once before calling render():
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. Create a new head for every request.
Sharing head can expose one request's tags to another.
Unhead parses raw templates on each request. If production HTML stays unchanged, reuse a prepareTemplate() result.
Each request still resolves head tags. Prepared templates do not cache rendered head output.
In development, prepare the result of transformIndexHtml() for each request.
Prepare again after any HTML change.
Client Entry
// 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')!)
If this returns undefined, the response has no stream bootstrap.
Check the server renderer and HTML template.
Usage
With the Vite plugin enabled, call useHead() in the component that reads the resource:
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 have no async resources, use standard SSR.