PRISM
PRISM is bext's native framework. JSX compiles to direct HTML strings
via the h() runtime — there is no React on the server, no virtual
DOM, no diffing. A build-time compile pass shipped in bext-core
folds whole component trees into single string concatenations before
any runtime work happens.
The combination puts a 50-row Tailwind table at 77 µs per render
(release-mode V8), vs. 2,835 µs for the same DOM rendered by
React 19 — about 40× faster, with 3.5× tighter p95 because
the rendered code is just "<...>" + (x) + "<...>" and never sees
React's reconciler.
The acronym is decorative. Read it as "the way bext renders TSX on the server" and move on.
Quickstart
A minimal PRISM site:
mkdir my-site && cd my-site
mkdir -p src/app
src/app/page.tsx:
export default function HomePage(props: {
searchParams?: { name?: string };
}) {
const name = props.searchParams?.name ?? "world";
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>my-site</title>
</head>
<body>
<h1>Hello, {name}!</h1>
<p>Rendered by PRISM (no React on the server).</p>
</body>
</html>
);
}
bext.config.toml:
[server]
port = 3088
app_dir = "."
[framework]
type = "prism"
tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@bext-stack/framework"
}
}
package.json:
{
"dependencies": {
"@bext-stack/framework": "workspace:*"
}
}
Run:
bext run .
That's the whole framework. No package.json build scripts, no
vite.config.ts, no next.config.mjs. The first request triggers a
per-route compile (30-200 ms cold); every request after is a warm
V8 isolate with cached page contexts (1-5 ms render).
How it works
The h() runtime
PRISM's JSX runtime lives at @bext-stack/framework/jsx-runtime. It
exports jsx, jsxs, Fragment, and (since 2026-04-29) escapeHtml.
TypeScript's react-jsx mode auto-imports jsx/jsxs at every JSX
call site.
// What you write:
<div className="card">
<h2>{title}</h2>
</div>
// What tsc-rs emits (when no compile pass runs):
jsx("div", { className: "card", children: jsx("h2", { children: title }) })
// What h() does:
function h(tag, props, ...children) {
if (typeof tag === "function") return tag({...props, children});
const attrs = formatAttrs(props); // " class=\"card\""
if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs}>`;
const flat = flatten(children);
return `<${tag}${attrs}>${flat.join("")}</${tag}>`;
}
Three properties make this tractable:
Strings flow through unchanged —
h()joins children with empty string, no escape. This is what allows the compile pass to pre-build subtree HTML and slot it directly into a parent's children array.Async path is
AsyncIterable<string>— when any child is aPromiseorAsyncIterable,h()yields chunks: open tag, then each child resolved, then close tag. The streaming protocol in@bext-stack/framework/streamingbuilds Suspense on top of this primitive.formatAttrsis the only escape boundary — attribute values are HTML-escaped, expression children are not. See HTML escaping for what this means in practice.
The compile pass
When [framework] type = "prism" is set, bext-turbopack's transform
pipeline runs prism_compile.rs against every .tsx/.jsx file in
the route's transitive closure. The pass is implemented as an SWC-style
visitor over tsc_rs_ast (no swc dep — uses bext's own TypeScript
parser).
What it folds:
| Pattern | Output |
|---|---|
<head><meta charSet="utf-8"/></head> |
"<head><meta charset=\"utf-8\"></head>" |
<h1>Hello, {name}!</h1> |
"<h1>Hello, " + (name) + "!</h1>" |
<div className={cond ? "a" : "b"}> |
'<div class="' + __bextEsc(cond ? "a" : "b") + '">' |
<ul>{items.map(i => <li>{i}</li>)}</ul> |
"<ul>" + (items).map((i) => "<li>" + (i) + "</li>").join("") + "</ul>" |
| `` (zero-prop) | recursively inlines Header's body |
| `` (destructured param) | inlines body with prop substitution |
Recursive: a parent containing a child component containing a
.map() containing a ternary class — all collapse to one concat
expression. On a typical Tailwind page, 0 of 11+ jsx() calls
remain in the compiled bundle.
The pass is default-on and safe-by-construction: anything it
can't statically prove safe falls through to runtime jsx(). Bails
on:
- Spread attrs (<div {...rest}>)
- Components with Ident-style param
function Card(props)— only destructuredfunction Card({ a, b })is supported -props.childrenreferences — could beAsyncIterable<string>, string-coerce would render[object AsyncGenerator]-style={{...}}anddangerouslySetInnerHTML={{...}}— runtime applies object semantics - Component calls with children ({slot}) - Block-bodied arrows with side effects in.map()callbacks - Async / generator functions
Opt out with BEXT_PRISM_COMPILE=0 in the environment. Useful for
diffing builds; unnecessary in normal use.
See the compile pass doc for full details.
File-system routing
src/app/
page.tsx → /
about/page.tsx → /about
blog/[slug]/page.tsx → /blog/:slug (dynamic param)
shop/[...rest]/page.tsx → /shop/* (catch-all)
api/users/route.ts → /api/users (HTTP API)
islands/Counter.tsx → /islands/Counter.js (browser bundle)
components/MyComp.tsx → not routed (server-only library)
Layouts wrap pages by directory:
src/app/
layout.tsx ← root layout (renders for every route)
blog/
layout.tsx ← nested, wraps every blog/*
page.tsx ← /blog
[slug]/
page.tsx ← /blog/:slug
The dispatcher composes layouts inside-out:
Layout0(Layout1(Layout2(Page(props)))). Each layout receives
children as its first prop, plus the full request context.
Server actions
PRISM exposes a Remix-style mutation pattern: post a <form> to
/_bext/action/<exportName>, dispatch via the colocated actions.ts:
// src/actions/auth.ts
"use server";
export async function login(req: Request): Promise {
const body = await req.formData();
// ... auth logic ...
return new Response(null, {
status: 302,
headers: { Location: "/dashboard", "Set-Cookie": "session=..." },
});
}
// somewhere in JSX
<form method="post" action="/_bext/action/login">
<input name="email" />
<input name="password" type="password" />
<button>Sign in</button>
</form>
The dispatcher walks src/actions/*.ts, finds files starting with
"use server", parses their exports. Any POST /_bext/action/<exportName>
is dispatched to the matching function. See
PRISM data conventions for the loader/action
conventions co-located with route files.
Islands
For interactivity, mark a component with "use client":
// src/islands/Counter.tsx
"use client";
export default function Counter({ start = 0 }: { start?: number }) {
const [n, setN] = useState(start);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Use it from a server component:
export default function Page() {
return (
<main>
</main>
);
}
The server renders a <bext-island> placeholder; the client loader
fetches /islands/Counter.js, parses props from data-props, and
calls the component's mount(el, props). Islands can use any
client-side framework (React, Preact, Solid, vanilla DOM) — the
server doesn't care, it just ships the bundle.
Performance
Numbers from the jsx-shootout harness on a 38 KB product-listing page (Node 25, release mode):
| Engine | avg | p95 | Throughput |
|---|---|---|---|
| Rust string builder (ceiling) | 6 µs | 7 µs | 196K/s |
| PRISM-compiled (this framework) | 9 µs | 11 µs | 108K/s |
| Marko 5 | 44 µs | 46 µs | 22K/s |
Solid 1.9 (generate: "ssr") |
65 µs | 70 µs | 15K/s |
| PRISM-interpreted (no compile pass) | 62 µs | 70 µs | 16K/s |
| React 19 | 2,683 µs | 5,500 µs | 373/s |
PRISM-compiled is ~5× faster than Marko 5, ~7× faster than
Solid 1.9, and within 1.5× of the bare Rust ceiling — V8's JIT
running an IIFE + for-loop + += accumulator is essentially
indistinguishable from native string-building.
PRISM-interpreted (no compile pass) is now in Solid's ballpark:
the runtime fast-path that landed in commit
2782ae6 made
escapeHtml indexOf-bail on safe strings, typeof-short-circuit
isAsync on primitives, walk props with for…in instead of
Object.entries, and gave h() a primitive-children fast path
that skips flat() and Array.join. The compile pass then emits
imperative IIFE codegen for any subtree containing .map or
Array.from, closing the gap to the hand-rolled ceiling.
End-to-end HTTP TTFB on sites/status (the bext.dev status page,
production PRISM site):
| Mode | avg | p95 |
|---|---|---|
BEXT_PRISM_COMPILE=0 |
17.59 ms | 23.69 ms |
| default (compile on) | 6.50 ms | 8.39 ms |
2.71× faster TTFB. The 4-5 ms difference between SSR microbench and HTTP TTFB is HTTP framework overhead (TCP, actix routing, response writing), unaffected by the pass.
When to use PRISM
| Use case | Choice |
|---|---|
| Public marketing / docs / blog | PRISM — zero-JS pages, microsecond renders |
| Status / dashboard / read-mostly admin | PRISM + a few islands for the interactive bits |
| Magazine / CMS-driven content | PRISM + ISR cache (per-route TTL in bext.config.toml) |
| App with form submissions, redirects | PRISM + server actions |
| Real-time interactive app (chat, editor) | Next.js / React (PRISM islands aren't a full SPA) |
| Existing React/Next.js codebase | Stay on Next.js, use bext as the HTTP frontend (type = "nextjs") |
PRISM is your own framework, in your repo. There's no
@bext-stack/framework API surface beyond what's in
sites/shared/framework/ — fork the runtime, add components,
ship. The bext team uses PRISM for bext.dev, status.bext.dev,
docs.bext.dev, and a handful of internal sites; the compile pass
was developed against those.
What's next
- JSX runtime details — h(), attributes, escape rules
- The compile pass — what folds, what doesn't, perf data
- PRISM data conventions — Remix-style loader/action
- Islands — React-hydrated interactive components
- Signals — fine-grained reactivity, no React, ~3 KB hydrate
- Server Actions — "use server" exports, `` PE, multipart uploads
- Tailwind — bext's built-in Tailwind v4 support