+1 (415) 943-4271

Writing Native Modules in 2026: Turbo Modules, Nitro Modules, and When to Write Neither

Sooner or later every serious React Native app needs native code: a vendor SDK with no JS wrapper, a Bluetooth or camera workflow, a hardware integration, a hot loop that is too slow across a normal module boundary. With the New Architecture now the only architecture, the way you write that code has changed — and there are three reasonable answers rather than one.

This is the decision tree we walk clients through, plus the practical bits that bite you after the first commit.

First: do you actually need a native module?

Most "we need native code" requests dissolve under questioning. Before you open Xcode:

  • Is there a maintained community package? A well-maintained wrapper you contribute a PR to is cheaper than a module you own forever.
  • Can it live on the server? Heavy processing that does not need device hardware usually belongs in your backend.
  • Is it a performance problem with a JS answer? List virtualisation, worklets on the UI thread, and the React Compiler solve a lot of what looks like "JS is too slow".

Every native module you write is a permanent tax: two platforms, two languages, two toolchains, and a rebuild every time it changes. Write one when you are getting real leverage — device APIs, vendor SDKs, or measured performance wins.

The three options

1. Turbo Modules (the core primitive)

A Turbo Module is the New Architecture's typed, lazily-loaded native module. You declare the interface in a TypeScript spec file, codegen generates the C++/Objective-C/Java scaffolding, and you implement it natively. Calls go through JSI rather than the old asynchronous JSON bridge, so synchronous calls are possible and payload marshalling is far cheaper.

The shape of the work:

// src/specs/NativeDeviceAttest.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  isSupported(): boolean;                     // sync is allowed now
  requestToken(nonce: string): Promise<string>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('DeviceAttest');

Codegen reads that spec at build time and produces the base classes you extend in Swift/Kotlin (via the generated Obj-C/JNI glue). The spec is the contract: if the native side drifts, the build fails rather than the app.

Choose Turbo Modules when you want zero extra dependencies, you are shipping a library for the broadest possible audience, or your module is small.

The friction: the codegen setup, the C++ glue for anything beyond primitives, and per-platform boilerplate that gets tedious fast.

2. Nitro Modules (ergonomics on top of JSI)

Nitro is a third-party module framework built on the same JSI foundations. It generates far more of the binding layer for you, lets you write plain Swift and Kotlin with typed objects, supports passing objects and callbacks both directions cheaply, and gives you a much shorter path to a working module.

It is the pragmatic pick for app-internal modules and for high-throughput cases (frame processors, audio buffers, crypto) where you want JSI speed without hand-writing C++. The trade-off is a dependency on a framework outside React Native core and a smaller, faster-moving ecosystem — fine inside your own app, a judgement call for a widely published open-source library.

3. Expo Modules API

If your app is already on Expo (managed or bare with CNG), the Expo Modules API is usually the fastest route. You write Swift and Kotlin with a small declarative DSL, get views, permissions, lifecycle events and config plugins for free, and it works under the New Architecture:

public class DeviceAttestModule: Module {
  public func definition() -> ModuleDefinition {
    Name("DeviceAttest")

    Function("isSupported") { () -> Bool in
      DCAppAttestService.shared.isSupported
    }

    AsyncFunction("requestToken") { (nonce: String) -> String in
      try await Attestation.token(for: nonce)
    }
  }
}

No spec file, no codegen ceremony, and a config plugin can inject the Info.plist and Gradle changes your module needs so npx expo prebuild stays reproducible.

Picking one

SituationReach for
Expo app, needs a device API or vendor SDKExpo Modules API
App-internal module, performance sensitiveNitro Modules
Public library, minimum dependenciesTurbo Modules
Just a native view (map, player, scanner)Fabric native component, via whichever framework above you picked

All three end up on JSI under the New Architecture. This is an ergonomics and ownership decision, not a capability one.

Things that bite after the first commit

Threading. JSI calls arrive on the JS thread. Anything that blocks — disk, network, crypto, a vendor SDK that likes the main thread — must hop to a background queue and come back through a promise or an event. A "fast" synchronous call that does 40 ms of work is a dropped-frame generator.

Events. Prefer a typed event emitter over polling. Under the New Architecture emitters are part of the spec, so both sides stay in sync; make sure you tear down listeners on unmount or you will leak native observers.

Nullability and enums. Codegen maps TypeScript types onto native types with rules that are stricter than you expect. Optional fields, unions and enums are where builds break first — model them explicitly rather than passing loose objects.

Rebuilds. A native module means no more JS-only OTA fixes for that surface. Keep the native layer thin and put policy, retries and formatting in TypeScript, so most bug fixes can still ship through an OTA update rather than a store release.

Autolinking and CNG. Ship a config plugin (Expo) or correct podspec/Gradle metadata (bare) so consumers do not have to hand-edit native projects. If your app uses Continuous Native Generation, anything you edit by hand in ios/ or android/ will be erased on the next prebuild.

Testing a native module

Three layers, in order of value for money:

  1. Native unit tests (XCTest / JUnit) for the logic inside the module — this is where the hard bugs live and where a simulator is not needed.
  2. A JS mock of the spec, so the rest of your app can be tested in Jest and React Native Testing Library without native code loaded.
  3. One end-to-end flow in Maestro on a real build, proving the wiring works on both platforms. You do not need broad E2E coverage of a module; you need one honest smoke test per platform in CI.

A sane workflow

Start the module inside the app, as a local package in your monorepo. Get it working on both platforms behind a small TypeScript facade that the rest of the app imports. Only extract it into its own repo once the API has stopped changing — and if it never stops changing, that is a signal the boundary is in the wrong place.

Keep the native API narrow and boring: a handful of functions, a typed event stream, and no business logic. The best native modules read like device drivers, not features.

Where we fit

We write and maintain native modules for clients across all three approaches — vendor SDK wrappers, Bluetooth and camera integrations, and JSI-backed performance work — and we hand them over with the tests, config plugin and docs needed for your team to own them. If you have native code on the roadmap, or an existing module that broke on the New Architecture, get in touch.