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, running ReactDOMServer.renderToString - that executes on QuickJS at serve time, so useState and createElement have 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:

  1. 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 as reactBase.
  2. A small per-route bundle. Each "use client" page is built with React externalized, so it require("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.