A Node single executable application is one binary that carries the Node runtime and your bundled script together, so users run ./mytool with no Node installed. On Node 26 you write a sea-config.json, run node --build-sea sea-config.json, and re-sign the result on macOS. Node 24 and 22 still need the older blob-plus-postject route.
The feature has existed since Node 19.7 and 18.16, but for most of that time it meant copying the node binary, generating a preparation blob, and injecting it with a separate npm tool. Node 25.5 added --build-sea, which does the copy and the injection in one step. Node 26 is now the LTS line, so as of October 2026 that one-step flow is something you can put in a production release pipeline. The single executable applications docs still mark the whole feature as Stability 1.1, active development, and warn that “the process documented here is subject to change”. Pin your Node version in CI and treat a major upgrade as something to re-test.
What goes into the binary?
One JavaScript file.
The injected main script does not read from the file system when it loads modules. By default, require() and import inside it can only load Node’s built-in modules. Your node_modules folder is not there, and neither are your other source files. So before Node ever sees your code, a bundler has to flatten the app into a single file.
esbuild does this in one command. With --platform=node it marks every built-in module such as fs as external, and with --bundle it inlines everything else:
npx esbuild src/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.js
If your source is TypeScript, point esbuild at the .ts entry and it strips the types on the way through. (For running TypeScript during development, without a build at all, see our post on running TypeScript in Node without ts-node.)
CommonJS output is the safe default. The Node 26 docs add a mainFormat field that accepts "module" for an ES module entry point, but the Node 24 docs say the feature “currently only supports running a single embedded script using the CommonJS module system”. If you need to build on both lines, emit CommonJS.
How do you build a Node single executable application with —build-sea?
Write the config next to your bundle:
{
"main": "dist/cli.js",
"output": "dist/mytool",
"disableExperimentalSEAWarning": true,
"useCodeCache": true,
"execArgv": ["--max-old-space-size=4096"],
"execArgvExtension": "none",
"assets": {
"template.html": "assets/template.html"
}
}
main and output are the only required fields, and relative paths resolve against the current working directory. On Windows, output must end in .exe. disableExperimentalSEAWarning stops the binary printing the experimental warning to every user on every run. useCodeCache stores V8’s code cache in the binary to speed up startup.
execArgv bakes Node flags into the binary so they apply at startup without the user passing anything. execArgvExtension controls whether the person running it can add more: "env" (the default) honours NODE_OPTIONS, "cli" accepts a --node-options="..." argument, and "none" accepts neither. Set it to "none" for a tool you hand to customers, and a stray NODE_OPTIONS in somebody’s shell profile cannot change how your binary behaves.
Then build and sign:
npx esbuild src/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.js
node --build-sea sea-config.json
# macOS only: the injection invalidates the original signature
codesign --sign - dist/mytool
./dist/mytool --help
The codesign --sign - line applies an ad-hoc signature, which is enough to run the binary on the machine that built it. Distributing to other Macs means signing with a real Developer ID and notarising, which is a separate job from anything Node does. On Windows, signtool sign /fd SHA256 mytool.exe signs it if you have a certificate, and the docs note that an unsigned binary still runs.
How do you read bundled files at runtime?
Anything listed under assets is embedded in the binary and read back through the node:sea module. getAsset(key) returns an ArrayBuffer copy, getAsset(key, 'utf8') decodes it to a string, getAssetAsBlob(key) returns a Blob, and getRawAsset(key) gives you the underlying bytes without copying (do not write to that buffer). getAssetKeys(), added in 24.8 and 22.20, lists what is there.
isSea() lets the same code run both inside the binary and from a normal node dist/cli.js during development:
const { readFileSync } = require('node:fs');
const path = require('node:path');
const sea = require('node:sea');
function loadAsset(name) {
if (sea.isSea()) {
return sea.getAsset(name, 'utf8');
}
return readFileSync(path.join(__dirname, '..', 'assets', name), 'utf8');
}
console.log(loadAsset('template.html').length);
Paths change too. Inside the binary, __filename and module.filename equal process.execPath, and __dirname is the folder the executable sits in. Code that builds paths relative to __dirname to find sibling files will look next to the binary, wherever the user happened to put it.
Node 26.9 added an option to mount assets as a read-only virtual file system (useVfs), which would let ordinary fs calls reach them. It carries its own Stability 1.0 marker and cannot be combined with useCodeCache or useSnapshot, so I would leave it alone until it settles.
What if you are still on Node 24 or 22?
Use the older three-step flow. Set output to a blob path rather than an executable, then:
node --experimental-sea-config sea-config.json
cp "$(command -v node)" mytool
codesign --remove-signature mytool
npx postject mytool NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
--macho-segment-name NODE_SEA
codesign --sign - mytool
That is the macOS version. On Linux, drop both codesign lines and the --macho-segment-name flag. The sentinel fuse string is how Node finds out a blob has been injected: the build looks for NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:0 in the binary and flips the final character to 1. The Node binary that generates the blob must be the same version as the one you inject it into, which is easy to break when a developer’s machine and the CI runner disagree.
Can you build for Linux and Windows from one machine?
Partly. The config takes an executable field pointing at the Node binary to build on top of, so a Mac can produce a Linux binary from a downloaded Linux build of Node. The docs attach a condition: for cross-platform builds, useCodeCache and useSnapshot must be false, or the output will be incompatible with the target.
Platform coverage is narrower than “anywhere Node runs”. The docs list the platforms the feature is tested on in Node’s CI: Windows, macOS on arm64 only, and Linux across supported distributions except Alpine, on every architecture except s390x. If you ship to Alpine containers or Intel Macs, you are outside what Node tests.
Native addons need extra work as well. A .node file cannot be loaded straight from the blob, so you embed it as an asset, write it to a temporary file at startup and call process.dlopen() on that path.
When to use it
For an internal CLI, a support tool you hand to a client, or a small agent that has to run on machines where you cannot install Node, a single executable is now the simplest delivery you have. I would build it on Node 26, bundle to CommonJS with esbuild, set execArgvExtension to "none", and run the build natively on each target OS in a CI matrix rather than cross-compiling, so you keep the code cache.
I would not use it for anything that leans on native addons, dynamic require() of user-supplied plugins, or Alpine as a target. And if the users already have Node, an npm package with a bin entry is smaller to download and simpler to update than a binary that carries a full copy of the runtime.
Whoooop builds Node.js tooling and services, from internal CLIs to the APIs behind them. If you have a script that needs to become something a client can run without a setup guide, our Node.js development work covers that.