First things, first... What the hack does HSX stand for? I'll say it's 'HTML for Server-Side eXtensions'. :0
But, honestly, I prefer: HTMX Slaps Xtremely :)
SSR-only JSX/TSX renderer for Deno that hides HTMX-style attributes away during the rendering process, and compiles them to hx-* attributes.
Disclaimer: this was a quick hack in my free time, held together by vibe coding and espresso. I like it a lot, but consider it an early release. I feel it is getting better (a lot)
- SSR-only - No client runtime. Outputs plain HTML.
- HTMX as HTML - Write
get,post,target,swapas native attributes - Type-safe routes - Branded
Route<Path>types with automatic parameter inference - Co-located components -
hsxComponent()bundles route + handler + render - Page guardrails -
hsxPage()enforces semantic, style-free layouts - Branded IDs -
id("name")returnsId<"name">typed as"#name" - Auto HTMX injection -
<script src="/static/htmx.js">injected when needed - No manual hx-* - Throws at render time if you write
hx-getdirectly
deno add jsr:@srdjan/hsxOr import directly:
import { id, render, route } from "jsr:@srdjan/hsx";HSX is a monorepo with two packages:
// Core - JSX rendering, type-safe routes, hsxComponent, hsxPage
import { Fragment, hsxComponent, hsxPage, id, render, route } from "jsr:@srdjan/hsx";
// Styles - ready-to-use CSS with theming support
import { HSX_STYLES_PATH, hsxStyles } from "jsr:@srdjan/hsx-styles";Install individually:
deno add jsr:@srdjan/hsx
deno add jsr:@srdjan/hsx-stylesFor backward compatibility, subpath exports still work:
import { Fragment, id, render, route } from "jsr:@srdjan/hsx/core";
import { HSX_STYLES_PATH, hsxStyles } from "jsr:@srdjan/hsx/styles";Clone and import using workspace package names:
import { hsxComponent, hsxPage, id, render, route } from "@srdjan/hsx";Note: This shows the low-level API using
route(). For most projects, use the hsxComponent pattern below instead.
import { id, render, route } from "@srdjan/hsx";
const routes = {
todos: route("/todos", () => "/todos"),
};
const ids = {
list: id("todo-list"),
};
function Page() {
return (
<html>
<head>
<title>HSX Demo</title>
</head>
<body>
<form post={routes.todos} target={ids.list} swap="outerHTML">
<input name="text" required />
<button type="submit">Add</button>
</form>
<ul id="todo-list">{/* items */}</ul>
</body>
</html>
);
}
Deno.serve(() => render(<Page />));Output HTML:
<html>
<head><title>HSX Demo</title></head>
<body>
<form hx-post="/todos" hx-target="#todo-list" hx-swap="outerHTML">
<input name="text" required>
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
<script src="/static/htmx.js"></script>
</body>
</html>import { hsxComponent } from "jsr:@srdjan/hsx";
export const TodoList = hsxComponent("/todos", {
methods: ["GET", "POST"],
async handler(req) {
if (req.method === "POST") {
const form = await req.formData();
await addTodo(String(form.get("text")));
}
return { todos: await getTodos() }; // must match render props
},
render({ todos }) {
return (
<ul id="todo-list">
{todos.map((t) => <li key={t.id}>{t.text}</li>)}
</ul>
);
},
});
// Use as route in JSX
<form post={TodoList} target="#todo-list" swap="outerHTML" />;
// Use as handler in your server
if (TodoList.match(url.pathname)) return TodoList.handle(req);TypeScript enforces that handler returns the same shape that render expects.
methods defaults to ["GET"]; set fullPage: true when your render function
returns a full document instead of a fragment.
Choose one style: Use either
hsxComponent(recommended) or the low-levelroute()API, but don't mix them in the same project.
hsxPage() wraps a render function that returns a complete HTML document
and validates that:
- The root is
<html>with<head>then<body> - Semantic tags (header/main/section/article/h1-h6/p/ul/ol/li/etc.) have no
classor inlinestyle <style>tags live in<head>; CSS belongs there, not inline- Composition stays within semantic HTML + HSX components
import { hsxComponent, hsxPage } from "jsr:@srdjan/hsx";
const Widget = hsxComponent("/data", {
handler: () => ({ message: "Hi" }),
render: ({ message }) => <p>{message}</p>,
});
const Page = hsxPage(() => (
<html lang="en">
<head>
<title>Guarded Page</title>
<style>{"body { font-family: system-ui; }"}</style>
</head>
<body>
<header>
<h1>Welcome</h1>
</header>
<main>
<section>
<div class="card">
<Widget.Component />
</div>
</section>
</main>
</body>
</html>
));
Deno.serve(() => Page.render());| HSX Attribute | Renders To | Description |
|---|---|---|
get |
hx-get |
HTTP GET request |
post |
hx-post |
HTTP POST request |
put |
hx-put |
HTTP PUT request |
patch |
hx-patch |
HTTP PATCH request |
delete |
hx-delete |
HTTP DELETE request |
target |
hx-target |
Element to update |
swap |
hx-swap |
How to swap content |
trigger |
hx-trigger |
Event that triggers request |
vals |
hx-vals |
Additional values (JSON) |
headers |
hx-headers |
Custom headers (JSON) |
behavior="boost" |
hx-boost="true" |
Enable boost mode (<a> only) |
Supported elements: form, button, a, div, span, section,
article, ul, tbody, tr
Use route() to create type-safe routes with automatic parameter extraction:
const routes = {
users: {
list: route("/users", () => "/users"),
detail: route("/users/:id", (p) => `/users/${p.id}`),
posts: route(
"/users/:userId/posts/:postId",
(p) => `/users/${p.userId}/posts/${p.postId}`,
),
},
};
// In JSX - params are type-checked:
<button get={routes.users.detail} params={{ id: 42 }}>View</button>;
// Renders: <button hx-get="/users/42">View</button>Use id() to create type-safe element references:
const ids = {
list: id("todo-list"), // Type: Id<"todo-list"> = "#todo-list"
count: id("item-count"),
};
// In JSX:
<ul id="todo-list">...</ul>
<button get="/todos" target={ids.list} swap="innerHTML">Refresh</button>
// Renders: <button hx-get="/todos" hx-target="#todo-list" hx-swap="innerHTML">Create reusable wrapper components that pass through HSX attributes for cleaner JSX:
import type { HsxSwap, Urlish } from "jsr:@srdjan/hsx";
function Card(props: {
children: unknown;
title?: string;
get?: Urlish;
trigger?: string;
swap?: HsxSwap;
}) {
return (
<div class="card" get={props.get} trigger={props.trigger} swap={props.swap}>
{props.title && <h2>{props.title}</h2>}
{props.children}
</div>
);
}
function Subtitle(props: { children: string }) {
return <p class="subtitle">{props.children}</p>;
}Usage:
function Page() {
return (
<main>
<h1>Dashboard</h1>
<Subtitle>Content loads lazily</Subtitle>
<Card
get={routes.stats}
trigger="load"
swap="innerHTML"
title="Statistics"
>
<LoadingSkeleton />
</Card>
<Card title="Team Members">
<UserList />
</Card>
</main>
);
}This pattern keeps your page components clean while maintaining full access to
HSX attributes. See the examples/*/components.tsx files for more examples.
Add this to your deno.json:
JSX intrinsic element types (<div>, <form>, <button>, etc.) are
automatically included via the jsx-runtime import—no additional type
configuration needed.
HSX injects <script src="/static/htmx.js"> when HTMX is used. You must serve
it:
if (url.pathname === "/static/htmx.js") {
const js = await Deno.readTextFile("./vendor/htmx/htmx.js");
return new Response(js, {
headers: { "content-type": "text/javascript; charset=utf-8" },
});
}HSX includes an optional CSS module with a default theme and dark variant:
import {
HSX_STYLES_PATH,
hsxStyles,
hsxStylesDark,
} from "jsr:@srdjan/hsx-styles";
// Serve the styles
if (url.pathname === HSX_STYLES_PATH) {
return new Response(hsxStyles, {
headers: { "content-type": "text/css; charset=utf-8" },
});
}
// In your page head
<link rel="stylesheet" href={HSX_STYLES_PATH} />;Exports:
hsxStyles- Default light theme (indigo accent)hsxStylesDark- Dark theme variantHSX_STYLES_PATH- Default path:/static/hsx.css
Customization: Override CSS variables in your page:
<style>{`:root { --hsx-accent: #10b981; --hsx-bg: #f0fdf4; }`}</style>;Available variables: --hsx-accent, --hsx-bg, --hsx-surface,
--hsx-border, --hsx-text, --hsx-muted, --hsx-error, --hsx-success,
spacing (--hsx-space-*), and radius (--hsx-radius-*).
Renders JSX to an HTTP Response.
render(<Page />, {
status: 200, // HTTP status code
headers: {}, // Additional response headers
maxDepth: 100, // Max nesting depth (DoS protection)
maxNodes: 50000, // Max node count (DoS protection)
injectHtmx: undefined, // true/false to force, undefined for auto
});Renders JSX to an HTML string.
const html = renderHtml(<Page />, {
maxDepth: 100,
maxNodes: 50000,
injectHtmx: undefined,
});Creates a type-safe route. Path parameters (:param) are automatically
extracted.
const r = route("/users/:id", (p) => `/users/${p.id}`);
// r.path = "/users/:id"
// r.build({ id: 42 }) = "/users/42"Creates a branded element ID with # prefix.
const listId = id("todo-list");
// Type: Id<"todo-list">
// Value: "#todo-list"JSX Fragment for grouping elements without a wrapper.
<Fragment>
<li>One</li>
<li>Two</li>
</Fragment>;Co-locates a route, request handler, and render function.
const Comp = hsxComponent("/items/:id", {
methods: ["GET", "DELETE"], // defaults to ["GET"]
fullPage: false, // default: return fragment Response
status: 200,
headers: { "x-powered-by": "hsx" },
handler: async (_req, params) => ({
item: await getItem(params.id),
}),
render: ({ item }) => <div>{item.name}</div>,
});
// Works anywhere a Route does:
<button delete={Comp} params={{ id: 42 }} target="#row-42" />;Run examples with deno task:
| Example | Command | Description |
|---|---|---|
| Todos | deno task example:todos |
Full CRUD with partial updates |
| Active Search | deno task example:active-search |
Live search as you type |
| Lazy Loading | deno task example:lazy-loading |
Deferred content loading |
| Form Validation | deno task example:form-validation |
Server-side validation |
| Polling | deno task example:polling |
Live dashboard with intervals |
| Tabs & Modal | deno task example:tabs-modal |
Tab navigation and modals |
| HSX Components | deno task example:hsx-components |
Co-located route + handler + render |
| HSX Page | deno task example:hsx-page |
Semantic full-page with hsxPage guardrails |
| Low-Level API | deno task example:low-level-api |
Manual render/renderHtml without hsxPage/hsxComponent |
| Index of examples | examples/README.md |
Quick guide to pick the right example |
- HTML escaping - All text content and attributes are escaped (XSS prevention)
- Raw text elements -
<script>and<style>children are NOT escaped. Never pass user input. - No manual hx-* - Throws at render time. Use HSX aliases instead.
- DoS protection - Optional
maxDepthandmaxNodeslimits
packages/
hsx/ # Core package (@srdjan/hsx)
mod.ts # Main entry point
jsx-runtime.ts # Minimal JSX runtime (compiler requirement)
render.ts # SSR renderer with HTMX injection
hsx-normalize.ts # HSX to hx-* attribute mapping
hsx-types.ts # Route, Id, HsxSwap, HsxTrigger types
hsx-jsx.d.ts # JSX type declarations
hsx-component.ts # hsxComponent factory (route + handler + render)
hsx-page.ts # hsxPage guardrail for full-page layouts
hsx-styles/ # Styles package (@srdjan/hsx-styles)
mod.ts # Main entry point (CSS themes)
examples/
todos/ # Full todo app example
active-search/ # Search example
lazy-loading/ # Lazy load example
form-validation/ # Form validation example
polling/ # Polling example
tabs-modal/ # Tabs and modal example
hsx-components/ # HSX Component pattern example
hsx-page/ # hsxPage full-page guardrail example
low-level-api/ # Manual render/renderHtml without hsxPage/hsxComponent
vendor/htmx/
htmx.js # Vendored HTMX v4 (alpha)
docs/
USER_GUIDE.md # Comprehensive user guide
HSX_OVERVIEW.md # Architecture overview
HTMX_INTEGRATION.md # HTMX integration details
MIT - see LICENSE
{ "imports": { "hsx/jsx-runtime": "jsr:@srdjan/hsx/jsx-runtime" }, "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hsx" } }