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.
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:
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.
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.
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
.
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.
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.
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, """)}"`;
})
.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:
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.
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.
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.
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.
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
).
The mechanism of JSX runtimes enables web frameworks to implement different strategies for parsing and rendering JSX nodes while using the same familiar syntax.
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
.
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
.