esbuild can bundle your JavaScript and CSS, transform code with plugins, and even serve your files with hot-reloading, all from a single CLI command.
Let’s see it in action. Imagine you have a simple HTML file, index.html, that includes a JavaScript file, src/index.js, and a CSS file, src/style.css.
index.html:
<!DOCTYPE html>
<html>
<head>
<title>esbuild Demo</title>
<link rel="stylesheet" href="dist/style.css">
</head>
<body>
<h1>Hello, esbuild!</h1>
<script src="dist/index.js"></script>
</body>
</html>
src/index.js:
import './style.css';
const heading = document.querySelector('h1');
heading.style.color = 'blue';
console.log('Hello from esbuild!');
src/style.css:
body {
font-family: sans-serif;
}
Now, from your terminal, you can run esbuild to bundle these:
esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --loader:.css=text
This command does several things:
src/index.js: This is the entry point for the build.--bundle: Tells esbuild to resolve all import paths and bundle them into a single output file.--outfile=dist/index.js: Specifies the output file name and directory.--format=esm: Sets the output module format to ECMAScript Modules.--loader:.css=text: This is crucial for handling CSS. It tells esbuild that files ending in.cssshould be treated as plain text. Since we’re importing CSS directly into our JavaScript and expecting it to be handled, this loader ensures esbuild reads the CSS content.
After running this, you’ll have a dist directory with index.js. However, the CSS isn’t automatically linked in index.html yet. For that, we need a plugin.
Let’s modify the command to include a simple way to inject CSS:
esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --loader:.css=text --servedir=dist --servedir=./
Wait, that’s not quite right. The --servedir option is for serving files, not for injecting CSS into the HTML. To properly handle CSS and inject it into the <head> of your HTML, you’d typically use a plugin like esbuild-plugin-html or configure esbuild to output a separate CSS file and link it in your HTML.
Let’s refine the goal: we want to bundle JS and CSS, and then serve them with live reloading.
Here’s the command for serving with hot-reloading:
esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --loader:.css=text --servedir=. --watch
Now, let’s break down the serving part:
--servedir=.: This tells esbuild to serve files from the current directory (.). When you run this from your project root, it will serveindex.html.--watch: This is the magic for hot-reloading. esbuild will monitor your source files for changes. When a change is detected, it will re-bundle and automatically refresh your browser.
With this command, esbuild will:
- Bundle
src/index.jsand its dependencies (including the importedsrc/style.css). - Output
dist/index.js. - Start a local development server.
- Serve
index.htmlfrom the current directory. - When you save changes in
src/index.jsorsrc/style.css, esbuild will rebuild and the browser will automatically reload.
However, the CSS isn’t being injected into the HTML automatically with this setup. The index.html is still looking for <link rel="stylesheet" href="dist/style.css">. esbuild, by default when using --loader:.css=text and bundling into JS, will treat the CSS as a string within the JS.
To get CSS into the HTML correctly for serving, you often need a more sophisticated setup or a different approach. One common pattern is to output a separate CSS file and have esbuild manage that.
Let’s try a command that outputs a separate CSS file and serves:
esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --outdir=dist --splitting --minify --servedir=. --watch
In this enhanced command:
--outdir=dist: Specifies that all output files should go into thedistdirectory.--splitting: This is key for separating CSS. It tells esbuild to create separate output files for dynamically imported modules and CSS. This will generatedist/index.jsanddist/style.css.--minify: Minifies the output JavaScript and CSS.
Now, your index.html needs to correctly reference these output files:
index.html (updated):
<!DOCTYPE html>
<html>
<head>
<title>esbuild Demo</title>
<link rel="stylesheet" href="dist/style.css">
</head>
<body>
<h1>Hello, esbuild!</h1>
<script src="dist/index.js"></script>
</body>
</html>
And src/index.js should not import CSS directly if you want it as a separate file. Or, if you still want to import it in JS, you’d use a plugin to extract it.
Let’s assume for this example that we want the CSS to be a separate file, and our src/index.js doesn’t need to import it directly. The build process will handle creating dist/style.css because of --splitting.
If src/index.js does import style.css, esbuild with --splitting will create a dist/chunk-*.css file, not necessarily dist/style.css by default. To ensure dist/style.css is created and linked, you’d typically configure plugins or use a specific loader for CSS output.
A more robust way to handle CSS output and serving, ensuring dist/style.css is generated and linked:
esbuild src/index.js --bundle --outdir=dist --format=esm --splitting --minify --servedir=. --watch --define:process.env.NODE_ENV="'development'" --loader:.css=css
Here’s what changed and why:
--outdir=dist: Ensures all outputs go todist.--splitting: This is crucial. It tells esbuild to split out CSS into its own file. When combined with--loader:.css=css, it tells esbuild to process CSS files and output them as separate CSS assets.--loader:.css=css: This loader specifically tells esbuild to treat.cssfiles as CSS. When--splittingis used, esbuild will extract the CSS into a separate file (often named based on the entry point or a hash, but the linking inindex.htmlis what matters).--define:process.env.NODE_ENV="'development'": A common practice to set environment variables during development.
With this command, esbuild will:
- Bundle
src/index.js. - Extract CSS from
src/style.css(and any other CSS imported) intodist/style.css. - Output
dist/index.js. - Start a dev server serving from the current directory.
- Watch for changes and reload automatically.
Your index.html should then look like this:
<!DOCTYPE html>
<html>
<head>
<title>esbuild Demo</title>
<link rel="stylesheet" href="dist/style.css">
</head>
<body>
<h1>Hello, esbuild!</h1>
<script src="dist/index.js"></script>
</body>
</html>
And your src/index.js would import the CSS:
src/index.js:
import './style.css'; // esbuild will process this with --splitting and --loader:.css=css
const heading = document.querySelector('h1');
heading.style.color = 'purple'; // Changed for visual confirmation
console.log('Hello from esbuild with CSS output!');
The core mental model of esbuild is its speed and simplicity for common tasks. It’s designed to be a drop-in replacement for many complex build tools, offering blazing-fast compilation times. The CLI is powerful because it allows you to define entry points, output configurations, loaders for different file types, and development server options all in one place. You can also use plugins for more advanced transformations, like integrating with frameworks or handling image assets.
The most surprising thing about esbuild’s serving capability is how it integrates hot-module replacement with bundling. It doesn’t just serve static files; it actively watches your source code, recompiles on the fly, and injects the updated code into the browser without a full page refresh for JavaScript changes, or a full refresh for CSS changes. This is achieved by esbuild’s internal WebSocket server that communicates with a small client-side runtime injected into your served pages.
When you use --splitting with CSS, esbuild doesn’t just bundle all CSS into your JS. Instead, it understands that CSS should be a separate asset. It will generate a .css file in your outdir and ensure that the JavaScript it produces includes the necessary logic to load this CSS file, or it will manage the creation of <link> tags if configured to do so via plugins or specific loader behaviors. The --loader:.css=css combined with --splitting signals esbuild to treat CSS as a first-class citizen for extraction.
The next concept you’ll likely encounter is managing more complex build configurations, potentially involving multiple entry points or custom plugins for tasks like TypeScript compilation or framework-specific optimizations.