Building Single-Executable Node.js Apps (Without the Pain)
Source: Dev.to
Single Executable Applications (SEA)
Node.js was never meant to ship the way it does today.
By the end of this article you’ll have a running command‑line executable – no node install, no npm install, no “works on my machine”. Just a binary.
And not only a CLI, but a real GUI executable, shipped the same way.
Node.js is an incredible C++ runtime when you command it properly.
Unfortunately the tooling story, plus the endless CommonJS ↔ ESM compatibility mess, can be a real pain.
In the spirit of bringing better tooling to Node.js, inspired by the most beautiful bundler I’ve ever seen – the Zig bundler – I decided to lean into Node’s most under‑used feature:
Quick Start
// build.js
import { release } from "sea-builder";
const production = {
entry: "app/index.js",
name: "myapp",
platforms: ["linux-x64", "win-x64"],
outDir: "./dist",
assets: {
"README.md": "./README.md",
},
};
release(production)
.then((res) => console.log(res))
.catch((err) => console.log(err));
That’s it – you get an executable that contains:
- Your app
- The Node runtime
- All assets
Benefits
- Fast startup (especially with code cache)
- Low‑level utilities like snapshots
- Predictable native modules
- A user experience that matches real software, not a dev environment
This is how Go, Rust, and Zig ship. Node can do it too – it just never had good tooling. That’s what sea‑builder is.
Note: Requires Node.js 20+. Check your version with
node -v.
Install
npm init -y && npm i sea-builder
Project layout
app/
index.js
README.md
cross-build.js
Example: Simple CLI
// app/index.js
const { getAsset, isSea } = require("node:sea");
console.log(`Hello, ${process.argv[2] || "World"}!`);
console.log(`Running from: ${process.execPath}`);
console.log(`Platform: ${process.platform}`);
console.log("Running as SEA!\n");
const readme = getAsset("README.md", "utf8");
console.log("readme:", readme);
// binary asset (returns ArrayBuffer)
const configBinary = getAsset("README.md");
console.log(
"Binary size:",
configBinary.byteLength,
"bytes"
);
// cross-build.js
const { release } = require("sea-builder");
const production = {
entry: "app/index.js",
name: "myapp",
platforms: ["linux-x64", "win-x64"],
outDir: "./dist",
assets: {
"README.md": "./README.md",
},
};
release(production)
.then((res) => console.log(res))
.catch((err) => console.log(err));
Run the build:
node cross-build.js
sea-builder downloads the correct Node binary and patches it. Easy, right?
Example: Windowed (GUI) App – tessera.js
tessera.js is a Node.js renderer I built.
Read more about it in the article [How I Built a Graphics Renderer for Node.js].
For this guide we’ll use a prepared repository.
git clone https://github.com/sklyt/tessera-seaexample.git
cd tessera-seaexample
npm i
node ./tesseraexample/build.js
The resulting executable will be at ./dist/img.exe.
Why bundle to CommonJS first?
ESM is the enemy of everything good, including SEA.
We first bundle everything to CommonJS, then hand the bundle to sea-builder.
// inside build.js (esbuild step)
await build({
entryPoints: [entryJs],
bundle: true,
platform: "node",
target: "node22", // adjust to your minimum Node target
format: "cjs",
outfile: bundlePath,
// Native binaries stay external
external: ["*.node", "*.dll"],
sourcemap: false,
logLevel: "info",
define: {
require: "require",
},
});
SEA configuration for the GUI app
const bundlerOptions = {
entry: bundlePath, // bundled script
name: appName,
platforms: targets,
outDir: outDir,
assets: {
"win-x64": {
"prebuilds/win32-x64/renderer.node": join(
__dirname,
"/prebuilds/win32-x64/renderer.node"
),
"prebuilds/win32-x64/glfw3.dll": join(
__dirname,
"/prebuilds/win32-x64/glfw3.dll"
),
"prebuilds/win32-x64/raylib.dll": join(
__dirname,
"/prebuilds/win32-x64/raylib.dll"
),
},
"linux-x64": {
"prebuilds/linux-x64/renderer.node": join(
__dirname,
"/prebuilds/linux-x64/renderer.node"
),
},
},
// Optional flags (code cache works only on the current platform;
// use CI workflows for cross‑compile builds)
useCodeCache: false,
useSnapshot: false,
};
console.log("Calling sea() with bundled entry and platform assets...");
await sea(bundlerOptions);
console.log("sea() finished. Outputs are in", path.resolve(outDir));
Loading assets from an SEA
// img.js (helper)
const getter = (name) => {
try {
return getAsset(name); // string or ArrayBuffer
} catch {
return null;
}
};
const { Renderer, FULLSCREEN, RESIZABLE } = loadRendererSea({
assetGetterSync: getter,
});
That’s it – you now have a shareable GUI executable.
Future note: In a forthcoming version of sea-builder the terminal won’t launch alongside GUI executables. If needed, you can patch the binary headers directly. But honestly, it already works.
Example sea-builder Configurations
module.exports = {
// Development build – fast iteration
dev: {
entry: "src/index.js",
name: "myapp-dev",
platforms: "current",
outDir: "./build",
useCodeCache: true,
},
// Production build – all platforms
production: {
entry: "src/index.js",
name: "myapp",
platforms: "all",
outDir: "./dist",
clean: true,
assets: {
"config.json": "./config/production.json",
"README.md": "./README.md",
LICENSE: "./LICENSE",
},
},
// Server build – Linux only
server: {
entry: "src/server.js",
name: "myapp-server",
platforms: ["linux-x64", "linux-arm64"],
outDir: "./dist/server",
useCodeCache: true,
assets: {
"config.json": "./config/server.json",
},
},
// CLI tool – small and portable
cli: {
entry: "src/cli.js",
name: "myapp-cli",
platforms: ["linux-x64", "win-x64", "macos-arm64"],
outDir: "./dist/cli",
useCodeCache: true,
assets: {
"help.txt": "./assets/help.txt",
},
},
};
Recap
- SEA lets you ship a single binary containing your Node app, the runtime, and any assets.
sea-builderautomates downloading the correct Node binary, patching it, and bundling your code (preferably as CommonJS).- The result feels like native Go/Rust/Zig binaries – fast startup, predictable native modules, and a clean user experience.
Give it a try and enjoy the simplicity of “write once, run everywhere” with Node.js!
Build Configuration
{
// …
cli: {
entry: './src/cli.js',
platforms: 'all',
outDir: './dist/cli'
}
}
Note:
useCodeCache: trueonly works on the current platform since it’s platform‑specific. Use CI to build cached binaries per target.
Build Script (build.js)
// build.js
const { sea } = require('sea-builder');
const config = require('./sea.config');
// node build.js production
const profile = process.argv[2] || 'dev';
if (!config[profile]) {
console.error(`Unknown profile: ${profile}`);
console.log('Available profiles:', Object.keys(config).join(', '));
process.exit(1);
}
console.log(`Building with profile: ${profile}`);
sea(config[profile]).then(results => {
console.log(`\nBuilt ${results.length} executable(s)`);
});
More from me
- Visualizing Evolutionary Algorithms in Node.js
- tessera.js repo
Thanks for reading!
Find me here
- X / Twitter – @yourhandle
- Substack – devlogs, deep dives, and long‑form content