Skip to content

Importing Node-API .node modules in Expo Router API Routes? #1377

Open
expo/expo
#53246

Description

@karlhorky

Companion issue (expo/expo): expo/expo#32350

Do you want to request a feature or report a bug?

Request a feature:

Documentation about Node-API native addons usage in Metro with platform: 'web', which Expo Router API Routes uses.

What is the current behavior?

Metro does not allow for importing Node-API native addons in platform: 'web' code, meaning that npm package containing native addons (such as @node-rs/bcrypt) cannot be used in Expo Router API Routes.

For example:

app/hash+api.ts

import { hashSync } from '@node-rs/bcrypt-darwin-arm64';

export async function GET(request: Request) {
  const hash = hashSync('hello world');
  return Response.json({ hash: 'hash' });
}

Reproduction (with Expo Router API Routes): https://github.com/karlhorky/repro-expo-router-api-routes-node-rs-bcrypt

Out of the box, Expo Router API Routes will not be able to bundle this, because the .node extension is not supported (as also mentioned by @EvanBacon here):

Android Bundled 10400ms node_modules/expo-router/entry.js (1132 modules)
λ Bundling failed 16ms app/hash+api.ts (1 module)

Metro error: Failed to bundle API Route: hash+api.ts

While trying to resolve module `@node-rs/bcrypt-darwin-arm64` from file `/Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/app/hash+api.ts`, the package `/Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/node_modules/@node-rs/bcrypt-darwin-arm64/package.json` was successfully found. However, this package itself specifies a `main` module field that could not be resolved (`/Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/node_modules/@node-rs/bcrypt-darwin-arm64/bcrypt.darwin-arm64.node`. Indeed, none of these files exist:

  * /Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/node_modules/@node-rs/bcrypt-darwin-arm64/bcrypt.darwin-arm64.node(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.mjs|.mjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css)
  * /Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/node_modules/@node-rs/bcrypt-darwin-arm64/bcrypt.darwin-arm64.node/index(.web.ts|.ts|.web.tsx|.tsx|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs|.web.mjs|.mjs|.web.scss|.scss|.web.sass|.sass|.web.css|.css)

Changing sourceExts to also contain .node (adding sourceExts.push('node') in node_modules/@expo/metro-config/build/ExpoMetroConfig.js) yields the following (trying to load the .node binary file):

Android Bundled 11972ms node_modules/expo-router/entry.js (1132 modules)
λ Bundling failed 191ms app/hash+api.ts (3 modules)

Metro error: Failed to bundle API Route: hash+api.ts

SyntaxError: /Users/k/p/repro-expo-router-api-routes-node-rs-bcrypt-sha1-not-computed/node_modules/@node-rs/bcrypt-darwin-arm64/bcrypt.darwin-arm64.node: Unexpected character '�'. (1:0)

> 1 | ����
          ���__TEX__text__TEXT�?��?�__stubs__TEXT�����
                                                      __stub_helper__TEXT������__const__TEXT���`��__gcc_except_tab__TEXT�8p�8__cstring__TEXTV*
                                                                                                                                              V__unwind_info__TEXTDb�Db__eh_frame__TEXT@z��@z
                          h8__DATA_CONST@@__got__DATA_CONST0h__mod_init_func__DATA_CONST0X0     __const__DATA_CONST��4�x__DATA@�@@__la_symbol_ptr__DATA@@@n__data__DATA@C�@C__thread_vars__DATA�Z��Z__thread_data__DATA�[(�[__thread_bss__DATA�[A�[__bss__DATA@\�&__common__DATA�
    | ^
  2 | �H__LINKEDIT�
  3 | @�x2
  4 | �/Users/runner/work/node-rs/node-rs/target/aarch64-apple-darwin/release/deps/libnode_rs_bcrypt.dylib"�0����x��    ��(��p���
                                                                                                                                 Pn���[�k@�q>����f"�(2 

                                                                                                                                                       *
                                                                                                                                                        8/usr/lib/libiconv.2.dylib
               8xA/usr/lib/libSystem.B.dylib&��)���������o

 ERROR  [SyntaxError: JSON Parse error: Unexpected character: <]

Unclear how to add "externals" in Metro

Thinking through this, one idea came up to use something similar to "externals" - code that Metro will not touch / bundle, but rather just load at runtime.

I can't see how I can modify the Metro configuration for Expo Router API Routes, to add something similar to webpack externals.

Maybe this feature doesn't exist in Metro?

If such a feature were to exist, this would also help with usage of the standard bcrypt package in Expo Router API Routes:

Metro Configuration

I believe this is the Metro configuration for Expo Router API Routes:

https://github.com/expo/expo/blob/48d64e9c285871e513f11238933e4abe7d7d1d9a/packages/%40expo/cli/src/start/server/metro/MetroBundlerDevServer.ts#L554-L577

 const opts: ExpoMetroOptions = { 
   // TODO: Possibly issues with using an absolute path here... 
   mainModuleName: convertPathToModuleSpecifier(filePath), 
   lazy: false, 
   asyncRoutes: false, 
   inlineSourceMap: false, 
   engine: 'hermes', 
   minify: false, 
   bytecode: false, 
   // Bundle in Node.js mode for SSR unless RSC is enabled. 
   environment: this.isReactServerComponentsEnabled ? 'react-server' : 'node', 
   platform: 'web', 
   mode: 'development', 
   // 
   ...this.instanceMetroOptions, 
  
   // Mostly disable compiler in SSR bundles. 
   reactCompiler: false, 
   baseUrl, 
   routerRoot, 
   isExporting, 
  
   ...specificOptions, 
 }; 

If the current behavior is a bug, please provide the steps to reproduce and a minimal repository on GitHub that we can yarn install and yarn test.

I'm not sure this is a bug, but just a gap in the documentation - that Node-API native addons are not intended to be supported in the platform: 'web' configuration.

What is the expected behavior?

It's documented (and a workaround is provided, if possible)

Please provide your exact Metro configuration and mention your Metro, node, yarn/npm version and operating system.

  • Metro: 0.80.12
  • Node.js: v20.18.0
  • Yarn: 1.22.22
  • npm: 10.8.2
  • macOS 15.0.1

Activity

  1. robhogan commented on Oct 27, 2024

    @robhogan
    Collaborator

    Hi @karlhorky - I'm not certain of all the details for how bundling is set up for Expo server-side routes - but at first glance I'd try assetExts and not sourceExts for .node files. Sources will be run through the transformer, which will choke on binary files.

    If that doesn't help I'll give this a closer look later in the week.

    (Generalised externals support is planned, but not there yet.)

  2. marklawlor commented on Oct 31, 2024

    @marklawlor
  3. karlhorky commented on Oct 31, 2024

    @karlhorky
    Author

    @marklawlor This issue #1377 is for a slightly different package actually - @node-rs/bcrypt instead of bcrypt - which does not have the same issues with @mapbox/node-pre-gyp.

    So the notes about @mapbox/node-pre-gyp, mock-aws-s3, etc. actually don't belong in this issue.

    But if you think I should open a separate Metro issue for bcrypt itself, then happy to do that too.

  4. chrisvariety commented on Apr 21, 2025

    @chrisvariety

    @karlhorky did you ever figure out a way around this?

  5. karlhorky commented on Apr 21, 2025

    @karlhorky
    Author

    @chrisvariety I didn't have a chance to follow up with @robhogan's suggestions in his comment - I would recommend following Rob's suggestions as a start:

    at first glance I'd try assetExts and not sourceExts for .node files. Sources will be run through the transformer, which will choke on binary files.

    If that doesn't help I'll give this a closer look later in the week.

  6. heineiuo commented on Apr 29, 2025

    @heineiuo
  7. karlhorky commented on Apr 30, 2025

    @karlhorky
    Author

    @heineiuo not sure I understand... does this allow for imports of Node-API native addons?

    For example, could hashSync from @node-rs/bcrypt-darwin-arm64 be imported in an Expo Router API Route?

    My original example code from the issue description:

    app/hash+api.ts

    import { hashSync } from '@node-rs/bcrypt-darwin-arm64';
    
    export async function GET(request: Request) {
      const hash = hashSync('hello world');
      return Response.json({ hash: 'hash' });
    }

    Reproduction (with Expo Router API Routes): https://github.com/karlhorky/repro-expo-router-api-routes-node-rs-bcrypt

  8. karlhorky commented on Jul 11, 2025

    @karlhorky
    Author

    Ohh it looks like @kraenhansen and others just announced Node-API Support for React Native on the Callstack blog 🔥 👀

    PR to add Node-API support to Hermes from @vmoroz here:

    Some early packages are available here:

  9. daniel-- commented on Nov 10, 2025

    @daniel--

    Is there any update on this? I'm having the same problem with @libsql/linux-x64-gnu

  10. karlhorky commented on Nov 9, 2026

    @karlhorky
    Author

    Update Callstack's Node-API support for React Native effort I linked in my comment above:

    1. The Hermes Node-API PR by @vmoroz aimed to bring existing Node-API support from React Native Windows to Hermes on other platforms. This PR was closed in May 2026 because Meta decided not to accept it and implemented its own version instead.

    2. Meta added its own Node-API implementation to Hermes in May 2026, with Node-API v10 support. This allows native code to interact with JavaScript, but doesn't automatically allow React Native apps to use native addons (.so files, .framework bundles). React Native also needs to load these files and provide support for things like background tasks and callbacks between native code and JavaScript. The version of Hermes included with React Native doesn't have Meta's Node-API implementation yet.

    3. @kraenhansen is now working on adding the missing React Native support, using Meta's implementation:

      The draft shows that React Native can load and run native addons using Meta's Hermes implementation. This would remove the need for a custom version of Hermes, which is currently required by https://github.com/callstackincubator/react-native-node-api.

      However, from what I can tell, importing addons directly from npm packages using require() or import is outside the scope of this PR. This appears to be currently handled in part by react-native-node-api, which will need to be adapted to use the new React Native support.

    For the original issue here, reading again it appears that Callstack's Node-API support for React Native is unrelated actually, because Expo Router API Routes run on a Node.js server where native addons are already supported.

    The problem in this issue is that Metro cannot resolve and bundle imports of .node files correctly, requiring a way to leave native addons out of the JavaScript bundle so Node.js can load them at runtime, such as the previously mentioned externals support.

  11. karlhorky commented on Nov 9, 2026

    @karlhorky
    Author

    @robhogan following up on your assetExts suggestion from your comment from 2024 about importing Node-API native addons (.node files) in Expo Router API Routes:

    Reproduction

    https://github.com/karlhorky/repro-expo-router-api-routes-node-rs-bcrypt-assetexts

    Tested locally with expo start:

    • macOS 27.2, Apple silicon (arm64)
    • Python
    • Command Line Tools for Xcode
    • Node.js 24.13.0
    • pnpm 12.6.0
    • Expo SDK 58 (expo 58.0.17)
    • Expo Router (expo-router 58.0.25)
    • Expo CLI (@expo/cli 58.1.18)
    • @expo/metro-config 58.0.20
    • Metro 0.87.1
    • @node-rs/bcrypt-darwin-arm64 2.0.0

    API Routes:

    • /hello imports hello directly from the locally compiled hello.node.
    • /node-rs imports hashSync from @node-rs/bcrypt-darwin-arm64.
    • /hello-commonjs imports a one-line CommonJS wrapper that loads the same hello.node with require(). This wrapper works in Node.js.

    The repro adds node to assetExts in metro.config.js:

    const { getDefaultConfig } = require('expo/metro-config');
    
    const config = getDefaultConfig(__dirname);
    
    config.resolver.assetExts.push('node');
    
    module.exports = config;
    1. Install dependencies, build hello.node, and confirm both addons work in Node.js:

      git clone https://github.com/karlhorky/repro-expo-router-api-routes-node-rs-bcrypt-assetexts.git
      cd repro-expo-router-api-routes-node-rs-bcrypt-assetexts
      pnpm install --frozen-lockfile
      pnpm build-addon
      pnpm control

      pnpm control prints "message":"hello from native" and "verified":true for the bcrypt hash.

    2. Start Expo CLI:

      pnpm start
    3. In another terminal, request the API Routes:

      curl --include http://localhost:8107/hello
      curl --include http://localhost:8107/node-rs
      curl --include http://localhost:8107/hello-commonjs

    Expected result

    HTTP 200 with {"message":"hello from native"} from /hello and /hello-commonjs, and a bcrypt hash from /node-rs.

    Actual result

    /hello and /node-rs return HTTP 500. The server log shows errors when calling hello() and hashSync():

    Metro error: (0 , _nativeBuildReleaseHelloNode.hello) is not a function
      GET (app/hello+api.js:4:40)
    
    Metro error: (0 , _nodeRsBcryptDarwinArm.hashSync) is not a function
      GET (app/node-rs+api.js:4:24)
    

    /hello-commonjs returns HTTP 500 too. Loading the addon through a JavaScript file containing require() fails with this error in its server log:

    Metro error: addon.default.hello is not a function
      GET (app/hello-commonjs+api.js:4:41)
    

    Investigation steps

    I tried to quickly do a bit of poking around (assisted by gpt-6.1-sol):

    1. Load the same addon without Expo, to check whether the addon itself works. native/hello-commonjs.cjs contains:

      module.exports = require('./build/Release/hello.node');

      Importing this file with Node.js works, as shown in the saved Node.js output:

      $ node --input-type=module --eval "import addon from './native/hello-commonjs.cjs'; console.log(addon.hello());"
      hello from native
    2. Check what the API Route receives from the import. The /hello-asset route returns the imported value instead of trying to call hello():

      $ curl --silent http://localhost:8107/hello-asset
      {"type":"string","value":"/assets/?unstable_path=.%2Fnative%2Fbuild%2FRelease/hello.node"}

      This is a download URL for the file. The API Route expects an object containing hello(), but receives this text string, which explains the hello is not a function error. The saved request output shows the same result for the bcrypt package at /node-rs-asset:

      curl --silent http://localhost:8107/node-rs-asset
    3. Find where that URL is created. Adding node to assetExts makes Metro treat the .node file as an asset, like an image or font. Expo's asset transformer turns that asset into a URL. The generated JavaScript for hello.node contains:

      module.exports = "/assets/?unstable_path=.%2Fnative%2Fbuild%2FRelease/hello.node";

      The generated JavaScript for the CommonJS wrapper loads this generated module, so its require() also receives the URL string.

    4. Check that the file at that URL is still the correct native addon. The file comparison shows that downloading it returns HTTP 200 and the same SHA-256 as the local hello.node file.

      I added another API Route, /native-require, to the same Expo app. When this URL is requested, the handler loads both native addons from disk using Node.js, then calls hello() and hashes and verifies a password.

      The route's code uses createRequire() from node:module. This Node.js API creates a function for loading files and packages. The handler uses it to load the addons when the request arrives, instead of importing them at the top of the file:

      const nodeRequire = createRequire(`${process.cwd()}/package.json`);
      const hello = nodeRequire('./native/build/Release/hello.node');
      const bcrypt = nodeRequire('@node-rs/bcrypt-darwin-arm64');

      Requesting /native-require returns HTTP 200. The recorded response contains "message":"hello from native", a bcrypt hash, and "verified":true. process.cwd() makes the file paths relative to the directory where the server was started, which is the repo root for these tests.

    🤯🎉 Both native addons can run in an Expo API Route! The failures occur when Metro processes their imports, rather than when Node.js loads the files itself.

    Next, I checked how Expo already lets Node.js load other modules:

    1. Find out how Expo already loads Node.js modules without processing their contents with Metro. For example, node:fs is the built-in Node.js file API. Expo CLI's requestNodeExternals() function tells the server to load these built-in modules with Node.js by generating a call to $$require_external().

      Its isNodeExternal() check only recognizes Node.js built-in module names. These .node files are not built-in modules, so they do not match. With node in assetExts, Metro treats them as assets and Expo generates the URL strings from step 3.

    With the workaround in the next comment, the same three requests return HTTP 200 without changing the API Routes. The recorded responses contain {"message":"hello from native"} for /hello and /hello-commonjs, and a bcrypt hash for /node-rs.

    I also tested this locally with sourceExts, using resolution.filePath for sourceFile instead of resolution.filePaths[0] for assetFiles, and the requests returned HTTP 200.

    I've opened a draft Expo PR for native-addon loading in development server bundles:

  12. karlhorky commented on Nov 9, 2026

    @karlhorky
    Author

    Workaround

    Apply these pnpm patches for Expo SDK 58 to load native addons in API Routes during development and include the native files in server exports. No Metro configuration is needed.

    Download these two files into a patches/ directory in your app:

    Merge these settings into pnpm-workspace.yaml:

    overrides:
      "@expo/cli": 58.1.18
      "@expo/metro-config": 58.0.20
    patchedDependencies:
      "@expo/cli@58.1.18": patches/@expo__cli@58.1.18.patch
      "@expo/metro-config@58.0.20": patches/@expo__metro-config@58.0.20.patch

    Install the patches and bcrypt:

    pnpm install
    pnpm exec expo install @node-rs/bcrypt@2.0.0

    Use the package in an API Route:

    import { hash, verify } from '@node-rs/bcrypt';
    
    export async function GET() {
      const passwordHash = await hash('hello world', { cost: 12 });
      return Response.json({
        verified: await verify('hello world', passwordHash),
      });
    }

    The patches handle two separate problems:

    1. Loading native files: Metro normally processes imports as JavaScript or assets. For .node imports in API Routes, the patches generate JavaScript that loads the native binary with Node.js instead.
    2. Including native files in deployments: expo export also needs to include the binaries in its output. The patches copy them into dist/server and generate paths relative to the exported API Route files, so loading does not depend on the original project location.

    Deploy to a service that supports loading native .node files in Node.js, such as Vercel. Tested responses from Vercel after deployment:

    https://github.com/karlhorky/repro-expo-api-routes-node-rs-bcrypt-native-addons/blob/4f98d895c219ec3c037b11127ad183ac0786e9e7/logs/pnpm-patch-vercel-requests.txt

    Source:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions