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>&copy; 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 dangerouslySetInnerHTML with 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 &lt; 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.