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.
On the current 38.3 KB Tailwind catalog fixture, the compile pass renders in
15.7 µs on average and the real automatic-JSX runtime path in 75.3 µs. React 19's
renderToString takes 1,823 µs for the same DOM. See
Performance for the fixture, runtime, and methodology.
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 (simplified):
function h(tag, props, ...children) {
if (typeof tag === "function") return tag(mergeChildren(props, children));
const attrs = formatAttrs(props); // " class=\"card\""
if (isVoidElement(tag)) return safe(`<${tag}${attrs}>`);
return safe(`<${tag}${attrs}>${renderChildren(children)}</${tag}>`);
}
Three properties make this tractable:
Plain strings are escaped; rendered HTML is branded —
h()wraps rendered subtrees inSafeHtml. A parent passes that brand through, while a plain string expression is escaped. This prevents both injection and double-escaping without an in-band marker in the response.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.Attributes and expression children are escape boundaries — dynamic attributes go through
formatAttrs; plain string children go throughescapeHtml. 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> |
__bextSafe("<head><meta charset=\"utf-8\"></head>") |
<h1>Hello, {name}!</h1> |
__bextSafe("<h1>Hello, " + __bextChild(name) + "!</h1>") |
<div className={cond ? "a" : "b"}> |
'<div class="' + __bextEsc(cond ? "a" : "b") + '">' |
<ul>{items.map(i => <li>{i}</li>)}</ul> |
one accumulator IIFE with a for loop and escaped dynamic text |
<Header/> (zero-prop) |
recursively inlines Header's body |
<Card title="x" body={user.bio}/> (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 the documented fold fixture, 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 (
<Card>{slot}</Card>) - 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<Response> {
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";
import { useState } from "preact/hooks";
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:
import Counter from "../islands/Counter";
export default function Page() {
return (
<main>
<Counter start={42} />
</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 byte-validated 38.3 KB product-listing page. Measurements use Bun 1.3.13 on a pinned core; the PRISM/Solid values are medians of alternating runs:
| Engine | avg | p50 | Throughput |
|---|---|---|---|
| Rust string builder | 3.9 µs | 3.9 µs | 255K renders/s |
| Safe hand-rolled JS ceiling | 15.8 µs | 14.3 µs | 63K renders/s |
| PRISM compile pass | 15.7 µs | 14.0 µs | 64K renders/s |
| Marko 5 | 38 µs | 22 µs | 26.0K renders/s |
PRISM interpreted, direct h() |
69.3 µs | 62.1 µs | 14.4K renders/s |
| PRISM automatic JSX | 75.3 µs | 67.9 µs | 13.3K renders/s |
Solid 1.9 (generate: "ssr") |
78 µs | 61 µs | 12.8K renders/s |
| React 19 | 1,823 µs | 1,646 µs | 549 renders/s |
Compiled PRISM is about 2.4× faster than Marko and 116× faster than React on this fixture, while matching the security-equivalent hand-written JavaScript ceiling within measurement noise. The Rust row is a native lower bound, not a JavaScript implementation of PRISM.
Interpreted PRISM and Solid are now in the same band: Solid has the lower p50, while PRISM has the slightly lower average in the latest alternating runs. The automatic runtime reuses compiler-created intrinsic props instead of cloning them, bypasses redundant component normalization when children are already normalized, and retains two exact class-escape results for repeated table rows. The cache is bounded to two entries and stores the escaped value, so repeated dynamic classes do not weaken the HTML-escaping contract.
For context, a separate historical end-to-end HTTP TTFB measurement on
sites/status (the bext.dev status page, a production PRISM site) recorded:
| Mode | avg | p95 |
|---|---|---|
BEXT_PRISM_COMPILE=0 |
17.59 ms | 23.69 ms |
| default (compile on) | 6.50 ms | 8.39 ms |
That run recorded 2.71× faster TTFB. It was not rerun as part of the current 38.3 KB rendering shootout. Its 4-5 ms difference between SSR microbench and HTTP TTFB was 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.
PRISM expression children ({value}) are escaped automatically when value is
a plain string. Rendered subtrees carry the out-of-band SafeHtml brand and pass
through without double escaping. Use dangerouslySetInnerHTML only for trusted
or already-sanitized rich HTML. See
HTML escaping.
If you want nested file-system routing without writing a router manually, see
Routing & Layouts — defineRoutes() handles the layout composition.
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,<Form>PE, multipart uploads - Tailwind — bext's built-in Tailwind v4 support
Related#
- JSX Runtime —
h(), attribute aliasing, HTML-escape contract - PRISM Compile Pass — how static subtrees fold to string literals
- PRISM Data Conventions — loader/action co-location
- Islands Architecture — progressive enhancement with client bundles
- Signals — fine-grained reactivity without React
- Rendering Pipeline — how bext dispatches a request to a V8 isolate