LiveViewReact renders the same component registry on the server and in the
browser. The BEAM adapter sends one mandatory flat transport-v2 frame:
version, component, identifierPrefix, props, streams, events, and
slots. props, streams, events, and slots are required even when they
are empty. The prefix comes from the required React root ID and is reused by
renderToString, hydrateRoot, and createRoot, so React 19 useId() values
remain stable.
Installer-generated entries
mix igniter.install liveview_react creates
assets/js/liveview_react_server.tsx with the canonical server entry:
import components from "virtual:liveview-react/components";
import { createLiveViewReactServer } from "liveview_react/server";
import type { ServerRenderRequest } from "liveview_react/server";
const server = createLiveViewReactServer({ components });
export function render(request: ServerRenderRequest): Promise<string> {
return server.render(request);
}The generated browser entry imports the same
virtual:liveview-react/components module. Do not maintain a separate
server-only registry: a component that resolves differently between SSR and
the browser cannot hydrate correctly.
The virtual registry eagerly discovers default-exported .js, .jsx, .ts,
and .tsx components below assets/react-components. See
Installation for its naming and validation rules.
Vite development SSR
The installer adds React and LiveViewReact to the existing PhoenixVite config. The relevant fragment is:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import liveViewReactPlugin from "liveview_react/vite";
export default defineConfig({
plugins: [
// Preserve the existing PhoenixVite and Tailwind plugins.
react(),
liveViewReactPlugin({
entrypoint: "./js/liveview_react_server.tsx",
}),
],
});The plugin factory is also a named export:
import { liveViewReactPlugin } from "liveview_react/vite";Its complete option surface is:
| Option | Default | Contract |
|---|---|---|
componentDirectory | ./react-components | Registry directory inside the Vite root; no symlink traversal |
entrypoint | ./js/server.ts | Module loaded by Vite development SSR; must export render |
maxBodyBytes | 1_048_576 | Positive safe-integer request limit |
path | /ssr_render | Absolute development endpoint without query or fragment |
The built-in LiveViewReact.SSR.ViteJS adapter posts to /ssr_render; keep the
default path when using that adapter. A custom endpoint requires a matching
custom BEAM SSR module.
The plugin serves the SSR endpoint through the running Vite development server and resolves the virtual registry. The installer also adds this development configuration:
config :liveview_react,
ssr: true,
ssr_module: LiveViewReact.SSR.ViteJS,
vite_host: "http://localhost:5173"PhoenixVite starts Vite with the Phoenix endpoint. Requests require
Content-Type: application/json with at most one optional charset parameter
and are bounded by the configured size and BEAM timeouts. Invalid requests use
specific 4xx responses. Unexpected renderer failures are logged with detail by
Vite but return only a generic 500 body to the BEAM client.
The endpoint authenticates neither callers nor individual render requests. It is a development transport between Phoenix and Vite, not a public SSR API. Bind the Vite server to loopback or a trusted private development network and do not expose this route to the Internet. The body limit and schema validation bound input; they are not authentication or authorization controls.
A configured Vite renderer fails explicitly when Vite is unreachable, the component is missing, or the renderer raises; it does not silently switch to client rendering.
If the Igniter installer is not used, the application must create the browser and server entry points, virtual-module declaration, Vite plugin entries, and development configuration itself. That manual setup does not receive the installer's source-aware merge or idempotency checks.
Node.js production SSR
The installer creates assets/vite.liveview-react.ssr.config.mjs and the
build:ssr package script. The dedicated config bundles the server entry and
writes the canonical ESM output to:
priv/liveview_react/server.mjsBuild it with npm from the assets directory:
cd assets
npm run build:ssr
For an installation created with --bun, run the configured Bun profile from
the Phoenix application root:
mix bun assets run build:ssr
The normal mix assets.build and mix assets.deploy aliases build the browser
bundle; they do not replace the separate SSR build.
Add the optional NodeJS dependency when production SSR is enabled:
defp deps do
[
{:liveview_react, "~> 1.0"},
{:nodejs, "~> 3.1"}
]
endStart its supervisor from the application supervision tree:
children = [
{NodeJS.Supervisor,
[path: LiveViewReact.SSR.NodeJS.server_path(:my_app), pool_size: 4]}
]Pass the OTP application that owns the generated SSR bundle. server_path/1
resolves that application's priv directory without relying on the calling
process:
LiveViewReact.SSR.NodeJS.server_path(:my_app)NodeJS module paths are relative to that supervisor path, so configure the
renderer as follows even though the source-tree file is physically located at
priv/liveview_react/server.mjs:
config :liveview_react,
ssr: true,
ssr_module: LiveViewReact.SSR.NodeJS,
ssr_filepath: "./liveview_react/server.mjs"The release image must contain a supported Node.js runtime. See Deployment for npm and Bun build sequences.
Rendering contract
In the LiveViewReact.SSR namespace, NotConfigured means SSR infrastructure
is absent and lets the root continue as client-only output. This includes no
configured module, missing optional NodeJS code, or an absent NodeJS
supervisor. RenderError means an available renderer failed; it is not
silently converted to client rendering. Missing components, invalid requests,
timeouts, and renderer exceptions use this failure path. Both exception types
are BEAM APIs; the browser root callbacks described in
Component API are separate React 19 client errors.
The production adapter calls the configured ESM bundle's render export once,
with the normalized request as its only argument, and asks NodeJS for one
binary result. LiveViewReact.SSR.render/1 rejects a non-binary result. Vite
development SSR mirrors that request/reply shape over HTTP. Its BEAM timeouts
bound how long the caller waits; they do not constitute a cross-runtime React
abort contract.
The renderer returns one complete HTML string. React 19 resource hints emitted
with preload or preloadModule remain inside that string; LiveViewReact does
not split, move, or reinterpret them. Dead-render HTML and its immutable v2
descriptor stay inside the React-owned target until the browser hydrates the
exact same component, provider, ordinary props, materialized stream props,
slots, event commands, and identifier prefix. If the connected join already
contains newer state, the runtime queues it and applies it only after the exact
dead-render tree commits hydration.
On successful SSR, the full dead stream snapshot is serialized once in the hydration descriptor. The outer stream lane is marked as hydration-owned and carries no second copy. When SSR infrastructure is absent or SSR is disabled, there is no descriptor and the outer lane carries the authoritative connected stream snapshot used by the client-only root. A configured renderer failure is still fatal and never switches silently to that client-only path.
The current renderer deliberately uses
renderToString.
A component that suspends during SSR renders its nearest Suspense fallback.
React does not wait for the suspended content, and LiveViewReact does not claim
or emulate streaming.
Streaming SSR decision
Status: explicitly deferred. Buffered component SSR remains the production contract. This is an architectural decision, not a missing call to a newer React function.
React's streaming APIs target an HTTP response owner. On Node.js,
renderToPipeableStream
returns pipe and abort; the Web Streams equivalent,
renderToReadableStream,
accepts an abort signal and exposes allReady. They can emit a Suspense shell,
later content, and inline replacement scripts progressively. They also divide
failures into shell failures and recoverable errors after the shell, and an
HTTP status can no longer change after streaming starts.
That response model does not fit the current boundary:
| Concern | Buffered contract today | Requirement for real streaming |
|---|---|---|
| Output | JavaScript returns Promise<string>; LiveViewReact.SSR accepts only a binary() | A cancellable stream handle and explicit shell, chunk, completion, and error phases |
| Suspense | The closest fallback is included in the final string | Progressive fallback replacement, including React's inline scripts |
| Backpressure | Vite and NodeJS collect one result before HEEx embeds it | React's source must remain connected to the final HTTP sink; Node stream piping manages flow only across that live connection |
| Abort and timeout | Vite bounds the BEAM HTTP wait; the renderer contract has no abort handle | Client disconnect and render timeout must abort React, close the transport, and release the Node worker |
| Errors | A renderer either returns the complete string or raises RenderError | Pre-shell failure may choose status or fallback; post-shell failure cannot replace the response and needs separate telemetry and client recovery |
| Hydration | The complete target and immutable descriptor exist before hydrateRoot | The descriptor, identifierPrefix, bootstrap ordering, CSP nonce policy, LiveView join, and late chunks need one race-free state machine |
| Deployment | One ESM render export crosses Vite HTTP or a NodeJS request/reply call | Development and production need the same long-lived stream protocol, bounded buffering, cancellation, and cleanup |
Phoenix LiveView's lifecycle
starts with a regular HTTP render and then establishes the stateful connection.
<.react> contributes a nested target while that HEEx response is assembled;
it does not own the Plug.Conn. Plug can
send_chunked/2 and
then send chunks, but that must happen at the response owner. Collecting a
React stream back into a string would preserve the current boundary but would
not provide progressive delivery or end-to-end backpressure.
prerenderToNodeStream
is a possible future experiment when the requirement is to wait for Suspense
data rather than reveal it progressively. React classifies it as static
generation: it waits for data before resolving, supports abort, and can be
collected into a string. It is not a drop-in streaming upgrade and is not the
default for LiveView-owned component snapshots.
Migration boundary
Any future streaming implementation must be a separate response mode, not a
union return added to render/1:
- Preserve the validated v2 request frame and shared component-tree builder,
including
version,component,identifierPrefix,props,streams,events, andslots. - Keep the current
render/1 -> binary()adapter for nested component SSR. Put streaming behind a dedicated Phoenix initial-response integration that owns thePlug.Connand can propagate backpressure. - Define
before_shell,streaming,complete, andabortedphases. Only a pre-shell failure may becomeRenderErrorwith a replacement response; later errors require telemetry and React's documented client recovery. - Propagate request timeout, browser disconnect, navigation, and target destruction to React's abort mechanism and release the Vite or Node worker exactly once.
- Make the hydration descriptor and CSP/bootstrap policy available before the first byte. Do not apply connected updates or replace the target until the stream and hydration state machine declares that operation safe.
- Require equivalent development and production protocols and verify slow consumers, abort cleanup, pre- and post-shell errors, CSP, hydration, join, navigation, and destroy races before enabling the mode.