Node.js native addons have always been the domain of C++. You write some C++, wire it through node-gyp, and hope that every developer on the team has the right Python version, C++ compiler, and build tools installed. If you've ever watched a new contributor spend their first hour debugging node-gyp rebuild failures instead of writing code, you know the pain. CI pipelines inherit the same complexity: provision Python 3, install platform-specific build toolchains, cross your fingers.

The C# Dev Kit team at Microsoft lived this reality. Their VS Code extension used a C++ addon to read the Windows Registry from TypeScript. It worked, but it dragged Python and a C++ toolchain into every contributor's setup. In April 2026, Drew Noakes announced they'd replaced that addon entirely with C# compiled via .NET 10 Native AOT. No Python. No node-gyp. No C++. Just dotnet publish.

This isn't a toy demo. It's production code running inside one of the most popular VS Code extensions. And the approach is surprisingly straightforward once you understand how Node.js loads native code.

How Node.js loads native addons

Node.js native addons are shared libraries (.dll on Windows, .so on Linux, .dylib on macOS) renamed to .node. When you call require('./my-addon.node'), Node.js loads the shared library and calls a well-known entry point: napi_register_module_v1.

The key technology here is Node-API (formerly N-API) -- a stable, ABI-compatible C interface that has been part of Node.js since version 8. Unlike the older V8 C++ API, Node-API doesn't change between Node.js major versions. An addon compiled against Node-API 8 will work on Node.js 18, 20, 22, and beyond without recompilation.

Node-API doesn't care what language produced the shared library. It only cares that the library exports the right symbols and calls functions through the correct C calling conventions. That's the opening .NET Native AOT walks through.

The project file

The .csproj is minimal. You need Native AOT enabled and unsafe blocks permitted for the function pointers:

NodeAddon.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

That's it. No NuGet packages for the raw N-API approach. The .NET SDK handles the rest.

The module entry point

When Node.js loads your .node file, it looks for a function called napi_register_module_v1. In C#, you export this using the UnmanagedCallersOnly attribute:

RegistryAddon.cs
public static unsafe partial class RegistryAddon
{
    [UnmanagedCallersOnly(
        EntryPoint = "napi_register_module_v1",
        CallConvs = [typeof(CallConvCdecl)])]
    public static nint Init(nint env, nint exports)
    {
        NativeLibrary.SetDllImportResolver(
            typeof(RegistryAddon).Assembly,
            (name, _, _) => name == "node"
                ? NativeLibrary.GetMainProgramHandle()
                : 0);

        RegisterFunction(env, exports, "readStringValue"u8, &ReadStringValue);
        return exports;
    }
}

There are a few things worth unpacking here.

UnmanagedCallersOnly tells the Native AOT compiler to export this method as a native symbol with the specified entry point name and calling convention. The nint type is a native-sized integer -- equivalent to C's intptr_t -- which is how N-API passes around opaque handles for environments, values, and callback info.

The NativeLibrary.SetDllImportResolver call solves a subtle problem. Your C# code needs to call N-API functions like napi_create_string_utf8, and those live inside the Node.js process itself. There's no node.dll to link against. The resolver tells .NET to look up any import from "node" against the main process handle, which is the running Node.js executable.

The "readStringValue"u8 syntax creates a UTF-8 byte literal, which is what N-API expects for string arguments. No allocation, no encoding conversion.

Declaring N-API imports

To call Node-API functions from C#, you use LibraryImport -- the source-generated replacement for DllImport:

NApi.cs
internal static partial class NApi
{
    [LibraryImport("node", EntryPoint = "napi_get_cb_info")]
    internal static partial int GetCallbackInfo(
        nint env, nint cbinfo, ref nuint argc,
        nint argv, out nint thisArg, out nint data);

    [LibraryImport("node", EntryPoint = "napi_create_string_utf8")]
    internal static partial int CreateStringUtf8(
        nint env, ReadOnlySpan<byte> str, nuint length, out nint result);

    [LibraryImport("node", EntryPoint = "napi_get_value_string_utf8")]
    internal static partial int GetValueStringUtf8(
        nint env, nint value, Span<byte> buf, nuint bufsize, out nuint result);

    [LibraryImport("node", EntryPoint = "napi_get_undefined")]
    internal static partial int GetUndefined(nint env, out nint result);
}

Because you declared "node" as the library name, the resolver from the entry point redirects these calls to the running Node.js process. No static linking required.

Marshalling strings across the boundary

String marshalling is where the real work happens. JavaScript strings are UTF-16 internally, but N-API exposes them as UTF-8. .NET strings are also UTF-16, so you need to encode and decode.

Reading a string argument from JavaScript involves three steps: get the byte length, allocate a buffer, then decode:

StringHelpers.cs
internal static string GetStringArg(nint env, nint info, int index)
{
    Span<nint> args = stackalloc nint[index + 1];
    var argc = (nuint)args.Length;
    NApi.GetCallbackInfo(env, info, ref argc, args[0], out _, out _);

    NApi.GetValueStringUtf8(env, args[index], Span<byte>.Empty, 0, out var length);

    Span<byte> buffer = length <= 256
        ? stackalloc byte[(int)length + 1]
        : new byte[length + 1];

    NApi.GetValueStringUtf8(env, args[index], buffer, (nuint)buffer.Length, out _);
    return Encoding.UTF8.GetString(buffer[..(int)length]);
}

