If you've ever built a VS Code extension that needs platform-specific access — reading the Windows Registry, querying system fonts, talking to a native library — you've probably resigned yourself to writing a C++ addon. That means pulling in node-gyp, which means installing Python, which means your contributors' first experience with your project is a wall of build errors on a fresh machine.

Microsoft's C# Dev Kit team lived this pain for years. Their VS Code extension needed to read the Windows Registry, and the C++ addon that handled it dragged in a Python dependency that complicated every CI pipeline and every new contributor's setup. Then someone asked the obvious question: the team already has the .NET SDK installed — why not write the addon in C#?

The answer is Native AOT. Since .NET 8, the PublishAot flag can produce shared libraries (.dll, .so, .dylib) with arbitrary native entry points. Node.js addons are just shared libraries that export a single function. The pieces fit together more naturally than you might expect.

How Node.js native addons actually work

A Node.js native addon is a shared library that follows a simple contract. When you require() a .node file, Node.js loads the shared library and calls one function:

c
napi_value napi_register_module_v1(napi_env env, napi_value exports);

That's it. N-API (also called Node-API) is the stable, ABI-compatible C API that Node.js exposes for this purpose. It doesn't care what language produced the shared library — only that the library exports that symbol and uses the N-API functions to register its methods.

This is the critical insight: Native AOT can produce shared libraries with exported entry points. N-API only needs a shared library with an exported entry point. There's no reason the language behind that library has to be C++.

Setting up the project

The .csproj is minimal:

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

Three things to note. PublishAot tells the compiler to produce a native shared library instead of a managed assembly. AllowUnsafeBlocks is required because you'll work with function pointers and raw nint handles — this is native interop, not a web controller. And there's no special NuGet package needed — the N-API functions come from the host Node.js process itself, which we'll resolve at runtime.

The module entry point

The [UnmanagedCallersOnly] attribute is what makes this work. It tells the AOT compiler to export a method as a native function with a specific name and calling convention:

ModuleInit.cs
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public static partial class ModuleInit
{
    [UnmanagedCallersOnly(
        EntryPoint = "napi_register_module_v1",
        CallConvs = [typeof(CallConvCdecl)])]
    public static nint Init(nint env, nint exports)
    {
        Initialize();
        RegisterFunction(env, exports, "readStringValue"u8, &ReadStringValue);
        return exports;
    }
}

When Node.js loads the .node file, it finds napi_register_module_v1 and calls it. The method registers your C# functions as properties on the exports object — exactly as you would in a C++ addon.

Resolving N-API functions against the host process

Here's the clever bit. Normally when you P/Invoke a native function, the runtime loads a separate library to find it. But N-API functions live in the Node.js executable itself — the host process that loaded your addon. You need to resolve your imports against the running process rather than a separate .dll or .so.

The solution is a custom DLL import resolver:

NativeInterop.cs
using System.Reflection;
using System.Runtime.InteropServices;

public static partial class NativeInterop
{
    [LibraryImport("node")]
    internal static partial int napi_create_function(
        nint env, nint name, nint length,
        delegate* unmanaged[Cdecl]<nint, nint, nint> cb,
        nint data, out nint result);

    [LibraryImport("node")]
    internal static partial int napi_set_named_property(
        nint env, nint obj, nint name, nint value);

    [LibraryImport("node")]
    internal static partial int napi_get_cb_info(
        nint env, nint cbinfo, ref nint argc,
        nint argv, nint thisArg, nint data);

    private static void Initialize()
    {
        NativeLibrary.SetDllImportResolver(
            Assembly.GetExecutingAssembly(),
            ResolveDllImport);
    }

    private static nint ResolveDllImport(
        string libraryName,
        Assembly assembly,
        DllImportSearchPath? searchPath)
    {
        if (libraryName == "node")
            return NativeLibrary.GetMainProgramHandle();

        return nint.Zero;
    }
}

Every [LibraryImport("node")] declaration tells the source generator to look for the function in a library called "node". The custom resolver intercepts that and returns NativeLibrary.GetMainProgramHandle() — a handle to the currently running process. Since Node.js is the host, all N-API symbols resolve correctly.

This pattern is reusable for any scenario where you need to call back into a host process from a loaded plugin.

String marshalling between JavaScript and .NET

Most of the interop work is moving strings back and forth. N-API represents strings as UTF-8 byte sequences, while .NET strings are UTF-16. You need to handle this conversion manually, and you need to do it without allocating on every call.

Reading a string argument from a JavaScript callback looks like this:

StringHelpers.cs
using System.Buffers;
using System.Runtime.InteropServices;
using System.Text;

