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

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.

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

json
{
  "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#

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

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

tsx
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>
  );
}
tsx
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:

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

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

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

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

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

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

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 &lt; 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:

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

Warning

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.

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

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