Transform jsx`` and reactJsx`` tagged template literals inside any bundler that supports webpack-style loaders (Rspack, Webpack, etc.). The loader rewrites JSX syntax that lives in template literals so the runtime helpers can execute it later—ideal when you want JSX inside Lit components, custom elements, or shared utilities without adding a .tsx transpilation step.
npm install @knighted/jsxThe loader ships with the package; no extra peer dependency is required.
mode: 'runtime' in browser-targeted bundles (Rspack/Webpack/Vite/etc.) needs the WebAssembly parser. The easiest path is to let the CLI wire it up:
npx @knighted/jsx init
# add --config to walk through loader options, or --dry-run to inspect firstinit installs the required runtimes plus @oxc-parser/binding-wasm32-wasi with the right platform flags so JSX inside template literals can be parsed at runtime. If you cannot run the CLI, install those same packages manually, passing npm_config_ignore_platform=true (or an equivalent flag for your package manager) when adding the binding or vendoring it like scripts/setup-wasm.mjs.
Run your source files through the loader. Example Rspack config:
// rspack.config.ts
import path from 'node:path'
import type { Configuration } from '@rspack/core'
const config: Configuration = {
module: {
rules: [
{
test: /\.[cm]?[jt]sx?$/,
include: path.resolve(__dirname, 'src'),
use: [
{
loader: '@knighted/jsx/loader',
options: {
// Customize loader behavior here
},
},
],
},
],
},
}
export default configBy default the loader transforms both jsx`` (DOM runtime) and reactJsx`` (React runtime) calls. You can also compile jsx to DOM at build time via the dom mode (see options below) to avoid shipping the runtime parser in browser bundles.
Tip
Building React SSR with the loader? See docs/ssr-rendered-bundle.md for an end-to-end guide that uses reactJsx in server bundles.
| Option | Type | Default | Description |
|---|---|---|---|
tags |
string[] |
['jsx', 'reactJsx'] |
Names of tagged template helpers to transform. Add aliases if you re-export the helpers under custom names. |
mode |
'runtime' | 'react' | 'dom' |
'runtime' |
Sets the default transformation target for every tag. runtime keeps the tagged template, react emits React.createElement calls, dom emits DOM creation code (no runtime parser). |
tagModes |
Record<string, 'runtime' | 'react' | 'dom'> |
undefined |
Per-tag override of mode. Common split: { jsx: 'dom', reactJsx: 'react' } to get DOM nodes for jsx and React elements for reactJsx without runtime parsing in web bundles. |
tag |
string |
undefined |
Legacy single-tag option. Prefer tags, but this remains for backward compatibility. |
- The runtime parser is Node-oriented (WASI) and will try to import
node:moduleif bundled for the browser. The loader emits a warning on web-like targets whenruntimeis only implied. Explicitmode/tagModesfordomorreactwill not warn. - If you need runtime mode in the browser, explicitly set
mode: 'runtime'and ensure you serve a web-safe parser; otherwise prefermode: 'react'ormode: 'dom'for client bundles. - The loader emits warnings through
emitWarningwhen available; without it the transform still proceeds unchanged.
import { jsx } from '@knighted/jsx'
const FancyButton = ({ label }: { label: string }) =>
jsx`
<button>
${label}
</button>
`
class Widget extends HTMLElement {
render() {
return html`
<div class="card">
${jsx`
<${FancyButton} label="Launch" />
`}
</div>
`
}
}During the build the loader rewrites everything inside ${jsx``} so each dynamic chunk becomes a regular ${expression} in the output template literal. Keep writing JSX exactly as you would in .tsx files: wrap dynamic bits with braces (className={value}, {children}, spread props, etc.). At runtime @knighted/jsx turns the transformed template back into live DOM nodes (or React elements when using reactJsx).
- Only tagged template literals that use the configured names are transformed; normal
.tsxfiles still need your existing JSX transformer. - Template literal
${expr}segments that sit outside JSX braces are wrapped automatically so destructured props, inline values, and children remain live without extra boilerplate. - The loader runs synchronously—avoid work that needs async I/O.
- When targeting the React runtime, ensure
react/react-domare bundled soreactJsxcan callReact.createElement.
- Import from
@knighted/jsx/litewhen you want the smallest runtime bundle—the loader output stays the same. - Lit templates can safely embed
jsx``insidehtml``blocks; the runtime returns DOM nodes orDocumentFragmentinstances that Lit inserts like any other value. - Frameworks such as Next.js or Remix should add the loader as a post-loader so SWC/Babel execute first and the tagged template literals are rewritten afterward.
The repository ships a Rspack + Lit + React fixture under test/fixtures/rspack-app/. The Vitest integration test (test/loader.e2e.test.ts) builds that fixture, stubs the parser WASM binding, and verifies the loader pipeline inside a real bundler. Prefer a standalone repo instead? Walk through morganney/jsx-loader-demo for a minimal bundler-focused project you can clone directly.
Manual preview steps:
- Build the library (
npm run build) so the loader artifacts exist underdist/. - Install the parser WASM binding (
npm run setup:wasm) to enable JSX parsing outside Node. Pass-- --use-stubtonpm run build:fixtureonly if you deliberately want the no-op parser stub. - Run
npm run build:fixtureto emittest/fixtures/rspack-app/dist/bundle.jsvia Rspack. - Serve the fixture folder (
npx serve test/fixtures/rspack-app -l 8080) and open it in a browser. You will see a Lit component that embeds DOM returned byjsxalongside a React badge rendered throughreactJsx.
The e2e test normally writes to a temporary directory and cleans up afterward. Use the steps above when you need a persistent bundle for manual inspection—the real WASM binding is required for interactive parsing; the stub exists strictly for loader smoke tests.