Node.js was never meant to ship like it does currently.
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 just CLI, a real GUI executable, shipped the same way.
Node.js is an unbelievable C++ runtime if you know how to command it properly. Sadly, the tooling story, and the endless CommonJS vs ESM compatibility mess, can be a real pain.
So, in the spirit of bringing better tooling to Node.js, inspired by the most beautiful bundler I’ve ever seen, the Zig bundler
No external tools. The same language you build with is the one doing the bundling.
So I decided to lean into Node’s most underused feature:
Single Executable Applications (SEA).
And then I built tooling that actually makes it usable.
// 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.
Why SEA actually matters
- Your app, runtime, and assets ship as one file
- Startup is fast (especially with code cache) < - you also have low level utility like snapshots etc.
- Native modules are predictable
- The user experience matches real software, not a dev environment
This is how Go ships.
This is how Rust ships.
This is how Zig ships.
Node can do this too, it just never had good tooling around it.
That’s what sea-builder is.
Example 1: Simple Script
Note: You’ll need Node.js 20+
Check your version:
node -v
Start a project:
npm init -y && npm i sea-builder
Folder structure
app/
index.js
README.md
cross-build.js
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');
Put anything you want in README.md. Plain text is fine.
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 handles downloading the correct Node binary and patching it.
Easy, no?
But let’s do something more fun, a windowed app.
Example 2: tessera.js
tessera.js is a Node.js renderer I built. You can read more about it here How I Built a Graphics Renderer for Node.js, but since this article is about SEA, I’ve prepped a repo for you.
Clone it:
git clone https://github.com/sklyt/tessera-seaexample.git
Install dependencies:
npm i
Run the build:
node .\tesseraexample\build.js
Exe
.\dist\img.exe
The key difference here is that we compile everything down to CommonJS first, because ESM is the enemy of everything good, including SEA.
// inside build.js
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'
}
});
Once the code is bundled to CommonJS, we call sea-builder as usual.
const bundlerOptions = {
entry: bundlePath, // <-- bundled entry, your 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 not cross compile use ci workflows for that)
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));
tessera.js is configured to load assets from an SEA via this helper in img.js:
const getter = (name) => {
try {
return getAsset(name); // string or ArrayBuffer
} catch {
return null;
}
};
const { Renderer, FULLSCREEN, RESIZABLE } =
loadRendererSea({ assetGetterSync: getter });
And that’s it, you now have a sharable executable.
In an upcoming version of sea-builder, the terminal won’t launch alongside GUI executables. Still figuring that out. Worst case, I’ll patch the binary headers directly.
But honestly?
It already works.
Common Build Profiles
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: '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.
Profile usage
// 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
Thanks for reading!
Find me here:


Top comments (0)