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:

  1. Strings flow through unchangedh() 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.

  2. Async path is AsyncIterable<string> — when any child is a Promise or AsyncIterable, h() yields chunks: open tag, then each child resolved, then close tag. The streaming protocol in @bext-stack/framework/streaming builds Suspense on top of this primitive.

  3. formatAttrs is 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 destructured function Card({ a, b }) is supported - props.children references — could be AsyncIterable<string>, string-coerce would render [object AsyncGenerator] - style={{...}} and dangerouslySetInnerHTML={{...}} — 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 detailsh(), 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