Integrations and bundlers
A .node file is a shared library loaded by the JavaScript runtime. It is not
JavaScript and should not be transformed, concatenated into a bundle, or sent
to a browser. Most integration problems disappear once the generated loader is
kept intact and executed by Node at runtime.
Understand the generated loader
With napi build --platform, NAPI-RS generates a JavaScript loader that:
- Detects
process.platform,process.arch, and Linux libc. - Tries a local file such as
addon.linux-x64-gnu.node. - Tries the separately published optional package such as
@scope/addon-linux-x64-gnu. - Falls back to the configured WASI binding when native loading failed.
- Throws one error whose
causechain contains the native-candidate load failures. Ordinary WASI fallback failures are not appended to that chain; useNAPI_RS_FORCE_WASI=errorwhen diagnosing WASI specifically.
The loader also recognizes two diagnostic controls:
NAPI_RS_NATIVE_LIBRARY_PATH=/absolute/addon.nodereplaces normal native platform and package selection with one explicit library. If that load fails, the loader records the error and can proceed to a configured WASI fallback, but it does not try the ordinary native candidates.NAPI_RS_ENFORCE_VERSION_CHECK=1rejects a separately published platform package whose version differs from the root package.
Keep the loader outside the application bundle whenever possible. It must be
able to perform its runtime detection and resolve optional dependencies from a
real node_modules tree.
Choose CommonJS or ESM deliberately
The native library itself has no module format. Only the generated JavaScript loader is CommonJS or ESM.
CommonJS package
napi build --platform --js index.cjs
{
"main": "./index.cjs",
"types": "./index.d.ts"
}
const { add } = require('@scope/addon')
Use a .cjs extension if the package has "type": "module"; otherwise Node
will parse a CommonJS loader as ESM.
ESM package
napi build --platform --esm --js index.js
{
"type": "module",
"main": "./index.js",
"types": "./index.d.ts"
}
import { add } from '@scope/addon'
The generated ESM loader uses createRequire internally because Node still
loads .node libraries through require. --esm changes the exported wrapper
to real static named ESM exports; it does not convert the native binary.
Dual CommonJS and ESM exports
Generate both loaders from the same native artifact:
{
"type": "module",
"main": "./index.cjs",
"module": "./index.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js",
"require": "./index.cjs"
}
},
"scripts": {
"build": "napi build --platform --js index.cjs && napi build --platform --esm --js index.js"
}
}
Test both import() and require() in CI. Test runners that transpile ESM can
exercise a different path than plain Node, which is why an ESM-only Jest error
is not proof that the native library failed to load.
Recommended bundler strategy: externalize
The most robust application build leaves the root addon package external. The deployment then contains:
- the generated loader;
- the root package metadata;
- the matching optional platform package and its
.nodefile.
This is the packaging model recommended for the open bundler request
(napi-rs#1948). It avoids
hashed asset names, relocated __dirname, and bundlers eagerly following every
platform-specific require branch.
WARNING
Marking a dependency external means the deployed runtime must still be able to resolve it. Copy production dependencies, install them in the deployment image, or provide them through a serverless layer. Externalization alone does not package the addon.
esbuild
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['src/server.js'],
bundle: true,
platform: 'node',
format: 'esm',
outdir: 'dist',
external: ['@scope/addon', '@scope/addon-*'],
})
Copy/install @scope/addon and its matching optional dependency beside the
bundle. Do not use a file/copy loader unless you have deliberately chosen a
single-platform, inline-binary package and verified the final relative paths.
webpack
module.exports = {
target: 'node',
externals: {
'@scope/addon': 'commonjs @scope/addon',
},
}
If the import name is computed or wrapped, use an externals function/plugin
that keeps the entire addon package external. node-loader can copy a direct
.node import, but it does not by itself preserve the generated loader's
platform and optional-package control flow.
A single-platform inline binary
Sometimes an internal application ships only one known target and keeps the
.node file in the root package rather than separate optional packages. In
that case:
- Keep the generated wrapper as an external file.
- Copy the
.nodefile without a content hash. - Preserve the relative path expected by the wrapper.
- Fail the build if more than one target could reach the deployment.
- Test from the final archive/image, not from the source tree.
This is a deployment-specific optimization, not a portable npm package.
Vite SSR and Astro
Native addons are server-only dependencies. Keep them out of Vite dependency optimization and SSR bundling:
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
exclude: ['@scope/addon'],
},
ssr: {
external: ['@scope/addon'],
},
})
Import the addon only from server modules. A component that is bundled for the
browser cannot load a native .node library.
Astro uses Vite, so the same externalization applies through its vite config.
When a CommonJS package does not expose named exports to Rollup, either emit the
NAPI-RS wrapper with --esm or load the CommonJS package in server code:
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { add } = require('@scope/addon')
The Astro integration report remains open as napi-rs#2206. Verify the adapter's final server output because an adapter may run another bundling step after Vite.
Next.js
Use the addon only in the Node.js runtime: route handlers, server actions, or server components that are not assigned to the Edge runtime. Externalize the package from the server bundle:
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['@scope/addon'],
}
export default nextConfig
export const runtime = 'nodejs'
import { add } from '@scope/addon'
export function GET() {
return Response.json({ value: add(20, 22) })
}
Do not import the addon from a Client Component, middleware using the Edge runtime, or code shared with either one. Ensure your deployment platform copies the external package and its optional binary dependency.
Electron
Electron can load a Node-API addon in the main process and in Node-enabled preload/renderer contexts. Prefer loading it in the main process or a preload script and expose a narrow IPC API; enabling unrestricted Node integration in a renderer expands the application's security boundary.
For packaged applications:
- build/install the binary for Electron's actual operating system and CPU;
- keep
.nodefiles outside ASAR compression (asarUnpack) or use the packager's native-module unpack support; - keep optional platform packages in production dependencies;
- test an installed/package artifact, including window reload and shutdown;
- test every Electron architecture you distribute.
Node-API reduces dependence on a particular V8 ABI, but it does not make a Linux x64 binary load on Windows or macOS. If another dependency uses the V8 addon ABI instead of Node-API, it may still need Electron-specific rebuilding.
Serverless and containers
Build and install for the deployment runtime, not the developer laptop. For example, a Linux Lambda deployment needs a Linux binary for the function's x64 or arm64 architecture and compatible glibc version.
A reliable flow is:
- Build/publish separate platform packages from CI.
- Install production dependencies for the deployment platform.
- Externalize the addon from the JavaScript bundle.
- Copy the root and optional platform packages into the image, function, or layer.
- Start the final artifact in the provider's base image and call one native export as a smoke test.
For Linux libc and target selection, follow Cross build. If the provider does not allow native addons but supplies the required WASI runtime features, consider the documented WASI fallback and test that host explicitly.
Diagnose a bundled deployment
Run these probes inside the final container/archive environment:
node -p "process.execPath"
node -p "process.platform + ' ' + process.arch"
node -p "process.report?.getReport?.().header.glibcVersionRuntime || 'no glibc version reported'"
npm ls @scope/addon
Then import the external package with plain Node. If it works before bundling
but not from the final bundle, inspect whether the bundler relocated the loader,
renamed the .node file, removed an optional dependency, or selected an Edge/
browser runtime. The troubleshooting guide shows
how to print the native loader failures in the cause chain and how to force a
separate WASI diagnostic.