mrkeyoor.com_
Fri 07 Aug 20:58 UTC
npmWeb Frontendupdated 07 Aug 2026

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.

Verdict

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.

API stability5/5One exported function with six optional fields, unchanged since 3.0.0 in September 2023, and the option names match the long-standing Babel pragma comments so they are unlikely to move.
Docs4/5The README documents every option with its default and comment form, shows a full input-to-output example with acorn, and calls out the acorn comment-attachment trap and the ESM export map requirement. What it does not cover is error behaviour or what happens when you pass a non-Program node.
Maintenance3/53.0.1 was published in October 2023 and the last repository push was August 2024, so nothing has moved in roughly two years. There are 0 open issues and 0 open PRs, and the unified collective maintains it as finished code rather than an active project, which is fine until you need a fix.
Ecosystem4/5Around 8.87M weekly downloads, essentially all of it transitive through recma-build-jsx and @mdx-js/mdx, against 23 GitHub stars. It is load-bearing infrastructure for the MDX toolchain and almost nothing else, so there is no plugin ecosystem or community around it.

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
Skip it if

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

PackageRegistryPick it when
esbuildnpmYou 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-jsxnpmYour pipeline is Babel, or you need pure annotations, throwIfNamespace, or useBuiltIns, none of which exist here
recma-build-jsxnpmYou 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/corenpmYou want a Rust-speed JSX transform with source maps and are willing to accept a native binary in your dependency tree