Using NW.js to create a Desktop App
By Flavio Copes
Run an NW.js desktop app from HTML, set the window in package.json, call Node from the page, then package it for Mac and Windows.
First, a disclaimer: this post is about packaging a web app so it runs on Mac and Windows like a native app. Linux works the same way, I’m just not covering it. I’ll show one small Node.js call from the page, because that’s what NW.js adds over a plain browser window. I’m not going into a full Node desktop app.
NW.js is, that’s how it’s called from its creators, a web runtime. I wrote this post when the project was still called node-webkit. The name changed in 2015, after Chromium replaced WebKit with its own Blink engine and the old name stopped making sense.
It’s based on Chromium and Node.js. It lets us call Node.js code and modules directly from the DOM, and opens new possibilities for native applications written using Web technologies. Electron is the other common Chromium-plus-Node runtime if you want that model instead.
The official site is nwjs.io, the source lives at github.com/nwjs/nw.js and you download builds from dl.nwjs.io. As of October 2026 the current release is v0.118, built on Chromium 155, and it bundles Node.js 26. NW.js ships a new version every few weeks to follow Chromium. There are two flavors: the SDK build includes DevTools, so use that one while developing, and the normal build is the smaller one you ship.
A plain HTML page in Chrome can’t read files from disk and can’t require() an npm package. NW.js loads that same page inside Chromium, then exposes Node in the same JavaScript context, so require('fs') works next to document.querySelector.
Run the app
First, let’s introduce the index.html file:
<html>
<body>
<p>test</p>
</body>
</html>
We also need a package.json file. main is the page NW.js opens at startup. name identifies the app. It must be unique, lowercase, with no spaces, because NW.js stores app data under that name.
The window block sets the first window. width and height are the inner size in pixels. title is what you see while the page is still loading.
{
"name": "my-web-app",
"main": "index.html",
"window": {
"title": "My Web App",
"width": 800,
"height": 600
}
}
Older tutorials set window.toolbar to hide the navigation bar. That field is deprecated since NW.js 0.13. Leave it out. If you want no native chrome at all, set "frame": false and give the user another way to close the window.
Now to run the app, download the SDK build for your platform and pass it the folder that contains package.json. On the Mac the binary is inside the .app bundle:
cd /PATH_TO_APP_DIRECTORY/
/PATH_TO/nwjs.app/Contents/MacOS/nwjs .
On Windows you run nw.exe . from the app folder, or drag the folder onto nw.exe.
If you’d rather not download anything by hand, the nw package on npm downloads the runtime for you. Run npm install --save-dev nw in the app folder, then npx nw . starts the app. Add @sdk if you want DevTools (npm install --save-dev nw@sdk).
You can also zip the whole app folder into a single file:
zip -r app.nw .
That’s the package we’ll use to distribute the app below. The current docs also let you ship the files unpacked, which starts faster. A big zip has to be extracted to a temp folder on every launch.
Call Node from the page
The HTML above is a web page. Replace it with this and you can see Node from the same file:
<html>
<body>
<p id="platform"></p>
<script>
const os = require('os')
document.getElementById('platform').textContent =
'You are running on ' + os.platform()
</script>
</body>
</html>
require is Node’s module loader. It works in the page because NW.js runs Chromium and Node in one process. npm packages you install next to package.json work the same way.
Be careful with native addons. A module compiled against regular Node will not load. Rebuild it with nw-gyp against the NW.js headers for that runtime version.
Native menus
Window size lives in the manifest. Menus are created in JavaScript with nw.Menu. This adds a menubar with one submenu:
<script>
const menu = new nw.Menu({ type: 'menubar' })
const file = new nw.Menu()
file.append(new nw.MenuItem({
label: 'Quit',
click: () => nw.App.quit()
}))
menu.append(new nw.MenuItem({
label: 'File',
submenu: file
}))
nw.Window.get().menu = menu
</script>
Assign the menubar to nw.Window.get().menu. If the page reloads, that menu object is gone, so for a real app put menu code in a background script (bg-script in the manifest) instead of in the page.
The rest of the manifest options (icon, kiosk, chromium-args, and so on) are in the NW.js Manifest format.
Now, let’s dig into packaging and distributing an app.
Package the app on the Mac
Unzip the normal nwjs.app package you downloaded, not the SDK one. Use the same NW.js version you developed against.
Right-click > Show Package Contents > Contents/Resources, then put your app.nw zip there (or copy the app directory and name it app.nw).
That’s it! You can now change the default icon (Contents/Resources/nw.icns) and properties in the Contents/Info.plist file. Set CFBundleDisplayName there if you want a different name in the menu bar.
macOS Gatekeeper will block an unsigned copy. People who download your zip see “cannot be opened because the developer cannot be verified.” For your own machine you can right-click the app and choose Open once. To ship to other people you need to sign with a Developer ID Application certificate. The NW.js docs walk through that in Support for Mac App Store, which also covers signing for distribution outside the store.
Package the app on Windows
Download the Windows normal package of the same NW.js version.
Put app.nw next to nw.exe, then run this command to merge the two into a single executable:
copy /b nw.exe+app.nw app.exe
The current docs name that zip package.nw. Same file, same copy /b trick. You can also skip the merge and leave the app files next to nw.exe, with package.json in that folder.
You can now remove the nw.exe and app.nw files, zip the directory and distribute the package. Your users will need to download the zip file, uncompress it and then run the app.exe file contained. Leave the rest of the NW.js files in that zip (d3dcompiler, ffmpeg, icudtl.dat, locales, and so on). The exe alone will not start.
For the details, like replacing the icon of nw.exe or renaming the Mac bundle, check the NW.js packaging docs.
Automate packaging with nw-builder
The copy-and-zip steps above work. They get old once you ship more than one platform. nw-builder downloads the runtime and lays out the app folder for you.
Install it in the app folder:
npm install --save-dev nw-builder
Then build. --glob=false treats the folder as the app, instead of expanding a glob. --flavor=normal is the build you ship. Pin version to the runtime you already tested, so Mac and Windows don’t drift:
npx nwbuild --mode=build --glob=false --flavor=normal --version=0.118.0 --platform=osx --arch=arm64 .
npx nwbuild --mode=build --glob=false --flavor=normal --version=0.118.0 --platform=win --arch=x64 .
Output lands in ./out by default. Use --flavor=sdk only when you want DevTools in a local build.
You can put the same options under an nwbuild key in package.json instead of typing the flags every time.
A few things that bite
Ship the normal build. The SDK zip includes DevTools plus tools (nwjc, payload, chromedriver) that you should not put in the download.
Use the same NW.js version on every platform. Mixing 0.118 on Mac with an older Windows runtime is a good way to get “works here, not there” bugs. nw-builder’s --version flag is how you keep that in one place.
If you install Node modules on one OS and zip them for another, native addons will break. Run npm install on each target, or skip native addons.
On a Mac, an unsigned .app shows a Gatekeeper warning. Sign it before you send the file to anyone else.
Want me to talk about your product? You can sponsor this site.