Server rendering

Server rendering

Render components to HTML on the server and hydrate them in the browser, with the same code on both sides.

Setup

bash
npm install @velozjs/dom @velozjs/ssr
npm install -D vite vite-plugin-veloz

With vite-plugin-veloz, your imports of @velozjs/dom resolve to @velozjs/ssr during SSR (in dev with ssrLoadModule and in vite build --ssr), so components don't need to know where they run.

Server entry

src/entry-server.tsx
import { renderToString } from '@velozjs/ssr';
import { App } from './App';

export function render(url: string) {
  return renderToString(() => <App url={url} />); // { html, css }
}

HTML shell

index.html
<!doctype html>
<html lang="en">
  <head>
    <!--ssr-css-->
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script type="module" src="/src/entry-client.tsx"></script>
  </body>
</html>
server.js
const { html, css } = render(req.url);
res.end(
  template
    .replace('<!--ssr-css-->', `<style data-veloz-css>${css}</style>`)
    .replace('<!--ssr-outlet-->', html)
);

Client entry

src/entry-client.tsx
import { hydrate } from '@velozjs/dom';
import { App } from './App';

hydrate(() => <App url={location.pathname} />, document.getElementById('app')!);

Async data

renderToString is synchronous: resources that are still loading render their Suspense fallback. To send the page with its data, use renderToStringAsync:

ts
import { renderToStringAsync } from '@velozjs/ssr';

const { html, css, script } = await renderToStringAsync(() => <App url={req.url} />, {
  timeout: 5000,
});

res.end(
  template
    .replace('<!--ssr-css-->', `<style data-veloz-css>${css}</style>`)
    .replace('<!--ssr-outlet-->', html + script) // the script must run before your client entry
);

It renders, waits for the pending fetches, and renders again with their values, repeating when one request depends on another. script sets the values for hydrate(), which starts every resource from them instead of fetching.

OptionDefault
timeoutnonerejects if the data takes longer (ms)
maxPasses10limit for chains of dependent requests
noncenoneCSP nonce for the data script

Values must be JSON-serializable; failed loads are sent as their error message. The JSON is escaped so it can't break out of the <script>.

How hydration works

  • The server gives each element a data-hk key in creation order and wraps every dynamic region in <!--[-->…<!--]--> comments.
  • hydrate() indexes the keys inside its container once, then creates the same tree: elements are reused instead of created, events and effects are attached, and the keys are removed.
  • Dynamic regions attach to their markers, so static text next to dynamic text is kept as the server sent it.
  • Hydration is scoped to the container, so several independent roots on one page work. This site does that: every interactive part is its own small root.

What differs on the server

  • createEffect and onMount don't run (they're for browser side effects), so code inside them can use window and document freely. Memos and resources do run. Event handlers aren't printed.
  • innerHTML is not printed (there is no DOM sanitizer on the server); the client sets it, sanitized, during hydration.
  • Portal renders nothing; its content is created on the client.
  • renderToStream() returns the same output as a byte stream you can pass to new Response().

Static sites

The same API prerenders pages at build time: render each route with renderToString (or renderToStringAsync) and write the HTML to disk. This site is built that way, with only its interactive parts hydrated.