Built-in JSX
bext includes a built-in JSX runtime that compiles JSX to HTML strings. There is no Virtual DOM, no diffing, no reconciliation. The h() function concatenates strings — the same thing you would do with template literals, but with JSX syntax.
// This JSX:
<div className="card">
<h2>{title}</h2>
<p>{description}</p>
</div>
// Becomes this string:
// <div class="card"><h2>My Title</h2><p>My description</p></div>
How it works#
bext's JSX runtime exports h() (hyperscript), jsx, jsxs, and Fragment.
TypeScript's react-jsx mode calls jsx/jsxs; direct callers can use h().
Instead of creating virtual DOM nodes, intrinsic elements return SafeHtml, a
branded String subclass whose primitive value is the wire HTML.
- Function components receive props and return a string
- HTML tags are rendered as string concatenation
- Children are flattened and joined
- There is no component lifecycle, no state, no hooks
The automatic runtime reuses the compiler-created props object for intrinsic
elements because formatAttrs already ignores children. For components it
bypasses normalization only when children are absent, scalar, or an already-flat
dense array; nested arrays, nullish values, and booleans retain the legacy
flattening semantics.
This makes it ideal for server-rendered pages where you want the ergonomics of JSX without shipping any framework code to the browser.
tsconfig.json setup#
Configure TypeScript to use bext's JSX runtime:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@bext-stack/framework",
"paths": {
"@bext-stack/framework/*": ["../shared/framework/*"],
"@bext-stack/framework": ["../shared/framework/src/index.ts"]
}
}
}
With this config, TypeScript automatically imports the JSX runtime from @bext-stack/framework/jsx-runtime. No manual imports needed.
Component examples#
Simple component#
function Header({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<header>
<h1>{title}</h1>
{subtitle && <p className="subtitle">{subtitle}</p>}
</header>
);
}
// Returns: <header><h1>My Site</h1><p class="subtitle">Welcome</p></header>
const html = <Header title="My Site" subtitle="Welcome" />;
Card component#
interface CardProps {
title: string;
body: string;
href?: string;
children?: string[];
}
function Card({ title, body, href, children }: CardProps) {
const Tag = href ? "a" : "div";
return (
<Tag className="card" href={href}>
<h3>{title}</h3>
<p>{body}</p>
{children}
</Tag>
);
}
const html = <Card title="Feature" body="Lightning fast" href="/features" />;
// <a class="card" href="/features"><h3>Feature</h3><p>Lightning fast</p></a>
Layout component#
function Layout({ title, children }: { title: string; children?: string[] }) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
<footer>© 2026</footer>
</body>
</html>
);
}
Navigation with active state#
interface NavItem {
href: string;
label: string;
}
function Nav({ items, currentPath }: { items: NavItem[]; currentPath: string }) {
return (
<nav className="sidebar">
{items.map((item) => (
<a
href={item.href}
className={currentPath === item.href ? "active" : ""}
>
{item.label}
</a>
))}
</nav>
);
}
Mixing with template strings#
bext JSX returns plain strings, so you can freely mix JSX and template literals:
import { h } from "@bext-stack/framework";
function renderPage(page: Page, ctx: RenderContext): string {
const nav = (
<nav>
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
);
// Mix JSX output with template strings
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>${page.title}</title>
</head>
<body>
${nav}
<main>${page.html}</main>
<footer>Built with bext</footer>
</body>
</html>`;
}
This is useful when your outer HTML shell is static and you only want JSX for dynamic components.
Fragment support#
Use Fragment (or the <>...</> shorthand) to return multiple elements without a wrapper:
import { Fragment } from "@bext-stack/framework";
function MetaTags({ title, description }: { title: string; description: string }) {
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
</>
);
}
// Returns: <title>Hello</title><meta name="description" content="World"><meta property="og:title" content="Hello">
dangerouslySetInnerHTML#
To inject raw HTML without escaping (for markdown output, sanitized user content, etc.):
function MarkdownContent({ html }: { html: string }) {
return (
<article
className="prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// The html string is inserted directly without escaping
const output = <MarkdownContent html="<h1>Hello</h1><p>World</p>" />;
// <article class="prose"><h1>Hello</h1><p>World</p></article>
Warning: Only use
dangerouslySetInnerHTMLwith trusted content. Never pass unsanitized user input.
Style objects#
Pass a JavaScript object to style and it will be converted to a CSS string. camelCase properties are converted to kebab-case:
function Badge({ color, label }: { color: string; label: string }) {
return (
<span
style={{
backgroundColor: color,
padding: "2px 8px",
borderRadius: "4px",
fontSize: "12px",
fontWeight: 600,
}}
>
{label}
</span>
);
}
// <span style="background-color:red;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600">New</span>
className to class aliasing#
bext's JSX runtime automatically converts React-style attribute names to their HTML equivalents:
| JSX Attribute | HTML Output |
|---|---|
className |
class |
htmlFor |
for |
httpEquiv |
http-equiv |
tabIndex |
tabindex |
crossOrigin |
crossorigin |
autoComplete |
autocomplete |
autoFocus |
autofocus |
<label htmlFor="email" className="form-label">Email</label>
// <label for="email" class="form-label">Email</label>
<meta httpEquiv="refresh" content="5" />
// <meta http-equiv="refresh" content="5">
Boolean attributes#
Boolean true renders the attribute without a value. false or null omits it:
<input type="checkbox" checked={true} disabled={false} />
// <input type="checkbox" checked>
<details open={isOpen}>
<summary>Click me</summary>
<p>Content</p>
</details>
Void elements#
Self-closing HTML elements (<img>, <br>, <input>, <meta>, <link>, etc.) are rendered correctly without closing tags:
<img src="/photo.jpg" alt="A photo" />
// <img src="/photo.jpg" alt="A photo">
<br />
// <br>
<link rel="stylesheet" href="/styles.css" />
// <link rel="stylesheet" href="/styles.css">
Comparison with React#
| Feature | bext JSX | React |
|---|---|---|
| Output | HTML string | Virtual DOM |
| Bundle size | 0 KB (strings) | ~140 KB (react + react-dom) |
| State management | None (server only) | useState, useReducer |
| Lifecycle hooks | None | useEffect, useMemo, etc. |
| Client hydration | None (zero JS) | Full hydration required |
| Render speed (38.3 KB fixture) | 69.3 µs direct / 75.3 µs automatic | 1,823 µs |
| Use case | Server-rendered pages | Interactive client apps |
Use bext JSX when:
- Your pages are server-rendered with no client interactivity
- You want zero JavaScript shipped to the browser
- You need maximum render performance (microsecond-level)
- You are building content sites, docs, blogs, marketing pages
Use React when:
- You need client-side interactivity (forms, animations, real-time updates)
- You have an existing React component library
- You need hooks, context, or state management
You can also mix both — see React, Preact & Solid for how to embed React components inside bext JSX layouts.
Related#
- PRISM Framework — the server framework built on this JSX runtime
- PRISM Compile Pass — how JSX calls are folded to string concats at build time
- Islands Architecture — shipping client-side components alongside server JSX
- Signals — reactive islands without React, using the same JSX syntax
- Tailwind CSS — generating CSS from JSX
classNameusage
HTML escaping — what's escaped and what isn't#
This is the most important security note in this doc. Dynamic text and attributes are safe by default; raw HTML remains an explicit opt-in.
| Position | Escaped? | Example |
|---|---|---|
| Static text inside JSX | Yes (compile time) | <p>5 < 10</p> → <p>5 < 10</p> |
| Attribute values | Yes (runtime formatAttrs) |
<a title={user.bio}> |
Expression string children {value} |
Yes (runtime or compile helper) | <p>{userInput}</p> |
| Nested rendered JSX | Passed through via SafeHtml |
<div>{<strong>safe</strong>}</div> |
dangerouslySetInnerHTML |
No (intentional) | as in React |
When you write <p>{userInput}</p>, a plain string goes through escapeHtml.
When the child is output from another PRISM element, it carries the out-of-band
SafeHtml brand and passes through without double escaping. Legacy compiled
bundles that still carry the reserved \x01 marker remain supported, but new
runtime output does not put that marker on the wire.
Normal dynamic text needs no wrapper:
<p>{userInput}</p>
<input value={userInput} readOnly />
If you need raw HTML output (markdown, sanitized rich text), use
dangerouslySetInnerHTML={{ __html: ... }} exactly like React. The
prop name is the warning — only use it with trusted or sanitized
content.
dangerouslySetInnerHTML bypasses escaping. Only pass trusted or separately
sanitized HTML to __html; do not use it for ordinary user input.
escapeHtml is still exported for non-JSX contexts and for compile-pass helper
calls. It regex-bails on strings without &<>". Class attributes add a bounded
two-entry exact-value cache for repeated table rows; the cache stores the
already-escaped result, so a repeated malicious value remains escaped.