Examples

The repo ships one runnable example app, device-playground, that exercises the whole bext-lite stack in a single demo: server-rendered string-builder pages, a "use client" React page, on-device KV data, and the native device SDK with graceful off-device degradation. It runs on the same offline-first QuickJS runtime that embeds into an iOS or Android shell, and it is served headless by bext-lite serve so you can open it in a browser today.

The source is at crates/bext-lite/examples/device-playground (its README.md has a route-by-route table and the embedding notes).

If you have not built the binaries yet, do Installation first. The commands below assume bext-lite-build and bext-lite are on your PATH and that you are at the root of the bext monorepo.

Run it#

Two commands: export, then serve. The example has "use client" React routes, so the export runs a real React SSR build. Point it at the vendored React and the shared framework:

BEXT_LITE_VENDOR_NODE_MODULES="$PWD/vendor/node_modules" \
BEXT_SHARED_FRAMEWORK_DIR="$PWD/sites/shared/framework" \
  bext-lite-build crates/bext-lite/examples/device-playground -o /tmp/dp
  • BEXT_LITE_VENDOR_NODE_MODULES points the React AOT build at the repo's vendored react / react-dom / scheduler. The exporter symlinks them into the app's node_modules for the bun build, then removes them again.
  • BEXT_SHARED_FRAMEWORK_DIR resolves the @bext-stack/framework string-builder runtime import that the server-rendered pages use.

Then serve the exported bundle:

bext-lite serve /tmp/dp --port 8080

Open http://127.0.0.1:8080/ or curl the routes.

Note

A string-builder-only PRISM app (no "use client" React pages) needs neither env var and no bun - the export is just bext-lite-build ./app -o ./dist. Both env vars are only here because this example includes React routes. See Quickstart.

A tour of the routes#

/ - the overview (string-builder)#

src/app/page.tsx is a pure string-builder page: no "use client", no React, no client JS. It compiles to a __bextPrismRender string bundle that runs on QuickJS with no React runtime, and the root layout (src/app/layout.tsx) wraps it in the full document shell - doctype, <head> with the page's metadata title, and the island loader. Everything the visitor sees is server-rendered HTML.

curl -s http://127.0.0.1:8080/            # overview page, a full HTML document

/interactive - React, ahead of time#

src/app/interactive/page.tsx is a "use client" React page. The exporter builds a real React bundle and runs ReactDOMServer.renderToString on QuickJS at serve time, so useState has a genuine runtime under the interpreter. Seed the counter from the query string and the server-rendered markup reflects it:

curl -s "http://127.0.0.1:8080/interactive?start=42"   # React SSR renders 42

Live interactivity is layered on with a small inline enhancement script - the same progressive-enhancement model as the island loader. A status pill starts as server-rendered and flips to live in browser once the browser runs the shipped JS, which is how you can tell the page hydrated. For the two interactivity styles, see React and Islands.

/device - the native device SDK#

src/app/device/page.tsx is a "use client" page whose buttons post to /__bext/sdk/device/* wire paths - the exact channel the bext.device.* client uses - and print the raw { ok, status, body } envelope. Served headless there is no native bridge installed, so every device call resolves ok:false, status:501:

curl -s -X POST http://127.0.0.1:8080/__bext/sdk/device/app/version \
  -H 'content-type: application/json' -d '{}'
# -> { ... "status": 501 ... }   (no bridge off-device)

That 501 is the feature, not a failure. Inside the iOS or Android shell the same buttons return real camera, Face ID, and GPS results with status:200; the app source does not change, only the runtime underneath it does. See Device SDK for the surface and Mobile for the bridge.

/notes and /api/notes - the on-device KV round-trip#

src/app/notes/page.tsx is a string-builder page with a form and a list. It reads and writes through the app's own /api/notes route (src/app/api/notes/route.ts), which persists to on-device KV (SQLite) via an in-process /__bext/sdk/kv/* call. GET lists, POST adds, DELETE clears - so it exercises kv.list / kv.get / kv.set / kv.delete end to end:

curl -s http://127.0.0.1:8080/api/notes                     # []  (empty KV)

curl -s -X POST http://127.0.0.1:8080/api/notes \
  -H 'content-type: application/json' -d '{"text":"hello"}'  # add a note

curl -s http://127.0.0.1:8080/api/notes                     # now lists the note

The note survives across requests because it is written to SQLite: this is the offline-first data path, with no network and no external database.

The client talks to /api/notes, never to KV directly, on purpose. The data SDK is not exposed over HTTP - a direct POST /__bext/sdk/kv/get from a browser returns 404 by design - so a tenant's data can never leak. Only /api/* is HTTP-reachable, and it touches KV server-side where it belongs. See Data and storage.

What works headless versus in the shell#

Everything here runs headless under bext-lite serve - that is the point of the demo. The one thing that behaves differently off-device is the device bridge:

Surface Headless (bext-lite serve) Inside the iOS / Android shell
/, /interactive, /notes Fully working Same
/api/notes + on-device KV Fully working (SQLite in the data dir) Same, on the device's writable data dir
/device buttons ok:false, status:501 (no bridge) ok:true, status:200 with real results

Check it before you ship#

bext-lite check renders every route once under QuickJS and runs a static policy scan, then tells you whether the app is publishable:

bext-lite check /tmp/dp

A clean run ends with => PUBLISHABLE (lite-eligible + policy-clean).

Ship it to a device#

The same dist-lite/ is what a mobile app ships. Scaffold the shell projects, then build and run on a simulator or device:

bext-lite mobile init                       # scaffolds ios/ and android/ shells
bext-lite mobile run ios --dist /tmp/dp

On a machine without the platform toolchain, mobile run stops cleanly at a toolchain guard that prints exactly what is missing, and exits non-zero. The shell copies dist-lite/ in as an app resource, boots the same embedded bext-lite server, installs a native bridge that answers the device/* namespace, and points a WebView at it. See bext-lite mobile and Mobile.

  • Quickstart - the same two-command flow, explained step by step
  • Islands - the free client runtime the demo uses for liveness
  • Device SDK - the bext.device.* surface /device calls
  • Data and storage - the KV path behind /notes