SwiftAce

How to Build a Custom JSX Runtime

JSX is a syntax extension for JavaScript that lets you write HTML-like markup within a JavaScript file. It was first introduced by React and is now used by several web frameworks. JSX also lets you define custom reusable "components" [1] and use them like HTML tags.

Here's an example of some JSX code in a React application (see it live):

import { createRoot } from 'react-dom/client';

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

function App() {
  return (
    <div class="container">
      <Greeting name="World" />
      <p>Welcome to JSX</p>
    </div>
  );
}

createRoot(document.body).render(<App />);

We define a JSX component Greeting that accepts a "prop"[2] name and creates an h1 heading. Then, we define a component App that creates a div containing a Greeting and a paragraph. Finally, we "render"[3] the App into the body of the HTML page in the browser.

In this tutorial, we'll create a custom JSX runtime that can be used within a web application (both on the server and within the browser) to turn JSX code into valid HTML. The code shown in this tutorial can be found here: https://github.com/aakashns/custom-jsx-runtime.

How JSX Works

While JSX code looks like HTML, under the hood JSX "elements"[4] are transformed into JavaScript function calls. This transformation is done by a compiler/bundler like TypeScript, ESBuild, or Deno. The code shown above is transformed into the following before execution:

import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { createRoot } from "react-dom/client";

function Greeting({ name }) {
  return _jsxs("h1", { children: ["Hello, ", name, "!"] });
}

function App() {
  return _jsxs("div", {
    class: "container",
    children: [
      _jsx(Greeting, { name: "World" }),
      _jsx("p", { children: "Welcome to JSX" }),
    ],
  });
}

createRoot(document.body).render(_jsx(App, {}));

JSX tags are replaced with calls to the functions _jsx and _jsxs imported from a framework-specific "JSX runtime" [5]. Both these functions accept three arguments:

  1. type: The type (or name) of the element. Tag names starting with lowercase letters (e.g. div or p) represent raw HTML tags and are converted to strings, while tag names starting with uppercase letters (e.g. Greeting or App) represent components.

  2. props: Attributes passed to JSX tags are collected into an object of key-value pairs called props. Further, child tags are included in a special prop children. The _jsx function is used for zero or one child, while _jsxs is used for two or more children.

  3. key (optional): The attribute key is not included in props. Rather, it is passed as a third argument to the function. It typically represents a unique identifier for an element in a list. It is used by React and other frameworks for efficient rendering of lists.

Objects created using a JSX runtime are turned into DOM nodes (in a browser) or HTML files (on a server) using a renderer. The App object created above using react/jsx-runtime is rendered into a browser's document.body using createRoot from react-dom/client.

A Custom JSX Runtime

Let's implement a simple JSX runtime to transform JSX tags into plain JavaScript objects. Let's create a folder customjsx and add a file jsx-runtime.js in it with the following code:

// customjsx/jsx-runtime.js

export function jsx(type, props, key = null) {
  return { type, props, key };
}

export const jsxs = jsx;

That's it! That's the entire JSX runtime. The function jsx returns an object containing the type, props, and key passed in as arguments, and jsxs does the same. Libraries like React, Preact, or Hono use more sophisticated implementations to support specific features.

Next, let's create a file app.jsx containing some JSX code mixed in with JavaScript:

// app.jsx

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

console.log(
  <div class="container">
    <Greeting name="World" />
    <p>Welcome to JSX</p>
  </div>
);

We define a component Greeting and use it to create a div element containing a Greeting and a paragraph. We log the div element to the console for inspection.

Configuration and Execution

We'll run app.jsx using Deno, a Node.js alternative with built-in JSX support [6]. We can configure Deno to use our JSX runtime by adding a file deno.jsonc with the following code:

// deno.jsonc
{
  "compilerOptions": {
    // Use a JSX runtime similar to React
    "jsx": "react-jsx",
    // Import the runtime from a custom source
    "jsxImportSource": "./customjsx"
  },
  "imports": {
    // Add ".js" to the path while importing the runtime
    "./customjsx/jsx-runtime": "./customjsx/jsx-runtime.js"
  }
}

We can now execute the code (assuming we have already installed Deno) by running the following command on a terminal (on Linux or macOS) or command prompt (on Windows):

$ deno app.jsx

Executing the above command results in the following output:

{
  type: "div",
  props: {
    class: "container",
    children: [
      { type: [Function: Greeting], props: { name: "World" }, key: null },
      { type: "p", props: { children: "Welcome to JSX" }, key: null }
    ]
  },
  key: null
}

The output is an object with the key type set to div, and props set to an object with keys class and children. The first child has type set to Greeting (our custom component) and a prop name set to World, while the second is a p tag with a single text child.

Rendering to HTML