The pattern here is typical of high-performance .NET interop. For small strings (256 bytes or fewer), stackalloc avoids any heap allocation entirely. For larger strings, you fall back to a regular array. In a production scenario, you'd use ArrayPool<byte> for the fallback path to avoid GC pressure under load.

Returning a string to JavaScript is simpler:

StringHelpers.cs
internal static nint CreateString(nint env, string value)
{
    var byteCount = Encoding.UTF8.GetByteCount(value);
    Span<byte> buffer = byteCount <= 256
        ? stackalloc byte[byteCount]
        : new byte[byteCount];

    Encoding.UTF8.GetBytes(value, buffer);
    NApi.CreateStringUtf8(env, buffer, (nuint)byteCount, out var result);
    return result;
}

A practical example: reading the Windows Registry

The C# Dev Kit's actual use case was reading registry values from TypeScript. In C++, this required the Windows SDK headers and careful memory management. In C#, it's trivial:

RegistryAddon.cs
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint ReadStringValue(nint env, nint info)
{
    try
    {
        var keyPath = GetStringArg(env, info, 0);
        var valueName = GetStringArg(env, info, 1);

        using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false);
        if (key?.GetValue(valueName) is string value)
        {
            return CreateString(env, value);
        }

        NApi.GetUndefined(env, out var undefined);
        return undefined;
    }
    catch
    {
        NApi.GetUndefined(env, out var undefined);
        return undefined;
    }
}

The try/catch is essential. An unhandled exception in an UnmanagedCallersOnly method would crash the entire Node.js process. You must catch everything and return a safe N-API value.

On the JavaScript side, consuming this is as simple as:

extension.js
const addon = require('./RegistryAddon.node');
const installPath = addon.readStringValue(
    'Software\\Microsoft\\VisualStudio',
    'InstallDir'
);

Registering functions

The RegisterFunction helper used in the entry point wires a C# method to a JavaScript function name:

RegistryAddon.cs
private static unsafe void RegisterFunction(
    nint env, nint exports, ReadOnlySpan<byte> name,
    delegate* unmanaged[Cdecl]<nint, nint, nint> callback)
{
    NApi.CreateFunction(env, name, callback, 0, out var fn);
    NApi.SetNamedProperty(env, exports, name, fn);
}

The delegate* unmanaged[Cdecl]<nint, nint, nint> syntax is a C# function pointer with an explicit calling convention. Combined with UnmanagedCallersOnly, this gives you zero-overhead interop -- no delegate allocation, no marshalling thunks.

Building and loading the addon

Build with dotnet publish:

terminal
dotnet publish -c Release -r win-x64

This produces a single native shared library. Rename it to have a .node extension and place it where your JavaScript code can require() it. For cross-platform support, you'd publish for each runtime identifier (win-x64, linux-x64, osx-arm64) and select the right binary at load time.

The output is a self-contained native binary. No .NET runtime required on the target machine. No dotnet CLI. Just the .node file.

When to use the higher-level SDK instead

The raw N-API approach shown above is ideal when you have a small, focused surface area -- a handful of functions that pass primitives and strings. The C# Dev Kit's registry reader is a perfect fit.

For richer scenarios -- exposing entire .NET classes to JavaScript, handling callbacks, working with complex object graphs -- Microsoft maintains the node-api-dotnet project. Its Microsoft.JavaScript.NodeApi NuGet package provides higher-level abstractions:

MathModule.cs
[JSExport]
public static class MathModule
{
    public static double Add(double a, double b) => a + b;
    public static double Multiply(double a, double b) => a * b;
}

The SDK handles type marshalling, class wrapping, async/Promise integration, and more. It supports both .NET runtime hosting and Native AOT compilation. Use it when your interop surface is complex enough that hand-rolling N-API calls becomes tedious.

For the raw approach, stick with it when you want zero dependencies, maximum control, and a minimal binary size.

Common pitfalls

Forgetting the try/catch in exported methods. Every UnmanagedCallersOnly method must catch all exceptions. An unhandled exception doesn't bubble up to JavaScript -- it terminates the Node.js process with no useful error message.

Using DllImport instead of LibraryImport. The older DllImport attribute works at runtime but isn't compatible with Native AOT's source generation model. Always use LibraryImport with partial methods.

Not setting up the DLL import resolver. Without NativeLibrary.SetDllImportResolver, the runtime won't find N-API functions because there's no node.dll on disk. The resolver must redirect to NativeLibrary.GetMainProgramHandle().

Allocating on every string conversion. N-API string operations happen frequently. Use stackalloc for small buffers and ArrayPool<byte> for larger ones. Allocating a byte[] on every call creates GC pressure that defeats the purpose of going native.

Skipping the .node rename. Node.js specifically looks for the .node extension when loading native addons via require(). A .dll or .so won't be found.

Ignoring cross-platform builds. Native AOT produces platform-specific binaries. If your addon needs to work on Windows, Linux, and macOS, you need to publish for each runtime identifier and implement platform detection in your JavaScript loader.

Summary