🌎 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.tsxexportdefault{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 your app in development mode:
Node.js doesn't support JSX syntax or declarative fetch servers, we recommend using mono-jsx with srvx:
// app.tsximport{serve}from"srvx";serve({port: 3000,fetch: (req)=>(<html><h1>Welcome to mono-jsx!</h1></html>),});
And you'll need tsx to start the app without a build step:
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.
mono-jsx uses JSX to describe the user interface, similar to React but with key differences.
Using Standard HTML Property Names
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:
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:
functionContainer(){return(<divclass="container">{/* Default slot */}<slot/>{/* Named slot */}<slotname="desc"/></div>)}functionApp(){return(<Container>{/* This goes to the named slot */}<pslot="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:
The html tag function is unsafe and can cause XSS vulnerabilities.
mono-jsx lets you write event handlers directly in JSX, similar to React:
functionButton(){return(<buttononClick={(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";functionButton(this: FC<{count: 0}>,props: {role: string}){constmessage="BOOM!";// server-side variablethis.count=0;// initialize a signalconsole.log(message);// only prints on server-sidereturn(<buttonrole={props.role}onClick={(evt)=>{alert(message);// ❌ `message` is a server-side variableconsole.log(props.role);// ❌ `props` is a server-side variabledoSomething();// ❌ `doSomething` is imported on the server-sideDeno.exit(0);// ❌ `Deno` is unavailable in the browserdocument.title="BOOM!";// ✅ `document` is a browser APIconsole.log(evt.target);// ✅ `evt` is the event objectthis.count++;// ✅ update the `count` signal}}><slot/></button>)}
mono-jsx supports <form> elements with the action attribute. The action attribute can be a string URL or a function that accepts a FormData object. The function will be called on form submission, and the FormData object will contain the form data.
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.
You can also use async generators to yield multiple elements over time. This is useful for streaming rendering of LLM tokens:
asyncfunction*Chat(props: {prompt: string}){conststream=awaitopenai.chat.completions.create({model: "gpt-4",messages: [{role: "user",content: prompt}],stream: true,});forawait(consteventofstream){consttext=event.choices[0]?.delta.content;if(text){yield<span>{text}</span>;}}}exportdefault{fetch: (req)=>(<html><Chatprompt="Tell me a story"placeholder={<spanstyle="color:grey">●</span>}/></html>)}
mono-jsx uses signals for updating the view when a signal changes. Signals are similar to React's state, but they are more lightweight and efficient. You can use signals to manage state in your components.
You can use the this keyword in your components to manage signals. The signals is bound to the component instance and can be updated directly, and will automatically re-render the view when a signal changes:
functionCounter(this: FC<{count: number}>,props: {initialCount?: number},){// Initialize a singalthis.count=props.initialCount??0;return(<div>{/* render singal */}<span>{this.count}</span>{/* Update singal to trigger re-render */}<buttononClick={()=>this.count--}>-</button><buttononClick={()=>this.count++}>+</button></div>)}
You can define app signals by adding app prop to the root <html> element. The app signals is available in all components via this.app.<SignalName>. Changes to the app signals will trigger re-renders in all components that use it:
You can use this.computed to create a derived signal based on other signals:
functionApp(this: FC<{input: string}>){this.input="Welcome to mono-jsx";return(<div><h1>{this.computed(()=>this.input+"!")}</h1><formaction={(fd)=>this.input=fd.get("input")asstring}><inputtype="text"name="input"value={""+this.input}/><buttontype="submit">Submit</button></form></div>)}
You can use this.effect to create side effects based on signals. The effect will run whenever the signal changes:
The callback function of this.effect can return a cleanup function that gets run once the component element has been removed via <toggle> or <switch> condition rendering:
The <toggle> element conditionally renders content based on the show prop:
functionApp(this: FC<{show: boolean}>){this.show=false;functiontoggle(){this.show=!this.show;}return(<div><toggleshow={this.show}><h1>Welcome to mono-jsx!</h1></toggle><buttononClick={toggle}>{this.computed(()=>this.show ? "Hide" : "Show")}</button></div>)}
Using <switch> Element with Signals
The <switch> element renders different content based on the value of a signal. Elements with matching slot attributes are displayed when their value matches, otherwise default slots are shown:
// ❌ Won't work - use `this` in a non-stateful componentconstApp=()=>{this.count=0;return(<div><span>{this.count}</span><buttononClick={()=>this.count++}>+</button></div>)};// ✅ Works correctlyfunctionApp(this: FC){this.count=0;return(<div><span>{this.count}</span><buttononClick={()=>this.count++}>+</button></div>)}
2. Signals cannot be computed outside of the this.computed method.
// ❌ Won't work - updates of a signal won't refresh the viewfunctionApp(this: FC<{message: string}>){this.message="Welcome to mono-jsx";return(<div><h1title={this.message+"!"}>{this.message+"!"}</h1><buttononClick={()=>this.message="Clicked"}>
Click Me
</button></div>)}// ✅ Works correctlyfunctionApp(this: FC){this.message="Welcome to mono-jsx";return(<div><h1title={this.computed(()=>this.message+"!")}>{this.computed(()=>this.message+"!")}</h1><buttononClick={()=>this.message="Clicked"}>
Click Me
</button></div>)}
3. The callback function of this.computed must be a pure function. That means it should not create side effects or access any non-stateful variables. For example, you cannot use Deno or document in the callback function:
// ❌ Won't work - throws `Deno is not defined` when the button is clickedfunctionApp(this: FC<{message: string}>){this.message="Welcome to mono-jsx";return(<div><h1>{this.computed(()=>this.message+"! (Deno "+Deno.version.deno+")")}</h1><buttononClick={()=>this.message="Clicked"}>
Click Me
</button></div>)}// ✅ Works correctlyfunctionApp(this: FC<{message: string,denoVersion: string}>){this.denoVersion=Deno.version.deno;this.message="Welcome to mono-jsx";return(<div><h1>{this.computed(()=>this.message+"! (Deno "+this.denoVersion+")")}</h1><buttononClick={()=>this.message="Clicked"}>
Click Me
</button></div>)}
mono-jsx binds a scoped signals object to this of your component functions. This allows you to access signals, context, and request information directly in your components.
The this object has the following built-in properties:
app: The app signals defined on the root <html> element.
context: The context defined on the root <html> element.
request: The request object from the fetch handler.
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:
asyncfunctionSleep({ ms }){awaitnewPromise((resolve)=>setTimeout(resolve,ms));return<slot/>;}exportdefault{fetch: (req)=>(<html><Sleepms={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):
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:
asyncfunctionHello(){thrownewError("Something went wrong!");return<p>Hello world!</p>;}exportdefault{fetch: (req)=>(<html><Hellocatch={err=><p>{err.message}</p>}/></html>)}
Since mono-jsx renders html on server side, and no hydration JS sent to client side. To render a component dynamically on client side, you can use the <component> element to ask the server to render a component and send the html back to client:
exportdefault{fetch: (req)=>(<htmlcomponents={{ Foo }}><componentname="Foo"props={{/* props for the component */}}placeholder={<p>Loading...</p>}/></html>)}
You can use <toggle> element to control when to render the component:
asyncfunctionLazy(this: FC<{show: boolean}>,props: {url: string}){this.show=false;return(<div><togglevalue={this.show}><componentname="Foo"props={{/* props for the component */}}placeholder={<p>Loading...</p>}/></toggle><buttononClick={()=>this.show=true}>Load `Foo` Component</button></div>)}exportdefault{fetch: (req)=>(<htmlcomponents={{ Foo }}><Lazy/></html>)}
You also can use signal name or props, change the signal value will trigger the component to re-render with new name or props:
mono-jsx provides a built-in <router> element that allows your app to render components based on the current URL. On client side, it listens all click events on <a> elements and asynchronously fetches the route component without reloading the entire page.
To use the router, you need to define your routes as a mapping of URL patterns to components and pass it to the <html> element as routes prop. The request prop is also required to match the current URL against the defined routes.
mono-jsx router requires URLPattern to match a route:
✅ Deno
✅ Cloudflare Workers
✅ Nodejs (>= 24)
For Bun users, mono-jsx provides a monoRoutes function that uses Bun's built-in routing:
// bun app.tsximport{monoRoutes}from"mono-jsx"constroutes={"/": Home,"/about": About,"/blog": Blog,"/post/:id": Post,}exportdefault{routes: monoRoutes(routes,(request)=>(<htmlrequest={request}><router/></html>))}
When you define a route with a parameter (e.g., /post/:id), mono-jsx will automatically extract the parameter from the URL and make it available in the route component. The params object is available in the request property of the component's this context.
You can access the params object in your route components to get the values of the parameters defined in the route pattern:
Route components are always rendered on server-side, you can use any database or storage API to fetch data in your route components.
asyncfunctionPost(this: FC){constpost=awaitsql`SELECT * FROM posts WHERE id = ${this.request.params!.id}`return(<article><h2>{post.title}<h2><div>html`${post.content}`</div></article>
)
}
Links under the <nav> element will be treated as navigation links by the router. When the href of a nav link matches a route, a active class will be added to the link element. By default, the active class is active, but you can customize it by setting the data-active-class attribute on the <nav> element. You can add style for the active link using nested CSS selectors in the style attribute of the <nav> element.
You can add fallback(404) content to the <router> element as children, which will be displayed when no route matches the current URL.
exportdefault{fetch: (req)=>(<htmlrequest={req}routes={routes}><router><p>Page Not Found</p><p>Back to <ahref="/">Home</a></p></router></html>)}
Customizing html Response
You can add status or headers attributes to the root <html> element to customize the http response:
exportdefault{fetch: (req)=>(<htmlstatus={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:
exportdefault{fetch: (req)=>{consturl=newURL(req.url);if(url.pathname==="/clicked"){return(<html><span>Clicked!</span></html>);}return(<htmlhtmx><buttonhx-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:
exportdefault{fetch: (req)=>(<htmlhtmxhtmx-ext-response-targetshtmx-ext-ws><buttonhx-get="/clicked"hx-swap="outerHTML">
Click Me
</button></html>)}
You can specify the htmx version by setting the htmx attribute to a specific version:
exportdefault{fetch: (req)=>(<htmlhtmx="2.0.4"htmx-ext-response-targets="2.0.2"htmx-ext-ws="2.0.2"><buttonhx-get="/clicked"hx-swap="outerHTML">
Click Me
</button></html>)}
By default, mono-jsx imports htmx from esm.sh CDN when you set the htmx attribute. You can also setup htmx manually with your own CDN or local copy:
exportdefault{fetch: (req)=>(<html><head><scriptsrc="https://unpkg.com/[email protected]"integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"crossorigin="anonymous"></script><scriptsrc="https://unpkg.com/[email protected]"integrity="sha384-vuKxTKv5TX/b3lLzDKP2U363sOAoRo5wSvzzc3LJsbaQRSBSS+3rKKHcOx5J8doU"crossorigin="anonymous"></script></head><body><buttonhx-get="/clicked"hx-swap="outerHTML">
Click Me
</button></body></html>)}