React
bext-lite renders two ways, and you choose per page:
- String-builder PRISM (the default). JSX is compiled server-side to a plain CJS bundle with no React in the isolate. The polyfills are pure JS, so the bundle is engine-neutral and renders byte-identically on QuickJS and V8. This is the right default for content and most UI.
- React (
"use client"). A page marked"use client"is compiled ahead of time into a real React SSR bundle -bun-built, runningReactDOMServer.renderToString- that executes on QuickJS at serve time, souseStateandcreateElementhave a genuine runtime.
Both compile to native bundles and both render byte-identical to what the full
bext server produces for the same route. Reach for React when a page genuinely
wants React component state at render time; otherwise the string-builder path is
smaller and needs no bun.
Marking a page React#
Put "use client" at the top of the page module. The exporter's is_client
branch detects it and routes that page through the React AOT build instead of the
string-builder path:
"use client";
import React, { useState } from "react";
export default function InteractivePage({ searchParams }: { searchParams: any }) {
const [count] = useState(Number(searchParams?.start) || 0);
return <output id="count">{count}</output>;
}
The concrete, working reference is the device-playground /interactive route: a
"use client" page whose useState seed is read from ?start=NN and evaluated
during the QuickJS SSR render, so the server-rendered markup visibly reflects real
React state.
What the AOT build does#
Inlining React into every React route would bloat each bundle by ~0.5 MB, so the exporter splits it:
react-base.js, built once.bun builds the React runtime into one shared IIFE that installs React on globals (globalThis.React,__ReactDOMServer, the jsx-runtime helpers). Its manifest-relative path is recorded asreactBase.- A small per-route bundle. Each
"use client"page is built with React externalized, so itrequire("react")s the shared base rather than carrying its own copy. A single React instance across all routes means no two-React "Invalid hook call".
At serve time the runtime evals, in this load-bearing order, a small server-DOM
preamble (stub document / navigator / location and a microtask-backed
MessageChannel that react-dom's scheduler grabs at init) -> react-base.js ->
the route bundle. The preamble and base are our own trusted code, evaluated before
the untrusted-tenant render deadline arms. Then __bextPrismRender calls
renderToString and returns HTML.
This runs byte-faithfully on QuickJS. React and ReactDOMServer are pure JS; the
V8 startup snapshot the full server uses is only a warm-start optimization, not a
correctness dependency - its own documented fallback is a full eval, which is
exactly what QuickJS does. React 19 SSR was verified under QuickJS with props
threaded and useState initial state intact.
Supported versus deferred#
| Feature | Status |
|---|---|
ReactDOMServer.renderToString |
Supported - the render entry for a React route. |
useState (initial state at SSR) |
Supported - real React state, evaluated in the render. |
Props / searchParams / params threading |
Supported. |
| Nested layouts | Deferred. |
| Suspense streaming SSR | Deferred - bext-lite renders fully then serves, buffered not streamed. |
The buffered-not-streamed choice is deliberate: Suspense streaming (the async-iterator path) is the riskiest sub-feature to carry across two engines and is unnecessary for a single-tenant local app. Rendering fully and then serving is correct for single-tenant and sidesteps the one fragile corner of React-on-QuickJS. See The engine decision.
Client hydration#
bext-lite serves a "use client" page as server-rendered HTML - there is no
full-page client React bundle shipped for it. Live interactivity is layered on the
same way the rest of the runtime does it: the free, pure-browser island loader,
plus any inline enhancement script the page ships.
The /interactive example demonstrates the pattern end to end. React renders the
counter and toggle on QuickJS to real HTML; a small inline script then wires the
buttons to live DOM updates and flips a status pill from "server-rendered" to
"live in browser", which is how you can tell the document hydrated and the shipped
JS ran. The island hydration runtime is already pure browser JS with zero server
coupling, so it runs unchanged in a browser or a WebView at no extra cost - which
is what lets the mobile "static export + islands + local SQLite" model sidestep
on-device rendering almost entirely. See
Architecture.
When a React page cannot compile#
A "use client" page has no working non-React bundle - the string-builder path
cannot run useState / createElement. So a React compile failure, or an absent
toolchain (bun / react / vendored node_modules missing), is a hard error by
default: the export aborts for that route rather than silently shipping a
known-broken bundle.
# App with React pages, using the repo's vendored React:
BEXT_LITE_VENDOR_NODE_MODULES="$PWD/vendor/node_modules" \
bext-lite-build ./app -o ./dist
The escape hatch is BEXT_LITE_ALLOW_DEGRADED_CLIENT=1, which ships a
string-builder fallback tagged renderer:"degraded" in the manifest instead of
failing. It is loud and marked so the runtime and operator can see the bundle is
not a working React render - useState / createElement will not run. Use it
only to unblock a build you cannot yet give a React toolchain; do not ship it as
the real thing.
Provide the toolchain via Configuration:
BEXT_LITE_VENDOR_NODE_MODULES (a node_modules with react / react-dom /
scheduler) unless the site vendors its own React, and BEXT_LITE_BUN to point at
a specific bun. A string-builder-only app needs none of this.
Related#
- The engine decision - why React renders correctly on QuickJS
- Configuration - the React toolchain env vars and the reactBase manifest field
- Architecture - where render and the client islands sit
- CLI reference - the exporter and its React env vars