Skip to content

Server-Side Rendering

renderToString() renders a Sygnal component to an HTML string without a browser DOM. Use it for server-rendered pages, static site generation, or any environment where you need HTML output from your components.

import { renderToString } from 'sygnal'
const html = renderToString(App, { state: { count: 0 } })
// → '<div class="counter" data-sygnal-ssr=""><h1>Count: 0</h1><button>+</button></div>'

The root element carries an empty data-sygnal-ssr attribute. It marks the markup as Sygnal’s server HTML, so the client can tell it from other content in the mount point (a persisted app restores its saved state after the first render instead of before it). The first client render removes it.

Pass a component function and optional state:

function Greeting({ state }) {
return <div className="greeting">Hello, {state.name}!</div>
}
Greeting.initialState = { name: 'World' }
// Uses component's initialState
renderToString(Greeting)
// → '<div class="greeting" data-sygnal-ssr="">Hello, World!</div>'
// Override state
renderToString(Greeting, { state: { name: 'Alice' } })
// → '<div class="greeting" data-sygnal-ssr="">Hello, Alice!</div>'

Sub-components are rendered recursively. State lensing works the same as in the browser — pass state="propName" to scope child state:

function App({ state }) {
return (
<div>
<Header state="header" />
<Content state="content" />
</div>
)
}
App.initialState = {
header: { title: 'My App' },
content: { body: 'Welcome' },
}
renderToString(App)
// Renders both Header and Content with their scoped state

Collections render each item from the state array:

function TodoItem({ state }) {
return <li>{state.text}</li>
}
function TodoList({ state }) {
return (
<ul>
<Collection of={TodoItem} from="items" />
</ul>
)
}
TodoList.initialState = {
items: [
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Write docs' },
],
}
renderToString(TodoList)
// → '<ul data-sygnal-ssr=""><div><li>Buy milk</li><li>Write docs</li></div></ul>'

Component context is computed from state and propagated to descendants:

function App({ state, context }) {
return <div className={`theme-${context.theme}`}>{state.label}</div>
}
App.initialState = { label: 'Hello', darkMode: true }
App.context = { theme: (state) => state.darkMode ? 'dark' : 'light' }
renderToString(App)
// → '<div class="theme-dark" data-sygnal-ssr="">Hello</div>'

Error boundaries work during SSR. Components with onError render fallback content; those without render an empty <div data-sygnal-error>:

function Fragile({ state }) {
throw new Error('Oops')
}
Fragile.onError = (err, { componentName }) => (
<div className="error">Something went wrong in {componentName}</div>
)
renderToString(Fragile)
// → '<div class="error" data-sygnal-ssr="">Something went wrong in Fragile</div>'

To report these errors, pass the app-level error hook: renderToString(App, { onError }). It is called with the phase 'view', after the boundary picked the fallback.

ComponentSSR Behavior
PortalChildren rendered inline (no target container on server)
TransitionUnwrapped to child element (no animation)
SuspenseAlways renders children (not fallback)
SlotUnwrapped to children
CollectionItems rendered from state array
SwitchableActive component rendered based on state

Embed serialized state in a <script> tag for client-side rehydration:

const html = renderToString(App, {
state: { count: 5 },
hydrateState: true,
})
// Appends: <script>window.__SYGNAL_STATE__={"count":5}</script>

Use a custom variable name:

renderToString(App, {
state: { count: 5 },
hydrateState: '__MY_APP_STATE__',
})
// Appends: <script>window.__MY_APP_STATE__={"count":5}</script>

On the client, read the embedded state to hydrate:

import { run } from 'sygnal'
const initialState = window.__SYGNAL_STATE__ || App.initialState
run(App, '#app', { initialState })

run() doesn’t clear the mount point. Its first render patches the server’s markup in place and adopts each element that matches what the client renders at the same position (the same tag; keyed children such as component roots and Collection items match by key). An adopted element stays the same DOM node, so everything the user did before the app started is kept: the focus, text typed into an uncontrolled field, a checked box, the scroll position. The same applies to Astro islands and Vike pages, which both hydrate this way.

