Skip to content

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:

  1. Detects process.platform, process.arch, and Linux libc.
  2. Tries a local file such as addon.linux-x64-gnu.node.
  3. Tries the separately published optional package such as @scope/addon-linux-x64-gnu.
  4. Falls back to the configured WASI binding when native loading failed.
  5. Throws one error whose cause chain contains the native-candidate load failures. Ordinary WASI fallback failures are not appended to that chain; use NAPI_RS_FORCE_WASI=error when diagnosing WASI specifically.

The loader also recognizes two diagnostic controls:

  • NAPI_RS_NATIVE_LIBRARY_PATH=/absolute/addon.node replaces 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=1 rejects 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

sh
napi build --platform --js index.cjs
package.json
json
{
  "main": "./index.cjs",
  "types": "./index.d.ts"
}
js
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

sh
napi build --platform --esm --js index.js
package.json
json
{
  "type": "module",
  "main": "./index.js",
  "types": "./index.d.ts"
}
js
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:

package.json
json
{
  "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.

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 .node file.

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

build.mjs
js
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

webpack.config.cjs
js
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:

  1. Keep the generated wrapper as an external file.
  2. Copy the .node file without a content hash.
  3. Preserve the relative path expected by the wrapper.
  4. Fail the build if more than one target could reach the deployment.
  5. 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:

vite.config.ts
ts
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:

ts
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:

next.config.mjs
js
/** @type {import('next').NextConfig} */
const nextConfig = {
  serverExternalPackages: ['@scope/addon'],
}

export default nextConfig
app/api/add/route.ts
ts
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 .node files 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:

  1. Build/publish separate platform packages from CI.
  2. Install production dependencies for the deployment platform.
  3. Externalize the addon from the JavaScript bundle.
  4. Copy the root and optional platform packages into the image, function, or layer.
  5. 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:

sh
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.