Manual setup
Use this guide when you already have a Rust crate or JavaScript package, need a
minimal project, or want to place the Rust and JavaScript packages in different
parts of a monorepo. If you are starting a standalone package and want the full
release workflow, napi new is usually faster.
The CLI is a build and packaging tool. Your addon remains an ordinary Cargo crate, so you can use the workspace layout and package manager you already have.
Prerequisites
Install a current Rust toolchain, Node.js 22.13+ (or Node.js 24+) for the current CLI, and the NAPI-RS CLI in the JavaScript package that owns the addon:
rustc --version
node --version
npm install --save-dev @napi-rs/cli@^3
Keeping the CLI local makes local builds and CI use the version recorded by the
project. Run it through a package script or npx napi; a global installation is
not required.
Minimal project
The smallest useful layout is:
my-addon/
├── Cargo.toml
├── build.rs
├── package.json
├── src/
│ └── lib.rs
└── test.cjs
Configure Cargo
The library must be a cdylib: Node loads the resulting shared library rather
than linking it into another Rust executable. napi-build configures the output
for the host platform, and the default napi-derive features enable strict
macro validation and TypeScript definition generation.
[package]
name = "my-addon-native"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = "3"
napi-derive = "3"
[build-dependencies]
napi-build = "2"
Create the build script:
fn main() {
napi_build::setup();
}
Export a Rust function
use napi_derive::napi;
#[napi]
pub fn add(left: i32, right: i32) -> i32 {
left + right
}
Configure the JavaScript package
binaryName controls the generated file name. --platform adds the current
platform suffix and generates a loader that selects either the local binary or
the corresponding optional platform package.
{
"name": "my-addon",
"version": "0.1.0",
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"build": "napi build --platform",
"build:release": "napi build --platform --release",
"test": "node --test test.cjs"
},
"napi": {
"binaryName": "my-addon"
},
"devDependencies": {
"@napi-rs/cli": "^3"
}
}
Build and call the addon:
const assert = require('node:assert/strict')
const test = require('node:test')
const { add } = require('./index.js')
test('adds two numbers', () => {
assert.equal(add(2, 3), 5)
})
npm run build
npm test
A debug build produces these files in the crate directory by default:
index.d.ts
index.js
my-addon.<platform-arch-abi>.node
The .node file is the native library. Import index.js, not a hard-coded
platform file: the generated loader also handles libc selection, separately
published platform packages, and an optional WASI fallback.
INFO
Without --platform, the CLI copies a single my-addon.node file but does
not generate the JavaScript loader. That is useful for low-level experiments;
published packages should normally use --platform.
Common variations
Async functions
Enable the async feature when an exported Rust async fn should become a
JavaScript Promise:
[dependencies]
napi = { version = "3", features = ["async"] }
napi-derive = "3"
tokio = { version = "1", features = ["fs"] }
See Async and concurrency before choosing
between Tokio, AsyncTask, ThreadsafeFunction, and streams.
A custom output directory
All paths are relative to --cwd. Place the generated JavaScript, TypeScript,
and native files in a JavaScript package with:
napi build --platform --output-dir ./dist
Keep the loader and its local .node file together. Moving only index.js
breaks its relative lookup.
A separate config file
By default, the CLI reads the napi object from package.json. You can move
that object to JSON and pass --config-path; when both exist, the separate
file wins.
{
"binaryName": "my-addon",
"packageName": "@scope/my-addon",
"targets": [
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
]
}
napi build --platform --config-path napi.config.json
targets describes the artifacts you intend to package. A local build still
builds one target at a time; pass --target <rust-triple> explicitly in CI.
Cargo and JavaScript workspaces
The Rust crate and the JavaScript package do not have to share a directory. For example:
workspace/
├── Cargo.toml # [workspace] members = ["crates/native"]
├── crates/
│ └── native/
│ ├── Cargo.toml # package.name = "my-addon-native"
│ ├── build.rs
│ └── src/lib.rs
└── packages/
└── addon/
└── package.json # owns the napi config and generated output
Run the CLI from the workspace root while making every path explicit:
napi build \
--cwd packages/addon \
--manifest-path ../../Cargo.toml \
--package my-addon-native \
--package-json-path package.json \
--output-dir . \
--platform
The important distinction is:
| Option | Selects |
|---|---|
--cwd |
Base directory for all other relative paths |
--manifest-path |
Crate or workspace Cargo.toml used by cargo metadata |
--package |
Exact Cargo package name to build inside a workspace |
--package-json-path |
JavaScript package and NAPI-RS configuration |
--output-dir |
Destination for .node, loader, and .d.ts files |
If the manifest points at a virtual Cargo workspace, --package is required.
The CLI otherwise cannot know which cdylib member owns the addon.
Prepare for distribution
For one local machine, the generated loader and .node file are enough. A
published cross-platform package normally uses a separate optional npm package
for every target:
- Add all release triples to
napi.targets. - Build one
--platform --release --target <triple>artifact per CI job. - Run
napi create-npm-dirs. - Download the CI artifacts and run
napi artifacts. - Follow the release guide and read every side
effect of
napi pre-publishbefore publishing.
Do not publish a binary built on your development machine as if it supported other operating systems. Use the cross-build guide and test the final package on each runtime you claim to support.