bext.dev
DocsPRISM (bext-native)
PRISM (bext-native)8 min read
On this page

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.

PRISM is sponsored by Inklura webdesign29

Quickstart#

A minimal PRISM site:

bash
mkdir my-site && cd my-site
mkdir -p src/app

src/app/page.tsx:

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:

toml
[server]
port = 3088
app_dir = "."

[framework]
type = "prism"

tsconfig.json:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@bext-stack/framework"
  }
}

package.json:

json
{
  "dependencies": {
    "@bext-stack/framework": "workspace:*"
  }
}

Run:

bash
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.

tsx
// 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:

  1. Plain strings are escaped; rendered HTML is brandedh() wraps rendered subtrees in SafeHtml. 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.

  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. Attributes and expression children are escape boundaries — dynamic attributes go through formatAttrs; plain string children go through escapeHtml. 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 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 (<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#

code
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:

code
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:

tsx
// 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=..." },
  });
}
tsx
// 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":

tsx
// 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:

tsx
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.

Tip

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.

Tip

If you want nested file-system routing without writing a router manually, see Routing & LayoutsdefineRoutes() handles the layout composition.

What's next#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.