Server markupFirst client render
An element the client renders with the same tagAdopted: the same node. Its attributes become the client’s: the ones the client also renders keep the server’s value (nothing is written again, so an iframe or img doesn’t reload), and attributes the client doesn’t render are removed
class and idKept when the client’s are the same, changed or removed when they aren’t
styleWritten again from the client’s style: a declaration only the server wrote goes
data-*Kept when the client renders them (as data-* props or in attrs), removed when it doesn’t; data-sygnal-ssr (the root’s marker) goes
open on a <details> or <dialog> the client renders without openKept: the user may have opened it before start-up. When the client renders open (open={isOpen}, also false), its value wins. A conditional spread ({...(isOpen && { open: true })}) renders no open at all when closed, so a server open stays: write open={isOpen} to control it
A text nodeKept, its text set to the client’s. Text next to text in the client’s view (Hello, {name}!: three text nodes) is one text node in the server’s HTML: it is split where the client’s first text ends, when it starts with that text, so the elements after it are still adopted
A <textarea>’s text (renderToString writes its value as text)The text node goes; the field keeps its value (the server’s text, or what the user typed)
A component that returns a fragment (<>…</>), or a false / null child ({cond && <X />})A fragment’s elements are adopted one by one, as its parent’s own children; a false / null child takes no element: the elements after them are still adopted. Later renders update, add, remove and move them as on a client-only page
Whitespace and comments where the client renders no text (a page template’s indentation)Removed
An element with another tag, or text where the client renders an elementReplaced in place by the client’s element; the elements around it are still adopted
An element whose vnode has an insert hook: a <Transition>’s child (its enter runs), a <VirtualCollection> row (measured when inserted), the <Toaster> region, a lazy() placeholder, an element with your own hook={{ insert }} (also with a postpatch, a ref or autoFocus)Made again in place, as a fresh render makes it: its insert runs
An element with a create or init hook (a thunk)Made again in place; its hooks run
An element whose hyperscript selector has a class or id (h('p.card'); JSX never makes one), and a <Portal>’s placeholderMade again in place (patching never changes a selector’s class or id)
An element with a ref, autoFocus, a widget, a <VirtualCollection> container, or your own hook without insertAdopted: ref points at the server’s element, autoFocus focuses it, a widget mounts on it. A hook’s postpatch runs
A <Portal>’s content (the server renders it inline)Replaced by the Portal’s placeholder; the content renders in the target
A custom element that builds its own light DOM (children the client doesn’t render), already upgraded before start-upAdopted, but those children are removed (they aren’t the client’s) and its connectedCallback doesn’t run again. Wrap such an element in <ClientOnly> (sygnal/vike/ClientOnly) so the client makes it
The mount point’s own attributes (<div id="app" class="shell" data-theme="dark">)Kept: they aren’t the app’s (when the app’s root element is the mount point itself, its props are written over them)

Form fields follow the usual rule for controlled fields: a field with a value (or checked) prop shows the state, so the client’s value replaces what the user typed or picked before start-up (an input, a textarea and a select alike); a field without one keeps it.

When the server’s markup differs from what the client renders (other state, a mismatched template), the page ends up as a fresh client render would make it. This also holds when the mount point holds something else before start-up (a loading spinner): it is patched into the app. The exceptions are the user’s changes listed above: an uncontrolled field’s value, an open the client doesn’t render.

uid() ids come from each component’s position in the tree, not from a counter, so renderToString and the client produce the same ids and hydration keeps the server’s for / id pairs. Both start from the root u. When a page has more than one app, give each its own root, and the same one on both sides:

// server
const html = renderToString(Signup, { uid: 'signup' })
// client, hydrating that markup
run(Signup, {}, { mountPoint: '#signup', uid: 'signup' })

The ids then start with signup- (signup-email). In Astro, pass a uid prop to the island; in Vike, the Page, Layouts and Wrappers get matching ids on both sides without any option.

The Astro server renderer uses renderToString internally. When using the Sygnal Astro integration, SSR happens automatically:

---
import Counter from '../components/Counter.jsx'
---
<Counter client:load />
function renderToString(
component: ComponentFunction,
options?: RenderToStringOptions
): string
OptionTypeDefaultDescription
stateanyComponent’s .initialStateState for the root component
propsRecord<string, any>{}Props to pass to the component
contextRecord<string, any>{}Parent context to merge with
hydrateStateboolean | string—Embed state in <script> tag
headany[]—Receives each rendered component’s head static (HEAD driver)
cacheQueryCache—A seeded queryCache(): resources found in it render as 'success' with their data, others as 'loading' (Server rendering)
onError(error, info) => void—App-level error hook, called with the phase 'view'
uidstring'u'The root of the uid() ids; use the same value as run()’s uid on the client
  • Intent and Model are skipped — SSR is render-only. Event handlers, streams, and state reducers don’t run on the server.
  • No requests — drivers don’t run, so resources render as 'loading' unless the cache option has them; seed it in a loader with cache.set(request, data) and send cache.dehydrate() to the client (Server rendering).
  • Refs are not populated — No DOM exists, so createRef() objects remain { current: null }.
  • Lazy components — lazy() wrappers render their loading placeholder. For SSR, import components directly instead.