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) and Fragment. When TypeScript compiles your JSX, it calls h() for each element. Instead of creating virtual DOM nodes, h() returns a plain HTML string.
- 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
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 = ;
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 (
<h3>{title}</h3>
<p>{body}</p>
{children}
);
}
const html = ;
// <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:
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:
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 = 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 | ~50us per page | ~2-10ms per page |
| 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.
HTML escaping — what's escaped and what isn't
This is the most important security note in this doc. bext's JSX runtime is more aggressive than React in some ways and less in others; read this carefully if your pages render user-supplied data.
| 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 children {value} |
No | <p>{userInput}</p> ← NOT escaped |
dangerouslySetInnerHTML |
No (intentional) | as in React |
The expression-child case is the gotcha. When you write
<p>{userInput}</p>, the runtime joins string children verbatim (this
matches the design — folded JSX subtrees, async streams, and pre-rendered
HTML strings all need to flow through children unescaped). If
userInput is user-supplied, you must escape it yourself before
inserting into a children position.
Three safe patterns:
// Pattern 1 — escape at the call site
<p>{escapeHtml(userInput)}</p>
// Pattern 2 — escape in a wrapper
function SafeText({ value }: { value: string }) {
return <span>{escapeHtml(value)}</span>;
}
// Pattern 3 — pass through an attribute (escaped automatically)
<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.
Why doesn't the runtime escape children automatically? Because the sync render path joins children with empty string and accepts:
- Plain strings (already-escaped HTML, like compiled subtrees)
- Numbers and booleans (coerced)
- Promise<string> / AsyncIterable<string> (streaming components)
Auto-escaping all of these would either break the streaming protocol or double-escape compiled output. The trade-off is that expression-child user input needs explicit escaping. This matches Marko's "trust your output" model and the same trade-off you make when concatenating template strings in any other server-side language.