Skip to content

Server-Side Rendering

Flexium renders to HTML on the server, then hydrates in the browser. Per-request state is isolated via AsyncLocalStorage — concurrent requests don't leak signals.

Basic SSR

Server entry (Node, Bun, Deno, edge runtime):

ts
import { renderToString } from 'flexium/server'
import App from './App'

const html = await renderToString(<App />)

response.send(`
  <!DOCTYPE html>
  <html>
    <head><title>My app</title></head>
    <body>
      <div id="app">${html}</div>
      <script type="module" src="/client.js"></script>
    </body>
  </html>
`)

Client entry:

tsx
import { hydrate } from 'flexium/dom'
import App from './App'

hydrate(<App />, document.getElementById('app')!)

hydrate attaches signals to the existing DOM without re-rendering. Event handlers light up; subsequent updates flow normally.

Streaming SSR

renderToString blocks until all Suspense boundaries resolve. For better TTFB, use the streaming primitives:

ts
import { renderToStream } from 'flexium/server'

const stream = renderToStream(<App />)
response.setHeader('content-type', 'text/html')
response.setHeader('transfer-encoding', 'chunked')

for await (const chunk of stream) {
  response.write(chunk)
}
response.end()

The head + above-fold flushes immediately. Each Suspense boundary streams in as its data resolves.

Per-request isolation

Per-request signals (auth user, locale, request context) need isolation. Flexium uses AsyncLocalStorage internally — but for app-level state, structure it explicitly:

ts
// server.ts
import { runWithRequest } from 'flexium/server'

async function handler(req, res) {
  await runWithRequest({ user: await getUser(req) }, async () => {
    const html = await renderToString(<App />)
    res.send(html)
  })
}
tsx
// App.tsx
import { useRequest } from 'flexium/server'

function Header() {
  const { user } = useRequest()
  return <p>Hello, {user?.name ?? 'guest'}</p>
}

Two concurrent requests with different users see isolated state. No globals, no shared mutable references.

CSS extraction

When using flexium/css, extracted styles need to ship with the HTML:

ts
import { renderToString, getStyleTag, resetStyles } from 'flexium/server'

async function handler(req, res) {
  resetStyles()  // reset per request
  const html = await renderToString(<App />)
  const styles = getStyleTag()  // <style>...</style>

  res.send(`<!DOCTYPE html>
<html>
  <head>${styles}</head>
  <body><div id="app">${html}</div></body>
</html>`)
}

In production with vite-plugin-flexium's build-time extraction, this happens at compile time and you serve a regular CSS file instead — no runtime collection needed.

Edge runtimes

renderToString works in Cloudflare Workers, Vercel Edge Functions, Deno Deploy, Bun. Just import flexium/server and use the same APIs.

For full-stack framework features (file-based routing, server loaders, Stream-based realtime, SSP), see Flexism — built on top of Flexium for fullstack apps.

Hydration mismatches

If server-rendered HTML diverges from what the client expects, hydration logs a warning and falls back to client-side render of the divergent subtree. Common causes:

  • Reading Date.now() or Math.random() at render time (different on server vs client)
  • Using window / document during render
  • Time-zone-dependent date formatting

Fix: do these in unsafeEffect (client-only) or compute on the server and pass as a prop.

Next

Examples — working code for everything covered in the guides.

Released under the MIT License.