a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1# Server-Side Rendering
2
3VoltX can render HTML on the server and hydrate it on the client so that the initial paint is fast and SEO-friendly without sacrificing interactivity.
4
5## When SSR Helps
6
7- Marketing and content-heavy pages that rely on search indexing.
8- Dashboards that must show current data immediately on first paint.
9- Progressive enhancement flows where the page should work without JavaScript.
10- Latency-sensitive experiences served to slow devices or connections.
11
12## When CSR Is Enough
13
14- Highly interactive applications dominated by client-side state.
15- Authenticated surfaces hidden from crawlers.
16- Rapid prototypes where deployment speed outweighs initial paint.
17- Workloads where duplicating rendering logic on the server adds complexity without user benefit.
18
19## Rendering Flow
20
211. Render HTML on the server and embed serialized state.
222. Ship that HTML to the browser.
233. Call `hydrate()` to attach VoltX bindings without re-rendering.
24
25### Produce Markup on the Server
26
27Use `serializeScope()` to convert reactive state into JSON before embedding it in the HTML you return:
28
29```ts
30import { serializeScope, signal } from "@volt/volt";
31
32export function renderCounter() {
33 const scope = {
34 count: signal(0),
35 label: "Visitors",
36 };
37
38 const serialized = serializeScope(scope);
39
40 return `
41 <div id="counter" data-volt>
42 <script type="application/json" id="volt-state-counter">
43 ${serialized}
44 </script>
45
46 <h2 data-volt-text="label">${scope.label}</h2>
47 <button data-volt-on:click="count++">Clicked <span data-volt-text="count">${scope.count.get()}</span> times</button>
48 </div>
49 `;
50}
51```
52
53Guidelines:
54
55- Every server-rendered root must have an `id`. VoltX looks for `<script id="volt-state-{id}">` next to it.
56- The script tag must use `type="application/json"` and contain valid JSON. Pretty printing is fine; whitespace is ignored.
57- Keep serialized data minimal. Fetch large collections after hydration.
58
59### Send HTML to the Client
60
61The HTML you return should already contain the serialized state script tag. VoltX will reuse the DOM structure; do **not** re-render the same tree on the client.
62
63### Hydrate in the Browser
64
65Call `hydrate()` once the page loads. It discovers `[data-volt]` roots automatically.
66
67```ts
68import { hydrate } from "@volt/volt";
69
70document.addEventListener("DOMContentLoaded", () => {
71 hydrate({
72 rootSelector: "[data-volt]",
73 skipHydrated: true, // defaults to true; repeat calls ignore already hydrated roots
74 });
75});
76```
77
78If you only hydrate a specific block, pass a narrower selector or manually select elements and call `hydrate({ rootSelector: "#counter" })`.
79
80## Serialized State
81
82VoltX exposes helpers that mirror the runtime's internal behavior:
83
84| Helper | Action |
85| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
86| `serializeScope(scope)` | Converts signals into their raw values before you embed them |
87| `deserializeScope(data)` | Restores a JSON payload into a fresh scope. Useful for streaming responses or server actions that return HTML partials |
88| `isServerRendered(element)` | Tells you if VoltX found a matching serialized state block |
89| `isHydrated(element)` | Detects whether a root has already been hydrated, which is handy when mixing SSR content with dynamic client mounts |
90| `getSerializedState(element)` | Reads the JSON payload for debugging or custom hydration flows. |
91
92```ts
93import { deserializeScope, getSerializedState } from "@volt/volt";
94
95const root = document.querySelector("#counter")!;
96const state = getSerializedState(root);
97
98if (state) {
99 const scope = deserializeScope(state);
100 console.log(scope.count.get()); // -> 0
101}
102```
103
104## Avoiding Flash of Unstyled Content
105
106Hydrated markup should look identical before and after VoltX runs. When CSS or font loading causes flicker, consider these patterns:
107
108### Hide Until Hydrated
109
110```html
111<style>
112 [data-volt]:not([data-volt-hydrated]) {
113 visibility: hidden;
114 }
115
116 [data-volt][data-volt-hydrated] {
117 visibility: visible;
118 }
119</style>
120```
121
122VoltX sets `data-volt-hydrated="true"` once `hydrate()` completes, so you can safely reveal content at that point.
123
124### Use a Loading Overlay
125
126```html
127<div id="app" data-volt>
128 <!-- server-rendered content -->
129</div>
130<div class="loading-overlay">Loading…</div>
131
132<script type="module">
133 import { hydrate } from "@volt/volt";
134
135 hydrate();
136</script>
137```
138
139```css
140.loading-overlay {
141 position: fixed;
142 inset: 0;
143 background: white;
144 display: grid;
145 place-items: center;
146}
147
148[data-volt-hydrated] ~ .loading-overlay {
149 display: none;
150}
151```
152
153### Progressive Enhancement
154
155Render functional HTML that works without JavaScript, then let VoltX enhance it:
156
157```html
158<form id="contact" method="POST" action="/submit" data-volt>
159 <script type="application/json" id="volt-state-contact">
160 { "submitted": false }
161 </script>
162
163 <input type="email" name="email" required />
164 <p data-volt-if="submitted" data-volt-text="'Thank you!'"></p>
165 <button type="submit">Submit</button>
166</form>
167```
168
169## Security Checklist
170
171- Escape user-generated content in the HTML you render.
172- Validate JSON before embedding it in `<script type="application/json">` tags.
173- Apply a strict Content Security Policy (CSP) so inline scripts are controlled.
174- Never serialize secrets. Treat the hydrated payload as public data.
175
176Pair these guidelines with the lifecycle hooks documented in [lifecycle](./lifecycle) to coordinate mount-time work across SSR and client renders.