Quick start
JSX and TSX in Node.js without React. One HTML factory for server and browser.
Requirements: Node.js ≥20.16 · ESM
01Installation
02Your first component
Create Page.jsx, Counter.jsx and app.js in the same directory.
import Counter from './Counter.jsx';
export default ({ title }) => <main>
<h1>{title}</h1>
<Counter count={3} />
</main>;export default ({
count,
label = 'One component. Two environments.',
buttonLabel = 'Add one',
}) => (
<div class="client-counter">
<p>{label}</p>
<output aria-live="polite">{count}</output>
<button type="button" data-increment>
{buttonLabel}
</button>
</div>
);import 'jtsx-loader';
// Registration completes before this dynamic import.
const { default: Page } = await import('./Page.jsx');
const html = String(Page({ title: 'Hello JSX' }));
console.log(html);<main><h1>Hello JSX</h1><div class="client-counter"><p>One component. Two environments.</p><output aria-live="polite">3</output><button type="button" data-increment="true">Add one</button></div></main>03Run
node app.jsHTML appears in your terminal. To serve the page in a browser, connect Express or Fastify.
Requires Node.js ≥20.16 and ESM ("type": "module" in package.json). In Node.js, the loader handles .jsx/.tsx imports and transforms syntax with esbuild. For the browser, bundle components into ordinary JavaScript.
Run without --import
Register the loader directly in your JavaScript entry point.
Node.js first needs a handler for JSX/TSX imports. Registration installs that handler once per process. Choose one approach: register in app.js before a dynamic import, use a separate bootstrap.js, or pass --import when starting Node.
Importing "jtsx-loader" registers the loader; the same module exports raw and renderToString. For explicit registration, use register.js as shown below. Load .jsx/.tsx with await import() afterwards. A static template import in the same entry file is linked too early, even if written below registration.
bootstrap.js is a small entry file: it registers the loader and then starts server.js. That lets server.js use import Page from "./Page.jsx". Find server.js in “Express & Fastify”. Use jtsx-loader/runtime.js for helpers without registration, including inside jtsx.config.js to avoid a loading cycle.
import 'jtsx-loader/register.js';
await import('./server.js');node bootstrap.js
# Or the original preload path:
node --import jtsx-loader server.jsFactory in the browser
One JSX component for frontend and backend.
Use Counter.jsx from the quick start and the three files below: client.jsx, build-client.mjs and index.html in one directory. Install esbuild, run the build and serve this directory with your local HTTP server. Open index.html over HTTP: the button updates the counter. After changing JSX, rebuild and refresh the page.
Counter.jsx from the quick start is already server-rendered below. The client bundle imports the same file and updates it in the browser. Click the button: the counter changes without a server request or React.
Import the factory from jtsx-loader/browser.js. This entry does not read Node configuration, register loader hooks or require Node polyfills. It exports _jsx, _jsxFragment, _jsxUtils, raw, renderToString and createFactory(options). Root imports also select browser.js in bundlers supporting the browser condition; the explicit path makes configuration predictable.
Compile JSX/TSX ahead of time: browsers do not parse JSX syntax themselves. Set jsxFactory, jsxFragment and inject in esbuild as shown. No React or ReactDOM installation is needed. esbuild runs at build time; the client bundle contains your components and the factory.
The factory produces HTML; your code owns state, DOM and events. This example uses innerHTML and a delegated addEventListener on a stable container. It re-renders rather than hydrating or diffing a virtual DOM: replacing content resets nested DOM state and focus. Choose a suitable DOM update strategy for more complex updates.
Shared components should use data and cross-platform imports only. Keep node:fs on the server and document/window in the client entry. Configure browser factories through createFactory(options), not jtsx.config.js. Await async component calls explicitly, or inject browserAsync.js for automatic nested resolution and use renderToString(await Page()). Existing factory/jsxFactory.js and factory/asyncFactory.js package imports also select client adapters under the browser condition.
One component. Two environments.
import { renderToString } from 'jtsx-loader/browser.js';
import Counter from './Counter.jsx';
const root = document.querySelector('#client-demo');
const ru = root.dataset.lang === 'ru';
let count = 0;
function render() {
root.innerHTML = renderToString(
<Counter
count={count}
label={
ru
? 'Один компонент. Две среды.'
: 'One component. Two environments.'
}
buttonLabel={ru ? 'Добавить один' : 'Add one'}
/>,
);
}
// Keep the listener on the stable container when replacing its content.
root.addEventListener('click', (event) => {
if (event.target.closest('[data-increment]')) {
count += 1;
render();
}
});
render();import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
await build({
entryPoints: ['client.jsx'],
bundle: true,
platform: 'browser',
format: 'esm',
target: 'es2020',
jsxFactory: '_jsx',
jsxFragment: '_jsxFragment',
inject: [fileURLToPath(import.meta.resolve('jtsx-loader/browser.js'))],
outfile: 'dist/client.js',
});npm install --save-dev esbuild
node build-client.mjs<div id="client-demo" data-lang="en"></div>
<script type="module" src="./dist/client.js"></script>import { createFactory } from 'jtsx-loader/browser.js';
export const { _jsx, _jsxFragment, _jsxUtils } = createFactory({
rewriteReactAttrs: true,
});
// Use this module as the bundler inject entry instead of browser.js.Components & layouts
Ordinary functions, props and composition.
A component is a function receiving a props object and returning JSX. <Layout title="Catalog">...</Layout> passes title and nested content as children. The Layout below defines a shared HTML document; the catalog component fills its content. To obtain a string, call renderToString(await Page({ items })) in your server code.
Props reach a component unescaped: values remain data until tag serialization. children is [], one value or an array. Arrays flatten recursively without extra spaces. null, undefined and booleans are omitted; 0 and bigint remain. Add spaces explicitly.
Fragments add no DOM wrapper. Arbitrary object children throw: select a field or explicitly use JSON.stringify. JSX does not install browser event handlers; load client JavaScript separately.
const Layout = ({ title, children }) => <html lang="en">
<head><title>{title}</title></head>
<body>{children}</body>
</html>;
export default ({ items }) => <Layout title="Catalog">
<h1>Catalog</h1>
<ul>{items.map(item => <li>{item.name}</li>)}</ul>
{items.length === 0 && <p>No items</p>}
<>{'A'}{' '}{'B'}</>
</Layout>;Async rendering
Explicit await, or a factory that awaits nested components.
Declare a component async when it loads data from an API or a file. For a single async component, explicit await is enough and needs no config change. To use nested <Child /> without awaiting each call manually, select the async factory in jtsx.config.js. These are two alternative approaches shown below.
The default factory is synchronous: use {await Child()} for an async component inside a template. A Promise passed to children produces an instruction to await it. Alternatively, select asyncFactory.js below.
The async factory returns a Promise of rendered JSX markup and resolves nested arrays and Promises concurrently in source order. Nested failures reach the outer await. Attribute values and __raw/__escape are not automatically awaited: resolve them first.
const Child = async () => <b>Ready</b>;
export default async () => <main>{await Child()}</main>;export default {
injectFactory: true,
importFactory: "import { _jsx, _jsxFragment, _jsxUtils } from 'jtsx-loader/factory/asyncFactory.js';",
};const Child = async () => <b>{await Promise.resolve('<Ready>')}</b>;
export default () => <main>
<Child />
{[Promise.resolve('A'), ['B', null, false, 0]]}
</main>;import { renderToString } from 'jtsx-loader';
const { default: Page } = await import('./async.jsx');
console.log(renderToString(await Page()));node app-async.jsAttributes & CSS
Native HTML names and explicit serialization rules.
Use ordinary class directly in JSX/TSX: <div class="card">. You do not need className or extra configuration, on either the server or the browser. For compatibility, className is also supported and always becomes class.
Other React-style names warn by default; rewriteReactAttrs: true enables replacement. style accepts a string or an object with native CSS names. style={null} is omitted.
true serializes as "true", false is omitted, null becomes "null", and undefined creates a bare attribute. Therefore use the string "false" for aria-expanded. Function-valued attributes are ignored with a warning.
attributeParser is selected by the prefix before a colon and returns a complete trusted HTML fragment. It must escape its own values. Attribute names should come from template code.
<label class="field" for="name">Name</label>
<input id="name" value={'"<&'} disabled={true} />
<div style={{ color: 'red', '--gap': '8px' }} />import { escapeHtml } from 'jtsx-loader/factory/jsxUtils.js';
export default {
attributeParser: {
ac: (name, value) =>
`data-${name.replaceAll(':', '-')}="${escapeHtml(value)}"`,
},
};Configuration
jtsx.config.js
Basic usage needs no configuration file. To change options, create jtsx.config.js beside package.json and export an object with export default. Start Node from that directory. Restart the process after changing configuration: ?reload refreshes templates rather than reconfiguring the loader.
Configuration is read from process.cwd(), not the template directory. A missing file is optional. A broken existing file warns and falls back to defaults; JTSX_STRICT_CONFIG=1 makes it fail. Configuration can run in separate Node contexts: avoid side effects.
When setting esbuildTransformConfig, use injectFactory: true or import the factory manually. Do not combine a manual import of the same names with forced injection. esbuildTransformConfig accepts transform options including minify and target.
| escapeChildren | true | Escape text; return a JSX wrapper |
|---|---|---|
| escapeAttributes | true | Escape ordinary attribute values |
| injectFactory | 'legacy' | Inject without esbuildTransformConfig; true always, false manually |
| importFactory | factory/jsxFactory.js | Import source for _jsx, _jsxFragment, _jsxUtils |
| esbuildTransformConfig | null | esbuild transform options |
| attributeParser | {} | Attribute prefix callbacks |
| disableAttrWarnings | false | Silence React-name warnings |
| rewriteReactAttrs | false | Rewrite React names to native names |
export default {
injectFactory: true,
esbuildTransformConfig: { minify: true },
};$env:JTSX_STRICT_CONFIG = "1"
node app.jsExpress & Fastify
An HTML string at the HTTP boundary.
Choose one server. For Express, save server.js below and bootstrap.js from “Run without --import” beside Page.jsx and Counter.jsx. Install express, run node bootstrap.js and open http://localhost:3000/. For Fastify, use a separate fastify.js, install fastify and run node fastify.js; this example needs no bootstrap.
On each request, the server calls Page with data, awaits the result and converts it to an HTML string with renderToString(). Pass that string to res.send()/reply.send(): the framework may treat a JSX object as JSON. The handlers below return 404 for an unknown path and 500 if rendering fails.
npm install express
node bootstrap.jsimport express from 'express';
import { renderToString } from 'jtsx-loader/runtime.js';
import Page from './Page.jsx';
const app = express();
app.get('/', async (req, res, next) => {
try {
const html = renderToString(await Page({ title: 'Hello' }));
res.type('html').send(html);
} catch (error) {
next(error);
}
});
app.use((req, res) => res.status(404).send('Not found'));
app.use((error, req, res, next) => {
console.error(error);
if (res.headersSent) return next(error);
res.status(500).send('Internal server error');
});
app.listen(3000);import Fastify from 'fastify';
import { renderToString } from 'jtsx-loader';
const { default: Page } = await import('./Page.jsx');
const app = Fastify({ logger: true });
app.get('/', async (request, reply) => {
const html = renderToString(await Page({ title: 'Hello' }));
return reply.type('text/html').send(html);
});
app.setNotFoundHandler((request, reply) => reply.code(404).send('Not found'));
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
reply.code(500).send('Internal server error');
});
await app.listen({ port: 3000 });npm install fastify
node fastify.jsStatic generation
Render to a file without an HTTP server.
Save static.js beside Page.jsx and Counter.jsx from the quick start. Running node static.js creates dist/index.html and exits. Open the file or deploy dist to static hosting. Run the generator again after changing a template: existing HTML files do not update themselves.
This example needs no --import flag. Do not swallow build errors: unavailable data or a broken template should produce a non-zero exit. Directory creation and writing are explicit. Add html/head/body to Page for a complete document.
import { mkdir, writeFile } from 'node:fs/promises';
import { renderToString } from 'jtsx-loader';
const { default: Page } = await import('./Page.jsx');
await mkdir('dist', { recursive: true });
const html = '<!doctype html>\n' + renderToString(await Page({ title: 'Hello' }));
await writeFile('dist/index.html', html);node static.jsJSX / TSX
Transpilation with esbuild, without type checking.
The loader handles .jsx and .tsx. Ordinary .ts files are delegated to Node, whose support varies by version. Include file extensions in imports. Run type checking separately; a complete JSX namespace and prop typings are not yet shipped. A successful TSX render is not a TypeScript check.
type Props = { title: string; count: number };
export default ({ title, count }: Props) => <section>
<h1>{title}</h1>
<p>{count}</p>
</section>;Development & diagnostics
See template changes and identify the cause of an error.
Node.js caches imported modules in memory. While the server keeps running, an ordinary import of the same path returns the already loaded module. Editing the file and refreshing the browser tab do not themselves refresh that module.
There are two ways to see edits: restart Node.js or import the template again with ?reload in the same process. Automatic browser refresh is a separate task; jtsx-loader does not watch files or send live-reload notifications to the browser.
Where to put ?reload
Append ?reload to the path inside a dynamic import: await import("./Page.jsx?reload"). The file on disk is still named Page.jsx. This is an import parameter handled by jtsx-loader, not a Node command-line flag or a query parameter in the browser address bar. Register the loader before importing the template.
Use exactly ?reload without a value. Each call creates a fresh instance of the template and its ESM dependencies, including nested JSX/TSX components and JS modules. Child imports do not need their own ?reload: refreshing propagates from the parent template. This does not clear CommonJS require.cache.
Run the import again on each refresh. If you save Page in a variable once, calling Page(props) again still invokes the old function. In reload.js below, the import is inside renderPage(), so each call obtains the current template.
import { renderToString } from 'jtsx-loader';
// Development only: import again for each render, including nested components.
export async function renderPage(props) {
const { default: Page } = await import('./Page.jsx?reload');
return renderToString(await Page(props));
}Try refreshing a nested component
Save reload.js and dev-server.js beside Page.jsx and Counter.jsx from the quick start. Run node dev-server.js and open http://localhost:3000/. Edit text in Counter.jsx, save and refresh the page. The response should contain the new text without restarting the server, even when Page.jsx is unchanged. This server uses built-in node:http and needs no Express.
This is a development example. New imports do not remove old instances from memory, and dependencies may execute initialization code again. Restart the process during long sessions; use ordinary imports in production. Restart Node.js after changing jtsx.config.js too.
import { createServer } from 'node:http';
import { renderPage } from './reload.js';
export const server = createServer(async (request, response) => {
if (request.url !== '/') {
response.writeHead(404).end('Not found');
return;
}
try {
const html = await renderPage({ title: 'Live JSX' });
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
response.end(html);
} catch (error) {
console.error(error);
response.writeHead(500).end('Render failed; check the terminal');
}
}).listen(Number(process.env.PORT || 3000), '127.0.0.1', () => {
console.log('Development server: http://localhost:' + server.address().port);
});node dev-server.jsAutomatically restart your server
If the process does not need to stay alive, use nodemon with the ordinary bootstrap.js from the Express example. It watches the listed file extensions and restarts Node after a save. This option needs no ?reload. Refresh the browser after the restart; nodemon does not refresh the tab itself.
npm install --save-dev nodemon
npx nodemon --watch . --ext js,mjs,cjs,json,jsx,tsx --ignore dist/ --ignore build/ bootstrap.jsRun the documentation site itself
These commands are for a clone of the jtsx-loader repository, not a project that installs the package. After npm install, run npm run dev and open http://localhost:3001/. The server restarts when source files change; refresh the browser separately. Use npm start to run without watching.
npm run build generates the complete site in build/: both languages, styles, icons and the client example. The documentation version comes from package.json at build time. Serve build/ as the site root with directory index.html support. npm start -- --write-html still saves only requested pages. JSX sources are not served over HTTP.
In the package repository, npm run patch increments the patch version and npm run bump increments the minor version; both then build the site. They update package.json and package-lock.json without publishing to npm or creating a Git commit/tag. If the build fails, the version remains updated: fix the error and retry npm run build.
npm install
npm run devCommon errors and fixes
| Error | Cause | Action |
|---|---|---|
| ERR_UNKNOWN_FILE_EXTENSION | Template imported before registration | Import "jtsx-loader" first, then await import("./Page.jsx"); or start server.js through bootstrap.js. |
| _jsx is not defined | Factory injection disabled | Set injectFactory: true in jtsx.config.js or import the factory manually. Check esbuildTransformConfig. |
| Tags shown as text | HTML passed as a plain string | Keep child JSX as a JSX value. Use raw(html) for trusted HTML strings; call renderToString before sending the final result. |
| [object Promise] | Async value in legacy mode | await or asyncFactory |
| A nested component stays unchanged | The server uses a cached import | Repeat await import("./Page.jsx?reload") and use the new default export, or restart the process. Refreshing the browser alone is not enough. |
| ERR_MODULE_NOT_FOUND | A package or imported file cannot be found | Install the dependency in this project and check the path, letter case and .jsx/.tsx extension. |
| JTSX TRANSFORM ERROR | Invalid template syntax or transform options | Open the file and line reported in the terminal. Fix JSX/TSX or esbuildTransformConfig and retry. |
Escaping & raw()
Strings are text. raw(value) explicitly permits HTML insertion.
Text and ordinary attribute values are escaped by default. JSX results retain their identity as rendered markup, so nested components are not escaped twice. Call renderToString(await Page(props)) at the HTTP, file or logging boundary. Converting child JSX to a string before insertion loses its markup identity.
raw() returns an immutable wrapper and does not sanitize anything. Only use trusted or previously sanitized HTML. raw() does not bypass escaping in attributes. __raw and __escape remain supported; ordinary text no longer needs __escape. Pre-escaped strings are escaped again.
Escaping does not validate URL protocols, dynamic tag/attribute names, CSS or string event handlers. Do not spread untrusted props wholesale. script/style need context-specific handling: raw() can insert trusted source code, but does not make user JavaScript safe.
import { raw } from 'jtsx-loader';
const text = '<b>Hello</b>';
const Text = () => <p>{text}</p>;
const Markup = () => <p>{raw(text)}</p>;<p><b>Hello</b></p>
<p><b>Hello</b></p>import { raw } from 'jtsx-loader/runtime.js';
// Escape HTML end tags before opting into raw JSON output.
export default ({ value }) => <script type="application/json">
{raw(JSON.stringify(value).replaceAll('<', '\\u003c'))}
</script>;Migration from 0.1.15–0.1.18
You can upgrade directly from 0.1.15. Versions 0.1.16–0.1.18 preserved existing behavior; the current API changes default escaping and the JSX result type. Follow the steps below, then review the changes at each stage.
- Replace direct HTTP/file output with renderToString(await Page(props)).
- Remove manual escapeHtml calls for ordinary children and attributes. Keep escaping inside attributeParser: its output is inserted as a complete attribute fragment.
- Wrap intentional HTML strings in raw(html).
- Do not join/interpolate child JSX into strings before rendering.
- Account for no implicit spaces and preserved zero.
Installing each intermediate version is unnecessary. From 0.1.15, review both stages below; from 0.1.18, start with the new rendering contract.
This update retains existing deep-import paths, --import registration, configuration, the async factory and ?reload. The complete changelog lives in the repository.
0.1.15 → 0.1.16–0.1.18: compatible additions
These versions kept child strings and ordinary attributes unescaped, and JSX results as strings. Node.js support changed from >=20.16 <25 to >=20.16: the registration API is selected automatically. The node --import jtsx-loader app.js command and existing deep-import paths still work.
injectFactory: true | false | "legacy" was added. "legacy" keeps the old rule: inject the factory only without esbuildTransformConfig. When configuring transform, set injectFactory: true or retain a manual factory import; do not combine both. escapeAttributes: true was added as an option, disabled by default in 0.1.16–0.1.18.
A separate factory/asyncFactory.js was added, selected through importFactory. It awaits nested components, Promises and arrays, preserves order and 0, omits null/undefined/booleans and adds no spaces. The default factory remains synchronous: updating alone does not enable awaiting nested async components.
A broken existing jtsx.config.js now produces a warning; the external JTSX_STRICT_CONFIG=1 variable makes that failure fatal. Missing configuration is allowed. Fixes cover backslashes in escapeHtml, null fragments, style={null}, JSX/TSX imports with query/hash and error locations. Review HTML snapshots that depended on those bugs.
export default {
injectFactory: true,
importFactory: "import { _jsx, _jsxFragment, _jsxUtils } from 'jtsx-loader/factory/asyncFactory.js';",
};After 0.1.18: the new rendering contract
escapeChildren and escapeAttributes now default to true. Native tags and fragments return immutable HTML objects; the async factory returns Promise<Html>. renderToString(await Page(props)) converts the result to a string at the HTTP/file boundary. It escapes plain strings and rejects unawaited Promises and unsupported objects.
Arrays flatten recursively without implicit spaces; null/undefined/booleans are omitted, while 0 and bigint remain. Insert required spaces explicitly with {" "}. Keep nested JSX as objects until final rendering: join(), concatenation and template strings lose markup identity.
raw(html) is for trusted or previously sanitized HTML and does not sanitize it. __raw and __escape remain supported; ordinary text no longer needs __escape. The examples below are alternatives for server.js started with node --import jtsx-loader server.js.
const Page = ({ html }) => <main>{html}</main>;
res.type('html').send(await Page({ html: '<b>Hello</b>' }));import { raw, renderToString } from 'jtsx-loader';
const Page = ({ html }) => <main>{raw(html)}</main>;
res.type('html').send(renderToString(await Page({ html: '<b>Hello</b>' })));New entry points
The existing --import path remains supported. The root import now also exports raw and renderToString; register.js can register the loader from JavaScript before a dynamic server.js import. Use runtime.js for helpers without registration, especially inside jtsx.config.js to avoid a loading cycle.
browser.js and browserAsync.js support shared server/client components. Browser JSX must be bundled ahead of time; settings go through createFactory(options), not the Node configuration file. Bundlers supporting the browser condition also select browser adapters for the existing factory/jsxFactory.js and factory/asyncFactory.js paths.
import 'jtsx-loader/register.js';
await import('./server.js');Gradual migration: legacy mode
Merge the settings below into your existing jtsx.config.js, preserving importFactory, attributeParser and other options. If you already enabled escapeAttributes: true in 0.1.16–0.1.18, keep it: escapeChildren: false alone restores string output.
In legacy mode, send strings directly without renderToString(), which would escape the entire page. The synchronous factory retains its old spacing and falsy-value behavior; the async factory returns Promise<string>. Legacy mode does not automatically protect user text; use the new defaults for new projects.
export default {
escapeChildren: false,
escapeAttributes: false,
};