mono-jsx: a JSX runtime that renders the html element into a Response
`<html>` as a `Response`.
At a glance
- What is it?
- mono-jsx lets you return an <html> JSX element straight from a fetch handler and get a Response back, with no build step, no virtual DOM and a 12KB gzipped footprint. It targets runtimes with real server-side JSX support, so Deno, Bun and Cloudflare Workers come first and Node.js needs extra tooling.
- Who is it for?
- Adopt mono-jsx if you are building a server-rendered app on Deno, Bun or Cloudflare Workers and want JSX without a framework, since it ships the router, signals and RPC in a 12KB package with no dependencies. Do not adopt it on plain Node.js without also taking srvx and tsx, because Node does not support JSX syntax or declarative fetch servers.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 108 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The idea in one line: html in, Response out
The repository description is the pitch: <html> as a Response. The README says mono-jsx is a JSX runtime that renders the html element to a Response object.
So the shape of a mono-jsx handler is this. You write a fetch handler that returns an <html> JSX element, and the runtime turns it into a Response. There is no render call, no createElement chain, no component library.
// app.tsx
export default {
fetch: (req) => (
<html>
<h1>Welcome to mono-jsx!</h1>
</html>
)
}The project positions itself as yet another JSX runtime for server-side rendering, and the difference from React is not the syntax but what happens at the edges. React's server rendering produces a string or a stream of markup that you then wrap in a Response yourself. mono-jsx takes the whole document element and produces the Response.
The feature list in the README is worth reading as a manifest of what the author thinks a small server framework should contain: no build step needed, lightweight at 12KB gzipped with zero dependencies, signals as reactive primitives, use web components with no virtual DOM, complete Web API TypeScript definitions, streaming rendering, a built-in router in SPA mode, a built-in remote procedure call API, session storage, htmx integration, and universality across Node.js, Deno, Bun, Cloudflare Workers and similar runtimes.
That is a lot for 12KB, which tells you the packaging is doing work. A lot of it is web platform primitives rather than machinery: signals and web components are runtime features the runtime does not have to implement, and the RPC and router are thin layers over standard APIs.
The licence is MIT, the primary language is TypeScript, the default branch is main, and there is a playground at val.town/x/ije/mono-jsx.
Standard HTML property names, not React's dialect
The first design decision worth checking is the naming convention, because it is where a JSX runtime either feels familiar or constantly trips you up.
mono-jsx adopts standard HTML property names and avoids React's custom naming conventions. The mapping given in the README is short and complete.
The mapping given in the README is short and complete: className becomes class, htmlFor becomes for, and onChange becomes onInput.
That last one is the one that catches people. React's onChange fires on every keystroke, which is not what onInput means in the DOM, so a port from React changes behaviour silently rather than erroring. The point of the runtime is that your markup is real HTML, so the attribute names are the ones a browser understands, and a custom element or a plain DOM script will work with it.
The related decision is that you use web components rather than a virtual DOM. The README lists use web components, no virtual DOM as a feature. That means the reactive unit is the browser's own component model, and the server renders markup that the browser then upgrades. No diffing happens, because there is no tree to diff.
Composition of the class attribute is unusually flexible for a runtime that claims to follow standard HTML. You can pass an array of strings, objects and expressions.
<div
class={[
"container box",
isActive && "active",
{ hover: isHover },
]}
/>;The object form maps keys to conditional inclusion, so hover: isHover adds the hover class only when the flag holds. That is a small feature that removes the usual need for a classnames helper, and it keeps the zero-dependency claim intact.
CSS in the style prop, including nesting and media queries
The style prop is where mono-jsx departs from the usual React model most visibly, and it is the part that makes the no-build-step claim meaningful.
The README states that mono-jsx supports pseudo classes, pseudo elements, media queries and CSS nesting in the style property. Not object styles with pseudo selectors bolted on afterwards, but CSS nesting directly in the style object.
<a
style={{
display: "inline-flex",
gap: "0.5em",
color: "black",
"::after": { content: "↩️" },
":hover": { textDecoration: "underline" },
"@media (prefers-color-scheme: dark)": { color: "white" },
"& .icon": { width: "1em", height: "1em" },
}}
>
<img class="icon" src="link.png" />
Link
</a>;The nesting example, & .icon, targets a descendant from within the same style object rather than requiring a separate stylesheet rule. The media query example handles the dark colour scheme preference inside the same declaration.
What this buys is that a component is genuinely self-contained. A React component with CSS normally means either inline styles with no pseudo-class support, a CSS-in-JS library, or a CSS module import that needs a build step. mono-jsx's answer is to accept a style object that is closer to a CSS block, and the zero-dependency, no-build-step story follows from that choice.
The trade-off is that the style object is not valid CSS in a type system sense, so the media query and nesting keys are string literals in an object, which means a typo in a selector is a silent no-op rather than a compile error. The README does not describe any validation for those keys, so treat them as strings you have to check.
View Transitions are supported through the same mechanism, using the viewTransition prop on show, hidden, switch, component and router elements, with the transition name set either as a prop value or through viewTransitionName in the style property.
Signals, slots and the components mono-jsx adds
Reactivity in mono-jsx is built on signals, and the README lists signals as reactive primitives. The examples show the pattern clearly: a component declares a signal, and a handler mutates it, and the view updates.
function App(this: FC<{ show: boolean }>) {
return (
<div
style={{
"@keyframes fade-in": { from: { opacity: 0 }, to: { opacity: 1 } },
"@keyframes fade-out": { from: { opacity: 1 }, to: { opacity: 0 } },
"::view-transition-group(fade)": { animationDuration: "0.3s" },
"::view-transition-old(fade)": { animation: "0.3s ease-in both fade-out" },
"::view-transition-new(fade)": { animation: "0.3s ease-in both fade-in" },
}}
>
<show when={this.show} viewTransition="fade">
<h1>Hello world!</h1>
</show>
<button onClick={() => this.show = !this.show}>Toggle</button>
</div>
)
}The this typing on the function is the signal-based reactivity idiom, where component state lives on the instance rather than in a hook, and the assignment in the click handler is what triggers the update. The show component is a conditional, and the viewTransition prop ties it to the View Transitions API so the toggle animates rather than snapping.
The slot element replaces React's children property, and named slots are supported with the name prop.
function Container() {
return (
<div class="container">
{/* Default slot */}
<slot />
{/* Named slot */}
<slot name="desc" />
</div>
)
}Using the platform's own slot element rather than a props.children convention is the same philosophy as using standard HTML attribute names, and it means slotted content works with plain CSS and with the browser's own rendering rules.
The runtime also adds elements you would otherwise write by hand: show, hidden and switch for conditional rendering, component for dynamic components, and router for the built-in SPA-mode router. Session storage and the RPC API are part of the same package.
The README notes that you can also set the view transition name in the style property with the viewTransition prop set to true, and that you can set the viewTransition prop on an html element which contains signal children, so a heading that changes with a signal can animate on its own.
Installing it and pointing your compiler at it
The install is one package manager command, and the runtime support is broad: all modern JavaScript runtimes including Node.js, Deno, Bun and Cloudflare Workers.
# Node.js, Cloudflare Workers, or other node-compatible runtimes
npm i mono-jsx
# Deno
deno add npm:mono-jsx
# Bun
bun add mono-jsxThen the compiler needs to know that mono-jsx is your JSX runtime. The configuration goes in tsconfig.json, or deno.json for Deno.
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "mono-jsx"
}
}The jsx value stays react-jsx, which is the automatic runtime mode, and jsxImportSource is what redirects it. There is also a setup command that writes the configuration for you.
# 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 setupThe truly zero-configuration path uses a pragma directive, without installing mono-jsx or touching package.json, tsconfig or node_modules, because the runtime installs it for you.
// Deno, Valtown
/** @jsxImportSource https://esm.sh/mono-jsx */
// Bun
/** @jsxImportSource mono-jsx */It is a good fit for a quick experiment, and the README points to a playground at val.town/x/ije/mono-jsx.
Running the app depends on the runtime. Deno and Bun can serve the tsx file directly, while Cloudflare Workers uses wrangler dev.
deno serve app.tsx
bun app.tsxnpx wrangler dev app.tsxNode.js is the exception and the README is explicit about it: Node does not support JSX syntax or declarative fetch servers, and the recommendation is to use mono-jsx with srvx and tsx, with tsx as the JSX loader.
# npm i srvx tsx
npx srvx --import tsx app.tsxSo on Node you get a working setup, but it is a different command and an extra dependency pair, which is worth weighing against the other runtimes.
The root html constraint, and where mono-jsx is the wrong tool
There are three limits, and the first one is stated by the project itself in a note.
The first is that only the 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 mono-jsx.
That constraint shapes what the runtime is for. It renders documents, not fragments, so a page-oriented server is the target. If your mental model is a component library that composes pieces and you expect to mount a widget into an existing page, that does not fit. The same applies to any plan to return an HTML fragment for a partial response or to render a non-HTML payload through JSX.
The second is the Node.js story. Node does not support JSX syntax or declarative fetch servers, so on Node the runtime needs srvx for the server and tsx for the JSX loader. That is two more packages, a different start command, and a build-time loader in a project whose headline feature is that no build step is needed. On Deno and Bun there is no such friction, and on Cloudflare Workers you are already in an environment that understands JSX.
The third is the maturity of the package itself. The version in package.json is 0.10.0-beta.23, the recent tags are v0.10.0-beta.1 from March 2026, v0.9.13 and v0.9.12, and the last push was on 2026-07-07. A 0.x line with a beta suffix in the version means the public interface is still expected to change, and the v0.9 to v0.10 step is where that happens. The MIT licence keeps the legal side simple.
The honest alternative is a mature full-stack framework. React with a server framework, or a meta-framework with routing and data loading built in, gives you a component model with a large ecosystem, established conventions and no version churn, at the cost of a much larger dependency tree and a build step. The difference in approach is the whole stack versus the rendering layer: mono-jsx gives you the render plus a router and RPC in 12KB with nothing else, and you write the rest yourself.
If you need streaming SSR with fine-grained control over the response, or partial rendering, or a component ecosystem with hundreds of packages, mono-jsx is the wrong tool. If you want a single file that renders a page, with signals and a router and nothing else in the tree, that is exactly the shape it was built for, and the choice comes down to whether document-shaped server rendering is what your project actually is.
Editorial conclusion
Adopt mono-jsx if you are building a server-rendered app on Deno, Bun or Cloudflare Workers and want JSX without a framework, since it ships the router, signals and RPC in a 12KB package with no dependencies. Do not adopt it on plain Node.js without also taking srvx and tsx, because Node does not support JSX syntax or declarative fetch servers. Before you start, check that your pages are document-shaped rather than component-shaped, since only the root html element is rendered as a Response.
Frequently asked questions
What is mono-jsx and what does it do?
It is a JSX runtime that renders the html element to a Response object, so a fetch handler can return an <html> JSX element directly. It has no build step, weighs 12KB gzipped with zero dependencies, uses signals as reactive primitives and web components instead of a virtual DOM, and ships streaming rendering, a router in SPA mode, an RPC API and session storage.
How do I install mono-jsx and configure it as my JSX runtime?
Install it with npm i mono-jsx, deno add npm:mono-jsx or bun add mono-jsx, then set "jsx": "react-jsx" and "jsxImportSource": "mono-jsx" in your tsconfig.json or deno.json compiler options. You can also run npx mono-jsx setup to write the configuration automatically.
Does mono-jsx work in Node.js?
It runs on Node.js, but Node does not support JSX syntax or declarative fetch servers, so the README recommends combining mono-jsx with srvx and tsx, where tsx acts as the JSX loader, and starting the app with npx srvx --import tsx app.tsx.
What limitation does mono-jsx place on the fetch handler?
Only the root <html> element is rendered as a Response object, and you cannot return a <div> or any other element directly from the fetch handler. The README describes this as a limitation of mono-jsx.
How does mono-jsx differ from React in property naming and styling?
mono-jsx uses standard HTML property names instead of React's conventions, so className becomes class, htmlFor becomes for, and onChange becomes onInput. Its style property accepts pseudo classes, pseudo elements, media queries and CSS nesting directly, and the class property accepts arrays of strings, objects and expressions.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ije-mono-jsx)Was this useful?