public static partial class StringHelpers
{
    internal static string GetStringArg(nint env, nint cbinfo, int index)
    {
        nint argc = 1;
        nint argv;
        unsafe
        {
            napi_get_cb_info(env, cbinfo, ref argc, (nint)(&argv), 0, 0);
        }

        nint byteCount;
        napi_get_value_string_utf8(env, argv, nint.Zero, 0, out byteCount);

        if (byteCount <= 512)
        {
            Span<byte> stack = stackalloc byte[(int)byteCount + 1];
            unsafe
            {
                fixed (byte* ptr = stack)
                {
                    napi_get_value_string_utf8(
                        env, argv, (nint)ptr, byteCount + 1, out _);
                    return Encoding.UTF8.GetString(ptr, (int)byteCount);
                }
            }
        }

        byte[] rented = ArrayPool<byte>.Shared.Rent((int)byteCount + 1);
        try
        {
            unsafe
            {
                fixed (byte* ptr = rented)
                {
                    napi_get_value_string_utf8(
                        env, argv, (nint)ptr, byteCount + 1, out _);
                    return Encoding.UTF8.GetString(ptr, (int)byteCount);
                }
            }
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(rented);
        }
    }
}

The two-tier allocation strategy is worth noting. For strings under 512 bytes — which covers most identifiers, paths, and short values — stackalloc avoids any heap allocation entirely. For larger strings, ArrayPool<byte> recycles buffers instead of allocating fresh arrays. This matters when your addon handles thousands of calls per second.

Going the other direction — creating a JavaScript string from a .NET value — is simpler because you can use UTF-8 literals:

Example.cs
internal static nint CreateString(nint env, string value)
{
    var bytes = Encoding.UTF8.GetBytes(value);
    unsafe
    {
        fixed (byte* ptr = bytes)
        {
            napi_create_string_utf8(env, (nint)ptr, bytes.Length, out var result);
            return result;
        }
    }
}

A practical example: reading the Windows Registry

Here's the function that the C# Dev Kit team actually uses — reading a string value from the Windows Registry:

RegistryAddon.cs
using Microsoft.Win32;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public static partial class RegistryAddon
{
    [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);

            return key?.GetValue(valueName) is string value
                ? CreateString(env, value)
                : GetUndefined(env);
        }
        catch (Exception ex)
        {
            ThrowError(env, $"Registry read failed: {ex.Message}");
            return 0;
        }
    }

    private static nint GetUndefined(nint env)
    {
        napi_get_undefined(env, out var result);
        return result;
    }

    private static void ThrowError(nint env, string message)
    {
        var bytes = System.Text.Encoding.UTF8.GetBytes(message + '\0');
        unsafe
        {
            fixed (byte* ptr = bytes)
            {
                napi_throw_error(env, nint.Zero, (nint)ptr);
            }
        }
    }
}

The try/catch is not optional. An unhandled exception in an [UnmanagedCallersOnly] method terminates the entire host process — no JavaScript error, no stack trace, just a crash. Every exported function must catch its own exceptions and convert them to N-API errors with napi_throw_error.

Building and loading the addon

Publishing is a single command:

terminal
dotnet publish -r win-x64 -c Release

This produces a native shared library — for example, RegistryAddon.dll on Windows. Rename it to .node and you can require() it from JavaScript:

index.ts
interface RegistryAddon {
    readStringValue(keyPath: string, valueName: string): string | undefined;
}

const registry = require('./native/win32-x64/RegistryAddon.node') as RegistryAddon;

const sdkPath = registry.readStringValue(
    'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk',
    'InstallLocation'
);

console.log(`SDK installed at: ${sdkPath}`);

For cross-platform distribution, you'd publish for each target runtime (win-x64, linux-x64, osx-arm64) and load the correct binary at runtime based on process.platform and process.arch.

// IMPORTANT

Native AOT does not support cross-compilation. You must build on each target platform — a Windows machine for win-x64, a Linux machine for linux-x64, and so on. CI matrix builds handle this naturally.

When to use node-api-dotnet instead

The approach described here is deliberately low-level. You're writing raw N-API calls, managing memory manually, and handling string marshalling yourself. For simple, focused addons — a handful of functions that do platform-specific work — this is fine.

For richer scenarios, Microsoft maintains the node-api-dotnet library, which handles the boilerplate automatically. It provides type marshalling between JavaScript and .NET types, automatic TypeScript definition generation, async/promise handling, and class wrapping. If you need to expose an entire .NET class hierarchy to JavaScript, reach for node-api-dotnet. If you need three functions that read the registry, the raw approach gives you full control without an extra dependency.

Common pitfalls

Forgetting exception handling in exported methods. This bears repeating: an unhandled exception in [UnmanagedCallersOnly] doesn't throw a JavaScript error — it kills the entire Node.js process. Wrap every exported method in try/catch.

Assuming cross-compilation works. Native AOT is a platform-native compiler. You cannot build a Linux .so on a Windows machine or vice versa. Set up CI builds for each platform you need to support.

Leaking N-API handles. Every nint you get from N-API represents a handle that's scoped to the current callback. Don't store them beyond the lifetime of the function call unless you explicitly create a reference with napi_create_reference.

Ignoring binary size. A minimal Native AOT shared library starts at around 4-10 MB due to the bundled runtime and GC. This is fine for a VS Code extension but may be excessive for lightweight CLI tools. If binary size matters, consider trimming aggressively or using the low-level IlcTrimMetadata options.

Using reflection or dynamic loading. Native AOT does not support Assembly.LoadFrom, unreferenced reflection, or runtime code generation. If your addon logic depends on any of these, you'll hit runtime failures. Source generators and compile-time approaches are the way forward.

Summary