estree-util-build-jsx
estree-util-build-jsx takes a JavaScript syntax tree that still has JSX nodes in it and rewrites those nodes into plain function calls, so <x /> becomes h('x') or _jsx('x'). It does not read files, does not parse source text, and does not print code back out. You hand it an estree Program that some JSX-aware parser already produced, it mutates that tree in place, and you print the result with something else. Think of it as the middle third of a compiler that someone else already started. This is the same job the Babel JSX plugin does, but against an estree tree rather than a Babel tree, which is why almost nobody installs it on purpose. It arrives as a transitive dependency of MDX through recma-build-jsx, which is where the download count comes from. You reach for it directly only when you are building your own tool that already holds an AST and needs JSX compiled away inside it.
A precise single-purpose tool that is exactly right if you already hold an estree tree and exactly wrong otherwise. Almost everyone reading this arrived through MDX and does not need to install it directly; if you are compiling from source, use esbuild or SWC.
Use it if
- You already have an estree Program in memory from acorn with acorn-jsx, espree, or esast-util-from-js, and adding esbuild or SWC just to compile JSX would mean serializing the tree back to text and reparsing it
- You are writing a unified or recma plugin for an MDX-style pipeline and need the JSX-to-function-call step as one transform among several that all operate on the same tree
- You need namespaced JSX such as <a:b c:d /> to compile rather than throw, which is the default here and the opposite of Babel's default
- You need the runtime, pragma, and import source to be configurable per file through @jsx, @jsxFrag, @jsxRuntime, and @jsxImportSource comments, because your users write those comments in their source
- You are targeting a non-React hyperscript interface such as xastscript, vhtml, or a custom h function and want the classic runtime pointed at your own identifier
- You are starting from source code rather than a tree. The README says this outright: use SWC or esbuild instead. Both parse, transform, and print in one pass and are far faster than parse plus walk plus print in JavaScript
- You are in a Babel pipeline. @babel/plugin-transform-react-jsx works on Babel's tree, supports pure annotations, throwIfNamespace, useSpread, and useBuiltIns, and this package supports none of those
- You want React Fast Refresh, hot reload, or any bundler integration. This produces function calls and nothing else, so all the development ergonomics are still yours to build
- You need CommonJS output from the automatic runtime. The injected import is always an ESM ImportDeclaration, so a require-based build has to rewrite it afterwards
- You want an actively developed dependency. Version 3.0.1 shipped in October 2023, the last repository push was August 2024, and the repo has 23 stars, so anything you hit that is not already handled is likely to sit
- The package is ESM only with no CommonJS entry, so a CJS build has to reach it through dynamic import
Setup reality
npm install estree-util-build-jsx gets you an ESM-only package with a single named export and no default export, so require() fails and a CommonJS consumer needs await import(). It is useless on its own: you need a parser that emits JSX nodes in an estree tree (acorn plus acorn-jsx, espree, or esast-util-from-js) and a printer (estree-util-to-js or astring) on the other side, so the real install is three packages, not one. buildJsx mutates the tree and returns undefined, which trips up anyone who writes const out = buildJsx(tree) and gets nothing back. Comment-based configuration only works if comments are actually attached to the Program node: espree does this, plain acorn does not, so with acorn you have to collect comments through the onComment option and assign tree.comments = comments yourself or every @jsxImportSource pragma in the file is silently ignored. In the automatic runtime the generated import declaration is spliced into the Program body, so if you hand it a subtree rather than a Program you get _jsx calls with no import to back them. Conflicting configuration throws rather than warns: an @jsx pragma together with @jsxRuntime automatic is an Error, as is @jsxImportSource with the classic runtime. TypeScript types come from @types/estree-jsx, which is a runtime dependency here rather than a devDependency, so your tree types need to line up with that package's version or the walker complains.
Patterns
Compile JSX in a tree with the classic runtimebasic-usage
import {buildJsx} from 'estree-util-build-jsx'
// `tree` is an estree Program containing JSXElement nodes
buildJsx(tree, {pragma: 'React.createElement', pragmaFrag: 'React.Fragment'})
// <a b /> -> React.createElement('a', {b: true})buildJsx returns undefined and edits the tree in place. Writing const out = buildJsx(tree) gives you undefined, not a new tree. Classic is the default runtime, so passing no options at all gives you React.createElement output.
Get a JSX tree out of acorn, comments includedparse-with-acorn
import {Parser} from 'acorn'
import jsx from 'acorn-jsx'
import {buildJsx} from 'estree-util-build-jsx'
const comments = []
const tree = Parser.extend(jsx()).parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
onComment: comments
})
// acorn does not attach comments; you have to
tree.comments = comments
buildJsx(tree)Skip the tree.comments assignment and every @jsx or @jsxImportSource pragma in the file is ignored with no warning. espree attaches comments for you, plain acorn does not. This is the single most common reason people report that comment configuration does not work.
Use the automatic runtime and let it inject the importautomatic-runtime
buildJsx(tree, {runtime: 'automatic', importSource: 'react'})
// input: <a b />
// output: import {jsx as _jsx} from 'react/jsx-runtime'
// _jsx('a', {b: true})The ImportDeclaration is spliced into the Program body after any directives such as 'use strict'. It only imports the specifiers actually used: jsx for single children, jsxs for multiple, Fragment for fragments. Pass a node that is not a Program and you get the calls with no import at all.
Target something that is not Reactcustom-hyperscript-pragma
buildJsx(tree, {pragma: 'x', pragmaFrag: 'null'})
// with `import x from 'xastscript'` at the top of the source:
// <album id={123}><name>Born in the U.S.A.</name></album>
// ->
// x('album', {id: 123}, x('name', null, 'Born in the U.S.A.'))pragma accepts a member expression, so 'h.createElement' works as well as 'h'. pragmaFrag set to the string 'null' is the idiom for hyperscript interfaces that have no fragment symbol. You are responsible for making sure the identifier is actually imported in the source; this package does not add classic-runtime imports.
Point the automatic runtime at Preact or your own runtimecustom-import-source
buildJsx(tree, {runtime: 'automatic', importSource: 'preact'})
// -> import {jsx as _jsx} from 'preact/jsx-runtime'
buildJsx(tree, {runtime: 'automatic', importSource: '@my-org/ui'})
// -> import {jsx as _jsx} from '@my-org/ui/jsx-runtime'The suffix /jsx-runtime or /jsx-dev-runtime is always appended, you never write it yourself. For an ESM package that target has to be declared in the exports map of the package you point at, otherwise Node cannot resolve it at runtime even though the generated code looks correct.
Let the source file choose its own runtimeinline-comment-config
/**
* @jsxRuntime automatic
* @jsxImportSource preact
*/
// or, for the classic runtime:
/* @jsx h */
/* @jsxFrag Fragment */Comment pragmas beat the options object. Mixing them throws rather than warning: @jsx together with @jsxRuntime automatic raises 'Unexpected `@jsx` pragma w/ automatic runtime', and @jsxImportSource with the classic runtime raises its own Error. Wrap buildJsx in a try/catch if the source is user-supplied.
Emit jsxDEV with source locationsdevelopment-mode
buildJsx(tree, {
runtime: 'automatic',
development: true,
filePath: 'src/page.jsx'
})
// -> import {jsxDEV as _jsxDEV} from 'react/jsx-dev-runtime'
// _jsxDEV('a', {b: true}, undefined, false, {fileName: 'src/page.jsx', lineNumber: 1, columnNumber: 1}, this)development only has an effect with runtime: 'automatic'; in classic mode it is ignored. Location info is only emitted when the parser recorded positions on the nodes, and filePath is what fills fileName. Never ship this to production, it adds an object literal per element.
Parse, transform, print in one scriptfull-pipeline
import fs from 'node:fs/promises'
import jsx from 'acorn-jsx'
import {fromJs} from 'esast-util-from-js'
import {buildJsx} from 'estree-util-build-jsx'
import {toJs} from 'estree-util-to-js'
const doc = String(await fs.readFile('example.jsx'))
const tree = fromJs(doc, {module: true, plugins: [jsx()]})
buildJsx(tree, {pragma: 'x', pragmaFrag: 'null'})
console.log(toJs(tree).value)This is the whole reason the package exists as a separate module: three small tools, each replaceable. esast-util-from-js attaches comments for you, unlike raw acorn. Note that toJs takes the same mutated tree object you passed in.
Wire it into a recma or unified pipelineunified-plugin
import {unified} from 'unified'
import recmaParse from 'recma-parse'
import recmaStringify from 'recma-stringify'
import {buildJsx} from 'estree-util-build-jsx'
function recmaBuildJsxLocal(options) {
return (tree) => {
buildJsx(tree, options)
}
}
const file = await unified()
.use(recmaParse, {jsx: true})
.use(recmaBuildJsxLocal, {runtime: 'automatic'})
.use(recmaStringify)
.process(source)The transformer must not return the value of buildJsx, because that is undefined and unified would treat a returned undefined as 'no replacement', which happens to be correct here only by accident. Returning the tree explicitly is clearer. recma-build-jsx is this exact wrapper published as a package.
Compile namespaced element and attribute namesnamespaced-jsx
// <a:b c:d>text</a:b>
buildJsx(tree, {pragma: 'h'})
// -> h('a:b', {'c:d': true}, 'text')Babel throws on namespaces unless you flip throwIfNamespace. This package supports them by default and there is no option to turn that off, which matters if you are compiling SVG, XML, or xast-style trees where xlink:href and similar names are normal.
Know what spreads compile tospread-attributes
// <x name key="value" {...spread} /> with the classic runtime
// -> h('x', Object.assign({name: true, key: 'value'}, spread))The classic runtime emits Object.assign for spreads with no way to switch to object rest syntax, because the useSpread and useBuiltIns options from Babel do not exist here. If your output target predates Object.assign you have to post-process the tree yourself.
The one-line replacement when you start from sourcewhen-to-use-esbuild-instead
import {transform} from 'esbuild'
const {code} = await transform(source, {
loader: 'jsx',
jsx: 'automatic',
jsxImportSource: 'preact'
})If your input is a string and your output is a string, this is the whole job in one call, with source maps, and it does not need a parser or a printer. Reach for estree-util-build-jsx only when serializing your tree back to text between steps would be the wrong shape for your tool.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| esbuild | npm | You are compiling JSX from source text rather than from a tree you already hold, and you want parsing, transformation, and printing in one fast call |
| @babel/plugin-transform-react-jsx | npm | Your pipeline is Babel, or you need pure annotations, throwIfNamespace, or useBuiltIns, none of which exist here |
| recma-build-jsx | npm | You are inside a unified or MDX pipeline and want this transform as a plugin rather than a function call you wire up by hand |
| @swc/core | npm | You want a Rust-speed JSX transform with source maps and are willing to accept a native binary in your dependency tree |