esbuild can compile JSX and React components, but it doesn’t transform JSX into JavaScript like Babel does. Instead, it relies on a separate tool to do that transformation first.
Let’s see esbuild in action, compiling a simple React component.
First, you need a JSX transformer. The most common choice is @babel/plugin-transform-react-jsx. You’ll also need react and react-dom for the actual components.
npm install esbuild @babel/core @babel/plugin-transform-react-jsx react react-dom --save-dev
Now, let’s create a basic React component.
src/App.jsx
import React from 'react';
function App() {
return <h1>Hello from React and esbuild!</h1>;
}
export default App;
Here’s how you’d configure esbuild to use Babel for the JSX transformation. The key is the plugins option in esbuild.
esbuild.js
const esbuild = require('esbuild');
const babelPlugin = require('@babel/core');
esbuild.build({
entryPoints: ['src/App.jsx'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [
{
name: 'jsx-transform',
setup(build) {
build.onLoad({ filter: /\.jsx?$/ }, async (args) => {
const code = require('fs').readFileSync(args.path, 'utf8');
// Use Babel to transform JSX
const babelResult = await babelPlugin.transformAsync(code, {
filename: args.path,
presets: [['@babel/preset-react', { runtime: 'automatic' }]],
plugins: [], // Add other Babel plugins here if needed
sourceMaps: true,
configFile: false, // Ensure Babel doesn't load a babel.config.js
babelrc: false, // Ensure Babel doesn't load a .babelrc
});
return {
contents: babelResult.code,
loader: 'jsx', // esbuild knows how to handle the output of the jsx loader
// You can also return source maps if babelResult.map is not null
// loader: 'js', // use 'js' if you don't want esbuild to re-process
};
});
},
},
],
}).catch(() => process.exit(1));
When you run node esbuild.js, esbuild will first read src/App.jsx. The jsx-transform plugin intercepts this. It passes the content to Babel, which transforms the JSX into standard JavaScript (using the automatic runtime, meaning you don’t need import React from 'react'; in newer React versions if you’re using this preset). esbuild then takes Babel’s output and proceeds with its own optimizations, bundling, and minification.
The presets: [['@babel/preset-react', { runtime: 'automatic' }]] is crucial. The automatic runtime is the modern way React handles JSX, automatically importing the necessary functions like jsx and jsxs from react when needed, rather than requiring import React from 'react' in every file.
If you were using the classic runtime (which requires import React from 'react' explicitly), your Babel preset would look like presets: [['@babel/preset-react', { runtime: 'classic' }]], and you’d need import React from 'react'; in App.jsx.
The loader: 'jsx' tells esbuild that the transformed code is JavaScript and can be processed further. If you omitted this, esbuild might try to process it as a different file type.
The most surprising true thing about this setup is that esbuild itself doesn’t understand JSX syntax at all; it’s entirely delegated to Babel. esbuild’s role is purely to orchestrate the build process, taking the output of the JSX transformation and then applying its own high-speed bundling and minification.
To see this in action, you’d create an index.html to load your bundle.js:
index.html
<!DOCTYPE html>
<html>
<head>
<title>esbuild React</title>
</head>
<body>
<div id="root"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
And then, you’d need a small JavaScript file to render your App component into the DOM. Let’s call it src/index.js.
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
You’d then modify esbuild.js to also include src/index.js and ensure Babel transforms it if it contains JSX (though in this case, it doesn’t). A more robust esbuild.js would handle multiple entry points and use a loader system more effectively.
esbuild.js (with multiple entry points)
const esbuild = require('esbuild');
const babelPlugin = require('@babel/core');
const fs = require('fs');
const isProduction = process.env.NODE_ENV === 'production';
esbuild.build({
entryPoints: ['src/index.js'], // Start with your main entry point
bundle: true,
outfile: 'dist/bundle.js',
minify: isProduction,
sourcemap: !isProduction,
plugins: [
{
name: 'jsx-transform',
setup(build) {
build.onLoad({ filter: /\.jsx?$/ }, async (args) => {
const code = fs.readFileSync(args.path, 'utf8');
const babelResult = await babelPlugin.transformAsync(code, {
filename: args.path,
presets: [['@babel/preset-react', { runtime: 'automatic' }]],
sourceMaps: true,
configFile: false,
babelrc: false,
});
return {
contents: babelResult.code,
loader: 'js', // esbuild will treat the output as JS
sourcefile: args.path, // Associate source map with original file
// If babelResult.map exists, you can return it like this:
// loader: 'js',
// contents: babelResult.code,
// loader: 'js',
// sourcefile: args.path,
// watchFiles: [args.path], // Optional: tell esbuild to watch this file
};
});
},
},
],
}).catch(() => process.exit(1));
Running node esbuild.js and opening index.html in your browser will now render "Hello from React and esbuild!".
The crucial detail often missed is how build.onLoad interacts with loader. When onLoad returns { contents: ..., loader: 'js' }, esbuild processes the contents as JavaScript. If you return loader: 'jsx', it tells esbuild to treat the output as JSX, which is usually not what you want after Babel has already transformed it. The sourcefile option is important for source maps to correctly point back to your original .jsx files.
The next thing you’ll likely want to explore is how to integrate esbuild’s watch mode for a better development experience, automatically recompiling on file changes.