Under the hood
Server rendering and hydration
How the server target produces HTML, how the hydrate target attaches to it, and what has to match between them.
The compiler generates server and hydration output from the same component representation. Both targets use ownership markers to agree on the boundaries of components, branches, and lists.
The server target
Compiling with target: 'server' produces code that walks the component's structure once and writes HTML. Text and attribute values are escaped. Where the client will need to find a dynamic region, a conditional branch, a list, a component's range, the server writes comment markers around it.
Server code has no browser globals. A component that touches window or document during construction throws on the server; move that access into an effect, which does not run on the server.
Effects do not run. Refs are not attached. useLayoutEffect is a no-op. useId produces deterministic IDs from the root's prefix so the client can produce the same ones. useSyncExternalStore uses getServerSnapshot.
Portals are rejected on the server, since there is no target node to render into.
The hydrate target
Compiling with target: 'hydrate' produces code that expects the server's HTML to already be in the document. Instead of creating elements, it walks the existing nodes in the same order the server wrote them, claims each one, and attaches bindings and listeners. Comment markers tell it where each range begins and ends.
When it finds something unexpected, a missing node, extra text, an attribute that differs, it repairs the DOM to match what the client would have rendered and reports the mismatch through onRecoverableError. Repair is best-effort; the report is what you should act on.
After hydration completes, the component is indistinguishable from one mounted on the client: same owners, same updaters, same disposal.
What has to match
The server and hydrate builds are compiled separately. They must agree on:
- Compiler version. The marker format is part of the compile-to-runtime protocol. Server and client builds from different versions refuse to hydrate.
- Feature flags. A feature changes what the compiler emits for the affected code. Use the same
featureslist for both builds. - Initial data. Every value the component reads during construction must be the same on both sides. Vidact Start guarantees this for loader data by serializing it into the page.
- Determinism. No
Math.random(), noDate.now(), no reads of browser state during construction.
Data transfer in Vidact Start
Start runs loaders on the server, renders with their results, and embeds a JSON snapshot in a <script type="application/json">. In the browser, hydrateStart reads that script, matches the same routes, and constructs the components with the same loaderData. Other inputs, such as time or random values read in the component, still need to be made deterministic.
The snapshot is a closed value model: plain data only, checked on both ends. Its checksum detects corruption and version skew. It is not a security boundary; loaders must not put secrets in it, because it is sent to the browser verbatim.
Streaming and continuations
With the framework feature, the server target supports renderToReadableStream and renderToPipeableStream. The current renderer waits for pending application resources before publishing the final application HTML; it does not stream individual Suspense fallbacks. prerender and resume provide separate APIs for a static prelude and postponed work.
Vidact Start sends the opening document shell after loaders settle, then the application HTML, then the loader snapshot and client entry. Customize this with renderDocumentShell. The older renderDocument callback receives the complete application string and buffers it. See the Start reference for error and cancellation behavior.
These APIs share names with React's, and their observable behaviour is designed to match, but the wire format is Vidact's own. They do not consume or produce React Flight payloads.
Why one compiler, three targets?
Generating all three targets from one IR keeps dependency and ownership analysis shared. Target-specific restrictions still apply: a client component that uses a portal or reads browser globals during construction cannot render unchanged on the server. Build and test both targets.