Next, let's define a function renderToHtml to turn the output of our JSX runtime into a valid HTML string. Let's create a file render.js within customjsx with the following code:

// customjsx/render.js

/* Renders a JSX element to its HTML string representation */
export function renderToHtml(element) {
  if ([null, undefined, false].includes(element)) return ""; // Empty
  if (typeof element === "string") return escapeForHtml(element); // Text
  if (typeof element === "number") return element.toString(); // Number
  if (Array.isArray(element)) return element.map(jsxToStr).join(""); // List

  if (typeof element !== "object") throw Error("Element must be an object");
  const { type, props } = element;
  if (typeof type === "function") return jsxToStr(type(props)); // Component

  const { children, ...attrs } = props; // HTML tag
  const attrsStr = attrsToStr(attrs);

  if (VOID_TAGS.includes(type)) { // Self-closing e.g. <br>
    if (children) throw Error("Void tag cannot have children");
    return `<${type}${attrsStr}>`;
  }

  const childrenStr = jsxToStr(children);
  return `<${type}${attrsStr}>${childrenStr}</${type}>`;
}

renderToHtml recursively converts a tree of JSX elements into an HTML string. It handles empty values, text, numbers, custom component functions, HTML tags (normal and self-closing), and arrays of children elements. Let's also implement the helper functions it uses:

// customjsx/render.js

/* Convert &, <, >, ", ' to escaped HTML codes to prevent XSS attacks */
function escapeForHtml(unsafeText) {
  const CODES = { "&": "amp", "<": "lt", ">": "gt", '"': "quot", "'": "#39" };
  return unsafeText.replace(/[&<>"']/g, (c) => `&${CODES[c]};`);
}

/* Convert an object of HTML attributes to a string */
function attrsToStr(attrs) {
  const illegal = /[ "'>\/= \u0000-\u001F\uFDD0-\uFDEF\uFFFF\uFFFE]/;
  const result = Object.entries(attrs)
    .map(([key, value]) => {
      if (illegal.test(key)) {
        throw Error(`Illegal attribute name: ${key}`);
      }
      if (value === true) return ` ${key}`; // Boolean (true)
      if ([null, undefined, false].includes(value)) return null; // Skipped
      return ` ${key}="${value.toString().replace(/"/g, "&quot;")}"`;
    })
    .filter(Boolean)
    .join("");
  return result;
}

/* Self-closing HTML tags that can't have children */
const VOID_TAGS = ["area", "base", "br", "col", "command", 
  "embed", "hr", "img", "input", "keygen", "link", 
  "meta", "param", "source", "track", "wbr"];

Finally, let's turn app.js into a web application that renders JSX and serves an HTML page:

// app.jsx

import { renderToHtml } from "./customjsx/render.js";

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

function App() {
  return (
    <html>
      <head>
        <title>Custom JSX</title>
      </head>
      <body>
        <div class="container">
          <Greeting name="World" />
          <p>Welcome to JSX</p>
        </div>
      </body>
    </html>
  );
}

// Use Deno's built in HTTP server
Deno.serve((req) => {
  const htmlPage = renderToHtml(<App />);
  const headers = { "Content-Type": "text/html" };
  return new Response(htmlPage, { headers });
});

We can now run the app by executing the following command on a terminal:

$ deno --allow-net app.jsx

You can now open up the URL http://localhost:8000 in a browser to view the HTML page:

page-preview

And that's it! We've built a custom JSX runtime[7] from scratch, including a function to turn JSX elements into a valid spec-compliant HTML string. We can use renderToHtml to programmatically generate HTML on both the server and within a browser.

I hope you found this introduction to building JSX runtimes useful.

Footnotes

  1. A JSX component is a self-contained, reusable piece of UI in JavaScript, defined as a function or class. It typically integrates structure, styling, and behavior in a single unit.

  2. Props (properties) are inputs that look like HTML attributes, passed to a JSX component to enable customization, dynamic behavior, and data flow from parent nodes.

  3. Rendering is the process of converting JSX elements into DOM nodes in the browser or HTML strings on a server. Libraries like React Native can also render to mobile apps.

  4. A JSX tag, together with its props and child tags, is called an element. Elements can be made up of simple HTML tags (e.g. div) or custom components (e.g. Greeting).

  5. The mechanism of JSX runtimes enables web frameworks to implement different strategies for parsing and rendering JSX nodes while using the same familiar syntax.

  6. If you're using TypeScript with Node.js, you can configure it using a tsconfig.json file. Similarly, ESBuild can be configured via command line arguments or tsconfig.json.

  7. Our implementation excludes features like state, hooks, keys, etc. for creating dynamic and interactive UI elements, since we only use it to generate HTML strings. However, we should consider adding features like Fragment and dangerouslySetInnerHtml.