esbuild 20 ⚡ The Serve API — Built-in dev server with –servedir
esbuild’s serve API is a development server that serves the output of the build and, when combined with watch mode, provides a live-reload mechanism through server-sent events. The --servedir flag is the piece that tells the server where to find static files that are not part of the build output—an index.html that references the bundled JavaScript, for example. The server does not transform files on request, does not provide hot module replacement, and is not designed for production. It serves the files that exist on disk after a build, and it rebuilds on request when watch mode is active.
The distinction between the output directory and servedir is the source of most configuration confusion. The build writes to outdir. The server reads from servedir and also from the in-memory build output. When the two are the same directory—--outdir=www --servedir=www, for example—the server serves both the bundled JavaScript and the static HTML from the same location. When they differ, the server falls back to servedir for files that are not in the build output. The index.html is almost always in servedir because esbuild does not generate HTML; it copies it if the copy loader is configured, or it leaves it on disk for the server to find.
Key point: --servedir tells the serve API where to find static files that are not part of the build output. The server serves both the in-memory build result and the files in servedir. It does not transform HTML, does not provide HMR, and is not a production server.
Why --servedir exists separately from outdir
The HTML problem. esbuild bundles JavaScript and CSS. It does not generate an index.html that references the bundled files. The index.html must exist somewhere, and the server must find it when the browser requests /. If the index.html is in outdir because it was copied there by the build, the server finds it in the build output. If the index.html is in a different directory—the project root, or a public folder—the server needs to be told where to look. The servedir option is that instruction. Without it, the server serves only the build output, and a request for / returns a directory listing or a 404, not the application.
The in-memory build result. The serve API does not write the build output to disk before serving it. It keeps the result in memory and serves it directly. This is why the serve API can start without a completed build: the first request triggers a build, and the result is served from memory . The servedir is for everything else—the HTML, the favicon, the static assets that are not imported by the JavaScript. The server checks the in-memory build result first, then falls back to servedir .
The live-reload endpoint. The serve API exposes an endpoint at /esbuild that the client can connect to via EventSource. The server sends a change event when the build output changes, and the client script reloads the page. The servedir does not affect this endpoint; it is part of the server’s own routing. The client-side script is not injected automatically—the developer adds it to the HTML or the JavaScript bundle, guarded by an environment check so that it does not ship to production .
The trade-off. The serve API is a convenience, not a development server on the level of Vite or Webpack Dev Server. It does not transform TypeScript on request, does not resolve bare imports at runtime, and does not provide HMR. It serves the output of a build. The build must be fast—esbuild’s incremental rebuild is—and the live reload is a full page reload, not a state-preserving hot update. For a simple static site or a small application, this is sufficient. For a framework with complex HMR requirements, it is not.
a. Configuring the serve API with servedir
The JavaScript API requires a context and a serve call. The CLI uses --serve and --servedir. The port defaults to 8000, and the host defaults to 0.0.0.0 (all local interfaces).
// build.mjs — serve with a servedir
import * as esbuild from 'esbuild';
const ctx = await esbuild.context({
entryPoints: ['src/app.js'],
bundle: true,
outdir: 'www',
});
// Start the server.
// The build result is served from memory.
// Files not in the build result are served from servedir.
const { hosts, port } = await ctx.serve({
servedir: 'www',
port: 3000,
});
console.log(`Serving at http://${hosts[0]}:${port}`);
The hosts array contains the addresses the server is listening on. The default host is 0.0.0.0, which means the server accepts connections on all local interfaces—127.0.0.1 and the machine’s LAN address. The returned hosts array reflects this . The port is the actual port the server bound to; if the requested port was occupied and the server fell back to a different one, the returned value is the one that is active.
# CLI equivalent
esbuild src/app.js --bundle --outdir=www --serve --servedir=www --port=3000
The CLI performs an initial build and starts the server. The --servedir flag points to the same directory as --outdir in this example, so the server finds the bundled JavaScript and the static HTML in the same place. A request for / returns www/index.html, and a request for /app.js returns the bundled output from memory.
b. Live reload with the /esbuild endpoint
The serve API exposes a server-sent events endpoint at /esbuild. The client connects to it with EventSource, and the server sends a change event when the build output changes. The event data contains the files that were added, removed, or updated.
// In the client JavaScript, guarded for development only.
// The script reloads the page when the build changes.
if (process.env.NODE_ENV === 'development') {
new EventSource('/esbuild').addEventListener('change', () => {
location.reload();
});
}
The event data can be used for more granular updates, such as swapping a CSS file without a full reload. The change event contains a JSON payload with added, removed, and updated arrays. A CSS hot-reload script can check whether the only updated file is a CSS file and swap the <link> element instead of reloading the page .
The live-reload endpoint only fires when the build output changes. A change to a file that is not part of the build graph does not trigger an event. This is the same limitation as watch mode: the watcher only sees the files that were resolved during the previous build .
c. The security constraint and the host restriction
The serve API is a development server. It is not intended to be exposed to the public internet, and it is not designed with production security in mind. A security advisory addressed a CORS misconfiguration in earlier versions: the server sent Access-Control-Allow-Origin: *, which allowed any website the developer visited to make requests to the local development server and read the source code . The fix restricted CORS to the hosts the server was configured to accept. The serve API now returns an array of hosts instead of a single host string, and requests from unmatched hosts are denied .
// The serve API returns an array of hosts.
const { hosts, port } = await ctx.serve({
servedir: 'www',
host: '127.0.0.1', // Restrict to localhost only
});
// hosts contains ['127.0.0.1']
The host option restricts the interfaces the server listens on. Setting it to 127.0.0.1 means the server is only reachable from the local machine, which is the safe default for development. The default 0.0.0.0 accepts connections from the LAN, which is useful for testing on a phone but introduces a wider exposure surface.
The server also refuses to serve the HTTPS key or certificate files if they are in servedir. The keyfile and certfile options are for enabling HTTPS on the development server, and the server checks the paths case-insensitively to prevent them from being served as static files .
Complete Example Session
// ============================================
// PART 1: THE MINIMAL SERVE CONFIGURATION
// ============================================
// build.mjs
import * as esbuild from 'esbuild';
const ctx = await esbuild.context({
entryPoints: ['src/app.js'],
bundle: true,
outdir: 'www',
});
const { hosts, port } = await ctx.serve({
servedir: 'www',
port: 3000,
});
console.log(`Serving at http://${hosts[0]}:${port}`);
// ============================================
// PART 2: THE CLI EQUIVALENT
// ============================================
// esbuild src/app.js --bundle --outdir=www --serve --servedir=www --port=3000
//
// The CLI prints the local and network addresses.
// The initial build completes before the server starts.
// ============================================
// PART 3: SERVING FROM A DIFFERENT DIRECTORY
// ============================================
// The build output goes to dist/.
// The HTML lives in public/.
const ctx = await esbuild.context({
entryPoints: ['src/app.js'],
bundle: true,
outdir: 'dist',
});
await ctx.serve({
servedir: 'public',
port: 3000,
});
// A request for /app.js is served from the in-memory build output.
// A request for / is served from public/index.html.
// The HTML must reference /app.js, not /dist/app.js.
// ============================================
// PART 4: LIVE RELOAD IN THE CLIENT
// ============================================
// In the client JavaScript:
if (process.env.NODE_ENV === 'development') {
new EventSource('/esbuild').addEventListener('change', () => {
location.reload();
});
}
// The EventSource connects to the serve API's built-in endpoint.
// The change event fires when the build output changes.
// The page reloads.
// ============================================
// PART 5: COMBINING WATCH AND SERVE
// ============================================
// The serve API rebuilds on request.
// The watch API rebuilds on file change.
// Together, the watch rebuilds and the serve live-reloads.
const ctx = await esbuild.context({
entryPoints: ['src/app.js'],
bundle: true,
outdir: 'www',
});
await ctx.watch();
const { port } = await ctx.serve({ servedir: 'www' });
// The watch method triggers rebuilds when source files change.
// The serve method provides the live-reload endpoint.
// They share the same context and the same build configuration.
// ============================================
// PART 6: THE HOST RESTRICTION
// ============================================
// Default host is 0.0.0.0 (all interfaces).
// Restrict to localhost for security.
const { hosts } = await ctx.serve({
servedir: 'www',
host: '127.0.0.1',
});
// hosts is ['127.0.0.1'].
// The server only accepts connections from the local machine.
// ============================================
// PART 7: HTTPS FOR THE DEVELOPMENT SERVER
// ============================================
// Generate a self-signed certificate:
// openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 9999 -nodes -subj /CN=127.0.0.1
const { port } = await ctx.serve({
servedir: 'www',
keyfile: 'key.pem',
certfile: 'cert.pem',
});
// The server now serves over HTTPS.
// The key and certificate files are not served as static assets.
// The server checks for them case-insensitively and returns 403 if requested.
// ============================================
// PART 8: THE CORS RESTRICTION
// ============================================
// The serve API restricts CORS to the configured host.
// A request from a different origin is denied.
// This prevents a malicious website from reading the source code
// via the development server.
// ============================================
// PART 9: WHAT THE SERVER DOES NOT DO
// ============================================
// The serve API:
// - Does not transform TypeScript on request.
// - Does not resolve bare imports at runtime.
// - Does not provide hot module replacement.
// - Does not inject the live-reload script automatically.
// - Is not designed for production use.
//
// It serves the build output from memory and the servedir from disk.
// ============================================
// PART 10: VERIFICATION
// ============================================
// 1. Start the server: node build.mjs
// 2. Open the browser at the printed URL.
// 3. Verify the page loads and the bundled JavaScript executes.
// 4. Edit a source file.
// 5. Verify the build output changes and the page reloads.
// 6. Verify a request to a static file in servedir resolves.
// 7. Stop the server and call ctx.dispose() to free resources.
The ten parts covered the minimal configuration, the CLI equivalent, the servedir/outdir distinction, the live-reload endpoint, the watch combination, the host restriction, HTTPS support, the CORS fix, and the limitations. The through-line is that the serve API is a static file server for the build output, and servedir tells it where the rest of the files are.
Quick Reference
Serve options
| Option | Default | Purpose |
|---|---|---|
port | 8000 (first free) | Server port |
host | 0.0.0.0 | Listen address |
servedir | (none) | Static files directory |
keyfile | (none) | HTTPS key file |
certfile | (none) | HTTPS certificate file |
CLI flags
| Flag | Effect |
|---|---|
--serve | Start the server |
--servedir=DIR | Serve static files from DIR |
--port=N | Set the port |
--watch | Rebuild on file changes |
--servedir=DIR | Static files for the server |
What the server serves
| Source | Served as |
|---|---|
| In-memory build output | The bundled JavaScript and CSS |
servedir files | HTML, images, and other static files |
/esbuild | SSE endpoint for live reload |
Best Practices
✅ Do This:
// Use the same directory for outdir and servedir if possible
await ctx.serve({ servedir: 'www' }); // ✅
// Restrict the host to localhost
await ctx.serve({ host: '127.0.0.1' }); // ✅
// Guard the live-reload script for development only
if (process.env.NODE_ENV === 'development') { ... } // ✅
// Combine watch and serve for live reload
await ctx.watch(); await ctx.serve({ servedir: 'www' }); // ✅
// Dispose the context on exit
process.on('SIGINT', () => ctx.dispose()); // ✅
❌ Don’t Do This:
// Deploy the serve API to production
// It is a development server, not a production server. // ❌
// Expect HMR for JavaScript
// The serve API provides live reload, not HMR. // ❌
// Forget servedir when HTML is outside outdir
await ctx.serve(); // No servedir, no HTML // ❌
// Use 0.0.0.0 on an untrusted network
await ctx.serve({ host: '0.0.0.0' }); // Exposed to LAN // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
/ returns 404 | No servedir set and no HTML in outdir | Set servedir to the directory with index.html |
| Live reload does not work | Client script not added | Add the EventSource script to the client |
| Page reloads but state is lost | Live reload is a full page reload | Use sessionStorage to persist state |
| CSS changes require full reload | The client script does not handle CSS updates | Implement the CSS swap logic in the event listener |
| Port conflict | Default port 8000 occupied | Set port explicitly or use port 0 for a random port |
Real-World Examples
1. Basic serve
const { port } = await ctx.serve({ servedir: 'www' });
2. CLI serve
esbuild src/app.js --bundle --outdir=www --serve --servedir=www
3. Different servedir
await ctx.serve({ servedir: 'public' }); // HTML in public/, JS in memory
4. Live reload script
new EventSource('/esbuild').addEventListener('change', () => location.reload());
5. Restrict host
await ctx.serve({ host: '127.0.0.1' });
6. HTTPS
await ctx.serve({ keyfile: 'key.pem', certfile: 'cert.pem' });
7. Watch + serve
await ctx.watch(); await ctx.serve({ servedir: 'www' });
8. Dispose
process.on('SIGINT', () => ctx.dispose());
9. Random port
await ctx.serve({ port: 0 }); // OS picks an ephemeral port
10. Verify static files
// Request /favicon.ico; the server falls back to servedir.
Visual
The Serve API’s File Resolution
┌──────────────────────────────────────────────────────────────┐
│ WHERE THE SERVER LOOKS FOR A REQUEST │
│ │
│ Browser requests /app.js │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ In-memory build │──── Found? ────▶ Served from memory │
│ │ output │ │
│ └────────┬────────┘ │
│ │ Not found │
│ ▼ │
│ ┌─────────────────┐ │
│ │ servedir │──── Found? ────▶ Served from disk │
│ │ (www/) │ │
│ └────────┬────────┘ │
│ │ Not found │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 404 │ │
│ └─────────────────┘ │
│ │
│ The build output is checked first. │
│ The servedir is the fallback for static files. │
└──────────────────────────────────────────────────────────────┘
The Live-Reload Flow
┌──────────────────────────────────────────────────────────────┐
│ FROM FILE CHANGE TO PAGE RELOAD │
│ │
│ Source file changes │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ctx.watch() │ Detects the change │
│ │ triggers build │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Build completes │ Output changes in memory │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ /esbuild SSE │ Sends "change" event │
│ │ endpoint │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Client script │ Receives the event │
│ │ location.reload()│ │
│ └─────────────────┘ │
│ │
│ The client script must be added manually. │
│ The server does not inject it. │
└──────────────────────────────────────────────────────────────┘
The Security Boundary
┌──────────────────────────────────────────────────────────────┐
│ HOST RESTRICTION AND CORS │
│ │
│ Default: host 0.0.0.0 │
│ ┌─────────────────────┐ │
│ │ Accepts connections │ │
│ │ from localhost AND │ │
│ │ the local network │ │
│ └─────────────────────┘ │
│ │
│ Restricted: host 127.0.0.1 │
│ ┌─────────────────────┐ │
│ │ Accepts connections │ │
│ │ from localhost only │ │
│ └─────────────────────┘ │
│ │
│ CORS is restricted to the configured host. │
│ A malicious website cannot read the source code │
│ via the development server. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| CLI flag | --serve --servedir=DIR |
| API | ctx.serve({ servedir, port, host }) |
| Default port | 8000 (first free) |
| Default host | 0.0.0.0 |
| Build output | Served from memory |
| Static files | Served from servedir |
| Live reload | /esbuild SSE endpoint |
| Client script | Must be added manually |
| HMR | Not supported |
| Production use | Not intended |
Key takeaways:
servediris for files not in the build output. The server serves the in-memory build result first, then falls back toservedir. The HTML file is almost always inservedirbecause esbuild does not generate HTML. If the HTML is inoutdir, the same directory works for both .- The serve API rebuilds on request. It does not require a completed build to start. The first request triggers a build, and the result is served from memory. This is why
servedircan be separate fromoutdir. - Live reload requires a client script. The server exposes
/esbuildas an SSE endpoint, but it does not inject the client script. The developer addsnew EventSource('/esbuild').addEventListener('change', () => location.reload())to the client, guarded for development only . - The host defaults to
0.0.0.0, which is exposed to the LAN. Settinghost: '127.0.0.1'restricts the server to localhost. The CORS configuration was fixed to prevent cross-origin reads of the source code . - The serve API is not a production server. It is not designed for security, does not provide HMR, and should not be exposed to public traffic. It is a development convenience for serving the build output and providing live reload .
Remember: The serve API is a thin layer over the build output. It serves what esbuild produced and what exists in servedir, and it provides a live-reload signal when the build changes. It does not transform files on request, does not resolve modules at runtime, and does not inject the client script. The servedir flag exists because the HTML is not part of the build output; it is a static file that the server needs to find. Configure the host restriction, add the client script manually, and treat the server as a development tool, not a deployment target.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!