@bioleyl/hxpress
v0.2.0
Published
A type-safe Express + HTMX framework with TSX rendering
Readme
HxPress — Type-safe Express + HTMX Framework with TSX Rendering
HxPress is a lightweight, type-safe framework layer on top of Express.js for building server-rendered HTMX-powered applications. It provides a TSX developer experience — write JSX components that render to HTML on the server, with zero client-side JavaScript and no hydration overhead.
Philosophy
- Type safety first: Strong TypeScript inference via Zod schemas for validated request bodies.
- Simplicity: No virtual DOM, no build step for the framework itself, minimal abstractions.
- HTMX-first: Automatic detection of
HX-Requestheaders to serve full pages or partial fragments.
Table of Contents
- Installation
- Quick Start
- TSX Setup
- TSX Rendering
- Routing
- HTMX Rendering Behavior
- Typed Actions & Validation
- Request Context
- Plugin System
- API Reference
- Example Application
Installation
npm install @bioleyl/hxpress express zodPeer dependencies: express (v4 or v5), zod (v3.22+ or v4).
HxPress publishes both ESM and CommonJS builds with full TypeScript type declarations.
Quick Start
A minimal HxPress application:
import express from "express";
import { createHxpress } from "@bioleyl/hxpress";
const app = express();
const hx = createHxpress(app);
// GET route — renders a full page for normal requests, partial for HTMX.
hx.get("/invoices", {
onPage: () => (
<html>
<body>
<h1>Invoices</h1>
<table id="invoice-list"></table>
</body>
</html>
),
onPartial: () => <table id="invoice-list">
<tr><td>Invoice A — $250.00</td></tr>
</table>,
});
hx.apply(); // Wire routes onto Express
app.listen(3000);TSX Setup
To use TSX with HxPress, set these TypeScript compiler options:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@bioleyl/hxpress",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}Notes:
- Use
.tsxfor files that contain JSX. - HxPress exposes
@bioleyl/hxpress/jsx-runtimeand@bioleyl/hxpress/jsx-dev-runtime, so no React dependency is required.
TSX Rendering
HxPress provides a lightweight JSX runtime that converts JSX trees to HTML strings on the server. No React dependency, no virtual DOM, no hydration.
JSX Elements
Prefer regular TSX syntax:
const element = (
<div class="card">
<h1>Hello World</h1>
</div>
);Low-level factory form (rarely needed, mostly for runtime internals):
import { jsx } from "@bioleyl/hxpress";
const element = jsx("div", { class: "card" }, "key", "Hello World");Components
Components are plain functions that return JSX nodes:
import type { PropsWithChildren } from "@bioleyl/hxpress";
function Card(props: PropsWithChildren<{ title: string }>) {
return (
<article class="card">
<h2>{props.title}</h2>
{props.children}
</article>
);
}
// Usage:
<Card title="My Card">
<p>Card content goes here.</p>
</Card>You can also use HxFC<Props> / AsyncHxFC<Props> as shorthand component aliases when you want children included automatically.
Fragments & Arrays
Use fragments to group elements without adding a wrapper DOM node:
<>
<span />
<em />
</>Arrays render their items sequentially. Nested arrays are flattened automatically:
["a", ["b", "c"]] // → abcAsync Components
Components can be async — HxPress detects Promise returns and awaits them:
async function UserProfile(props: { userId: number }) {
const user = await fetchUserById(props.userId);
return (
<div>
<h1>{user.name}</h1>
</div>
);
}HTML Escaping
All text content and attribute values are automatically escaped to prevent XSS. The escapeHtml and escapeAttr functions are also exported for manual escaping when needed:
import { escapeHtml } from "@bioleyl/hxpress";
<p>{escapeHtml(userInput)}</p>; // Safe against <script> injectionRouting
GET Routes
Register a GET route with onPage (full page) and optionally onPartial (HTMX fragment):
hx.get("/invoices", {
onPage: () => (
<html>
<body>
<table></table>
</body>
</html>
),
onPartial: () => <table></table>,
});POST Routes (Traditional)
POST routes without validation use the same onPage/onPartial pattern:
hx.post("/submit", {
onPage: (_ctx) => <form></form>,
onPartial: (_ctx) => <p>Submitted!</p>,
});HTMX Rendering Behavior
onPage vs onPartial
HxPress automatically detects whether a request is an HTMX request by checking the HX-Request: true header. When it is, HxPress calls onPartial; otherwise, it calls onPage.
hx.get("/items/:id", {
// Called for normal browser navigation → full page with layout wrappers
onPage: (ctx) => <html><body>Item {ctx.params.id}</body></html>,
// Called when HX-Request header is present → only the partial content
onPartial: (ctx) => <div class="item-detail">Details for item {ctx.params.id}</div>,
});Automatic HTMX Detection
The detection checks req.headers["hx-request"] and matches it case-insensitively against "true". This works with both GET and POST routes.
Typed Actions & Validation
HxPress supports action-style POST routes that validate request bodies using Zod schemas, then pass the validated data to an action handler with full TypeScript inference.
Zod Schema Inference
import { z } from "zod";
import { createSchema } from "@bioleyl/hxpress";
const CreateInvoiceSchema = z.object({
title: z.string().min(1),
amount: z.number(),
});
// The schema is wrapped to provide explicit _input/_output type markers.Action Handlers
The action handler receives a context where ctx.body is typed from the Zod schema:
hx.post("/invoices", {
schema: CreateInvoiceSchema,
action(ctx) {
// ctx.body has type { title: string; amount: number } — inferred automatically!
const invoice = createInvoice({
title: ctx.body.title, // ← TypeScript knows this is a string.
amount: ctx.body.amount, // ← TypeScript knows this is a number.
});
return { html: <tr><td>{invoice.id}</td></tr> };
},
});Error Handling with onError
When validation fails, the optional onError callback receives the raw (invalid) body and structured errors:
hx.post("/invoices", {
schema: CreateInvoiceSchema,
action(ctx) { /* ... */ },
onError(_ctx, errors) {
// errors is ValidationError[] with message + path for each issue.
return { html: <ul>{errors.map(e => <li>{e.message}</li>)}</ul> };
},
});HTMX Response Headers
Action handlers can set HTMX response headers via the ActionResponse object:
| Field | Purpose | Header Set |
|-------|---------|------------|
| html | Rendered HTML body (JSX node or string) | — |
| trigger | Client-side event trigger name | HX-Trigger |
| redirect | Redirect URL | HX-Retarget, Express redirect |
action(ctx) {
return { html: <tr>...</tr>, trigger: "invoiceCreated" };
}
// Sets HX-Trigger: invoiceCreated header.Request Context
Every handler receives a typed context object with the following properties:
| Property | Type | Description |
|----------|------|-------------|
| ctx.req | express.Request | The raw Express request object |
| ctx.res | express.Response | The raw Express response object |
| ctx.params | Record<string, string> | Route parameters (e.g., { id: "42" }) |
| ctx.query | Record<string, string \| string[]> | Query string key-value pairs |
| ctx.body | T (from Zod schema) or undefined | Validated body in action routes |
| ctx.validationErrors | ValidationError[] \| undefined | Validation errors for form re-rendering |
| ctx.isHxRequest() | boolean | Whether the request has HX-Request: true |
| ctx.render(node) | (node) => Promise<void> | Render a JSX node and send it as response |
hx.get("/users/:id", {
onPage(ctx) {
console.log(ctx.params.id); // "42"
console.log(ctx.query.q ?? ""); // query string value
return <div>User {ctx.params.id}</div>;
},
});Plugin System
HxPress provides a powerful plugin architecture for extending the framework. Plugins can register middleware, routes, extend context per-request, and hook into the rendering pipeline.
Context Extension
Add custom properties to every request's context:
const userPlugin = createPlugin({
name: "user-plugin",
extendContext() {
return { user: { id: "42", name: "Alice" } };
},
});
// Now ctx.user is available in all handlers.
hx.get("/profile", { onPage: (ctx) => <div>{ctx.user.name}</div> });Lifecycle Hooks
Transform rendered HTML or inspect the rendering pipeline:
const transformPlugin = createPlugin({
name: "transform-plugin",
hooks: {
// Called before each JSX tree is rendered.
beforeRender(ctx, node) { console.log("Rendering:", typeof node); },
// Called after HTML is generated — return a modified string to replace it.
afterRender(_ctx, html) { return `<html><body>${html}</body></html>`; },
},
});Middleware Registration
Register Express middleware that runs before route handlers:
const authPlugin = createPlugin({
name: "auth-plugin",
registerMiddleware() {
return [(req, res, next) => {
if (!isAuthenticated(req)) { res.status(401).send("Unauthorized"); }
else { next(); }
}];
},
});Route Registration
Plugins can add fallback routes:
const healthPlugin = createPlugin({
name: "health-plugin",
registerRoutes() {
return [{ path: "/healthz", method: "get" as const, onPage: () => <p>OK</p> }];
},
});Registering Plugins
Pass plugins at app creation time:
const hx = createHxpress(app, {
plugins: [userPlugin, authPlugin, healthPlugin],
});API Reference
| Export | Type | Description |
|--------|------|-------------|
| createHxpress(app, options?) | (app) => Hxpress | Create a framework instance attached to an Express app. |
| jsx(tag, props?, maybeKey?, ...children) | JsxElement | Low-level JSX factory used by the TSX transform. |
| Fragment({ children }) | JsxFragment | Group multiple nodes without adding a wrapper DOM element. |
| render(node) | (node) => Promise<string> | Convert a JSX tree to an HTML string. |
| escapeHtml(str) | (str) => string | Escape special characters for safe text content inclusion. |
| escapeAttr(str) | (str) => string | Escape special characters for safe attribute value inclusion. |
| PropsWithChildren<Props> | Props & { children?: HxChildren } | Add optional children to a component props shape. |
| HxFC<Props> | (props) => JsxNode | Synchronous component alias with children included in props. |
| AsyncHxFC<Props> | (props) => Promise<JsxNode> | Async component alias with children included in props. |
| createPlugin(def) | (def) => HxPlugin | Create a typed plugin from a definition object. |
| validateRequest(schema) | (schema) => RequestHandler | Express middleware that validates body against a Zod schema. |
| createSchema(zodSchema) | (zodSchema) => ValidatedSchema<T> | Wrap a Zod schema with explicit _input/_output type markers. |
| createHxContext(req, res) | (req, res) => HxContext | Create a typed request context from Express req/res objects. |
Example Application
A complete working example is included in the repository:
cd examples/basic-app
npm install
npm run devThe demo implements an invoice management app with:
- TSX components:
InvoiceRow,InvoiceTable,CreateInvoiceForm,CardSection(typed withPropsWithChildren) - HTMX behavior: Full pages for navigation, partials for HTMX swaps
- Typed actions: Zod-validated POST routes with typed
ctx.body - Plugins: Analytics plugin (context extension + HTML transformation) and logger middleware
License
MIT © bioleyl
