mono-jsx is a JSX runtime that renders <html>
element to Response
object in JavaScript runtimes like Node.js, Deno, Bun, Cloudflare Workers, etc.
- 🚀 No build step needed
- 🦋 Lightweight (8KB gzipped), zero dependencies
- 🔫 Minimal state runtime
- 🚨 Complete Web API TypeScript definitions
- ⏳ Streaming rendering
- 🥷 htmx integration
- 🌎 Universal, works in Node.js, Deno, Bun, Cloudflare Workers, etc.
mono-jsx supports all modern JavaScript runtimes including Node.js, Deno, Bun, and Cloudflare Workers.
You can install it via npm
, deno
, or bun
:
# Node.js, Cloudflare Workers, or other node-compatible runtimes
npm i mono-jsx
# Deno
deno add npm:mono-jsx
# Bun
bun add mono-jsx
To use mono-jsx as your JSX runtime, add the following configuration to your tsconfig.json
(or deno.json
for Deno):
Alternatively, you can use a pragma directive in your JSX file:
/** @jsxImportSource mono-jsx */
You can also run mono-jsx setup
to automatically add the configuration to your project:
# Node.js, Cloudflare Workers, or other node-compatible runtimes
npx mono-jsx setup
# Deno
deno run -A npm:mono-jsx setup
# Bun
bunx mono-jsx setup
mono-jsx allows you to return an <html>
JSX element as a Response
object in the fetch
handler:
// app.tsx
export default {
fetch: (req) => (
<html>
<h1>Welcome to mono-jsx!</h1>
</html>
)
}
For Deno/Bun users, you can run the app.tsx
directly:
deno serve app.tsx
bun run app.tsx
If you're building a web app with Cloudflare Workers, use wrangler dev
to start local development:
npx wrangler dev app.tsx
Node.js doesn't support JSX syntax or declarative fetch servers, so we recommend using mono-jsx with srvx:
// app.tsx
import { serve } from "srvx";
serve({
port: 3000,
fetch: (req) => (
<html>
<h1>Welcome to mono-jsx!</h1>
</html>
),
});
You'll need tsx to start the app without a build step:
npx tsx app.tsx
Note
Only root <html>
element will be rendered as a Response
object. You cannot return a <div>
or any other element directly from the fetch
handler. This is a limitation of the mono-jsx runtime.
mono-jsx uses JSX to describe the user interface, similar to React but with key differences.
mono-jsx adopts standard HTML property names, avoiding React's custom naming conventions:
className
→class
htmlFor
→for
onChange
→onInput
mono-jsx allows you to compose the class
property using arrays of strings, objects, or expressions:
<div
class={[
"container box",
isActive && "active",
{ hover: isHover },
]}
/>;
mono-jsx supports pseudo classes, pseudo elements, media queries, and CSS nesting in the style
property:
<a
style={{
color: "black",
"::after": { content: "↩️" },
":hover": { textDecoration: "underline" },
"@media (prefers-color-scheme: dark)": { color: "white" },
"& .icon": { width: "1em", height: "1em", marginRight: "0.5em" },
}}
>
<img class="icon" src="link.png" />
Link
</a>;
mono-jsx uses <slot>
elements to render slotted content (equivalent to React's children
property). You can also add the name
attribute to define named slots:
function Container() {
return (
<div class="container">
{/* Default slot */}
<slot />
{/* Named slot */}
<slot name="desc" />
</div>
)
}
function App() {
return (
<Container>
{/* This goes to the named slot */}
<p slot="desc">This is a description.</p>
{/* This goes to the default slot */}
<h1>Hello world!</h1>
</Container>
)
}
mono-jsx provides an html
tag function to render raw HTML in JSX instead of React's dangerouslySetInnerHTML
:
function App() {
return <div>{html`<h1>Hello world!</h1>`}</div>;
}
The html
tag function is globally available without importing. You can also use css
and js
tag functions for CSS and JavaScript:
function App() {
return (
<head>
<style>{css`h1 { font-size: 3rem; }`}</style>
<script>{js`console.log("Hello world!")`}</script>
</head>
)
}
Warning
The html
tag function is unsafe and can cause XSS vulnerabilities.
mono-jsx lets you write event handlers directly in JSX, similar to React:
function Button() {
return (
<button onClick={(evt) => alert("BOOM!")}>
Click Me
</button>
)
}
Note
Event handlers are never called on the server-side. They're serialized to strings and sent to the client. This means you should NOT use server-side variables or functions in event handlers.
import { doSomething } from "some-library";
function Button(this: FC, props: { role: string }) {
let message = "BOOM!";
console.log(message); // only executes on server-side
return (
<button
role={props.role}
onClick={(evt) => {
alert(message); // ❌ `message` is a server-side variable
console.log(props.role); // ❌ `props` is a server-side variable
doSomething(); // ❌ `doSomething` is imported on the server-side
Deno.exit(0); // ❌ `Deno` is unavailable in the browser
document.title = "BOOM!"; // ✅ `document` is a browser API
console.log(evt.target); // ✅ `evt` is the event object
this.count++; // ✅ update the state `count`
}}
>
Click Me
</button>
)
}
Additionally, mono-jsx supports the mount
event for when elements are mounted in the client-side DOM:
function App() {
return (
<div onMount={(evt) => console.log(evt.target, "Mounted!")}>
<h1>Welcome to mono-jsx!</h1>
</div>
)
}
mono-jsx also accepts functions for the action
property on form
elements, which will be called on form submission:
function App() {
return (
<form action={(data: FormData, event: SubmitEvent) => console.log(data.get("name"))}>
<input type="text" name="name" />
<button type="submit">Submit</button>
</form>
)
}
mono-jsx supports async components that return a Promise
or an async function. With streaming rendering, async components are rendered asynchronously, allowing you to fetch data or perform other async operations before rendering the component.
async function Loader(props: { url: string }) {
const data = await fetch(url).then((res) => res.json());
return <JsonViewer data={data} />;
}
export default {
fetch: (req) => (
<html>
<Loader url="https://api.example.com/data" placeholder={<p>Loading...</p>} />
</html>
)
}
You can also use async generators to yield multiple elements over time. This is useful for streaming rendering of LLM tokens:
async function* Chat(props: { prompt: string }) {
const stream = await openai.chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: prompt }],
stream: true,
});
for await (const event of stream) {
const text = event.choices[0]?.delta.content;
if (text) {
yield <span>{text}</span>;
}
}
}
export default {
fetch: (req) => (
<html>
<Chat prompt="Tell me a story" placeholder={<span style="color:grey">●</span>} />
</html>
)
}
mono-jsx provides a minimal state runtime for updating the view based on client-side state changes.
You can use the this
keyword in your components to manage state. The state is bound to the component instance and can be updated directly, and will automatically re-render the view when the state changes:
function Counter(
this: FC<{ count: number }>,
props: { initialCount?: number },
) {
// Initialize state
this.count = props.initialCount ?? 0;
return (
<div>
{/* render state */}
<span>{this.count}</span>
{/* Update state to trigger re-render */}
<button onClick={() => this.count--}>-</button>
<button onClick={() => this.count++}>+</button>
</div>
)
}
You can define app state by adding appState
prop to the root <html>
element. The app state is available in all components via this.app.<stateKey>
. Changes to the app state will trigger re-renders in all components that use it:
interface AppState {
themeColor: string;
}
function Header(this: FC<{}, AppState>) {
return (
<header>
<h1 style={{ color: this.app.themeColor }}>Welcome to mono-jsx.</h1>
</header>
)
}
function Footer(this: FC<{}, AppState>) {
return (
<footer>
<p style={{ color: this.app.themeColor }}>(c) 2025 mono-jsx.</p>
</footer>
)
}
function Main(this: FC<{}, AppState>) {
return (
<main>
<p>
<label>Theme Color: </label>
<input type="color" onInput={({ target }) => this.app.themeColor = target.value}/>
</p>
</main>
)
}
export default {
fetch: (req) => (
<html appState={{ themeColor: "#232323" }}>
<Header />
<Main />
<Footer />
</html>
)
}
You can use this.computed
to create computed state based on state. The computed state will automatically update when the state changes:
function App(this: FC<{ input: string }>) {
this.input = "Welcome to mono-jsx";
return (
<div>
<h1>{this.computed(() => this.input + "!")}</h1>
<form action={(fd) => this.input = fd.get("input") as string}>
<input type="text" name="input" value={this.input} />
<button type="submit">Submit</button>
</form>
</div>
)
}
The <toggle>
element conditionally renders content based on the value of a state.
function App(this: FC<{ show: boolean }>) {
this.show = false;
function toggle() {
this.show = !this.show;
}
return (
<div>
<toggle value={this.show}>
<h1>Welcome to mono-jsx!</h1>
</toggle>
<button onClick={toggle}>
{this.computed(() => this.show ? "Hide" : "Show")}
</button>
</div>
)
}
The <switch>
element renders different content based on the value of a state. Elements with matching slot
attributes are displayed when their value matches, otherwise default content is shown:
function App(this: FC<{ lang: "en" | "zh" | "emoji" }>) {
this.lang = "en";
return (
<div>
<switch value={this.lang}>
<h1 slot="en">Hello, world!</h1>
<h1 slot="zh">你好,世界!</h1>
<h1>✋🌎❗️</h1>
</switch>
<p>
<button onClick={() => this.lang = "en"}>English</button>
<button onClick={() => this.lang = "zh"}>中文</button>
<button onClick={() => this.lang = "emoji"}>Emoji</button>
</p>
</div>
)
}
1. Arrow function are non-stateful components.
// ❌ Won't work - use `this` in a non-stateful component
const App = () => {
this.count = 0;
return (
<div>
<span>{this.count}</span>
<button onClick={() => this.count++}>+</button>
</div>
)
};
// ✅ Works correctly
function App(this: FC) {
this.count = 0;
return (
<div>
<span>{this.count}</span>
<button onClick={() => this.count++}>+</button>
</div>
)
}
2. State cannot be computed outside of the this.computed
method.
// ❌ Won't work - state updates won't refresh the view
function App(this: FC<{ message: string }>) {
this.message = "Welcome to mono-jsx";
return (
<div>
<h1 title={this.message + "!"}>{this.message + "!"}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}
// ✅ Works correctly
function App(this: FC) {
this.message = "Welcome to mono-jsx";
return (
<div>
<h1 title={this.computed(() => this.message + "!")}>{this.computed(() => this.message + "!")}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}
3. Calling server-side functions or using server-side variables in compute functions is not allowed.
// ❌ Won't work - throws `Deno is not defined` when the button is clicked
function App(this: FC<{ message: string }>) {
this.message = "Welcome to mono-jsx";
return (
<div>
<h1>{this.computed(() => this.message + "! (Deno " + Deno.version.deno + ")")}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}
// ✅ Works correctly
function App(this: FC<{ message: string, denoVersion: string }>) {
this.message = "Welcome to mono-jsx";
this.denoVersion = Deno.version.deno;
return (
<div>
<h1>{this.computed(() => this.message + "! (Deno " + this.denoVersion + ")")}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}
mono-jsx binds a special this
object to your components when they are rendered. This object contains properties and methods that you can use to manage state, context, and other features.
The this
object contains the following properties:
app
: The app state defined on the root<html>
element.context
: The context defined on the root<html>
element.request
: The request object from thefetch
handler.computed
: A method to create computed properties based on state.
type FC<State = {}, AppState = {}, Context = {}> = {
readonly app: AppState;
readonly context: Context;
readonly request: Request;
readonly computed: <V = unknown>(computeFn: () => V) => V;
} & Omit<State, "app" | "context" | "request" | "computed">;
Check the Reactive section for more details on how to use state in your components.
You can use the context
property in this
to access context values in your components. The context is defined on the root <html>
element:
function Dash(this: FC<{}, {}, { auth: { uuid: string; name: string } }>) {
const { auth } = this.context;
return (
<div>
<h1>Welcome back, {auth.name}!</h1>
<p>Your UUID is {auth.uuid}</p>
</div>
)
}
export default {
fetch: async (req) => {
const auth = await doAuth(req);
return (
<html context={{ auth }} request={req}>
{!auth && <p>Please Login</p>}
{auth && <Dash />}
</html>
)
}
}
You can access request information in components via the request
property in this
which is set on the root <html>
element:
function RequestInfo(this: FC) {
const { request } = this;
return (
<div>
<h1>Request Info</h1>
<p>{request.method}</p>
<p>{request.url}</p>
<p>{request.headers.get("user-agent")}</p>
</div>
)
}
export default {
fetch: (req) => (
<html request={req}>
<RequestInfo />
</html>
)
}
mono-jsx renders your <html>
as a readable stream, allowing async components to render asynchronously. You can use placeholder
to display a loading state while waiting for async components to render:
async function Sleep({ ms }) {
await new Promise((resolve) => setTimeout(resolve, ms));
return <slot />;
}
export default {
fetch: (req) => (
<html>
<Sleep ms={1000} placeholder={<p>Loading...</p>}>
<p>After 1 second</p>
</Sleep>
</html>
)
}
You can set the rendering
attribute to "eager"
to force synchronous rendering (the placeholder
will be ignored):
export default {
fetch: (req) => (
<html>
<Sleep ms={1000} rendering="eager">
<p>After 1 second</p>
</Sleep>
</html>
)
}
You can add the catch
attribute to handle errors in the async component. The catch
attribute should be a function that returns a JSX element:
async function Hello() {
throw new Error("Something went wrong!");
return <p>Hello world!</p>;
}
export default {
fetch: (req) => (
<html>
<Hello catch={err => <p>{err.message}</p>} />
</html>
)
}
You can add status
or headers
attributes to the root <html>
element to customize the http response:
export default {
fetch: (req) => (
<html
status={404}
headers={{
cacheControl: "public, max-age=0, must-revalidate",
setCookie: "name=value",
"x-foo": "bar",
}}
>
<h1>Page Not Found</h1>
</html>
)
}
mono-jsx integrates with htmx and typed-htmx. To use htmx, add the htmx
attribute to the root <html>
element:
export default {
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/clicked") {
return (
<html>
<span>Clicked!</span>
</html>
);
}
return (
<html htmx>
<button hx-get="/clicked" hx-swap="outerHTML">
Click Me
</button>
</html>
)
}
}
You can add htmx extensions by adding the htmx-ext-*
attribute to the root <html>
element:
export default {
fetch: (req) => (
<html htmx htmx-ext-response-targets htmx-ext-ws>
<button hx-get="/clicked" hx-swap="outerHTML">
Click Me
</button>
</html>
)
}
You can specify the htmx version by setting the htmx
attribute to a specific version:
export default {
fetch: (req) => (
<html htmx="2.0.4" htmx-ext-response-targets="2.0.2" htmx-ext-ws="2.0.2">
<button hx-get="/clicked" hx-swap="outerHTML">
Click Me
</button>
</html>
)
}
By default, mono-jsx installs htmx from esm.sh CDN when you set the htmx
attribute. You can also install htmx manually with your own CDN or local copy:
export default {
fetch: (req) => (
<html>
<head>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.2" integrity="sha384-vuKxTKv5TX/b3lLzDKP2U363sOAoRo5wSvzzc3LJsbaQRSBSS+3rKKHcOx5J8doU" crossorigin="anonymous"></script>
</head>
<body>
<button hx-get="/clicked" hx-swap="outerHTML">
Click Me
</button>
</body>
</html>
)
}