Building Native Modules in 2026: Turbo Modules, Expo Modules API, and Nitro Modules Compared

A hands-on guide to building React Native native modules in 2026. Compare Turbo Modules, Expo Modules API, and Nitro Modules with Swift, Kotlin, and TypeScript examples — plus benchmarks and a decision framework.

Why You Still Need Native Modules (And Why the Choices Have Never Been Better)

React Native gives you a lot out of the box. Between the built-in APIs, the Expo SDK, and the massive ecosystem of community packages, you can build entire production apps without ever touching native code. But at some point — and I'd argue it happens sooner than most tutorials suggest — non-trivial apps hit a wall. Maybe you need to integrate a proprietary SDK. Maybe you need raw performance for a computation-heavy feature. Or maybe there's a platform capability that simply doesn't have a JavaScript wrapper yet.

That's where native modules come in.

And in 2026, you've got more options than ever for building them. With the New Architecture now mandatory (starting with React Native 0.82, and carried forward in 0.83 and Expo SDK 55), the old bridge-based native modules are officially deprecated. The legacy architecture was frozen in June 2025 — no new features, no bugfixes, nothing. If you're writing native code today, you're writing it for the New Architecture. The only real question is which framework you use to do it.

This guide walks you through the three major approaches to building native modules in 2026: Turbo Modules (React Native's built-in system), the Expo Modules API (Expo's developer-friendly abstraction), and Nitro Modules (Margelo's high-performance alternative). We'll build a practical example with each, compare their trade-offs, and help you figure out which one actually fits your project.

A Quick Primer: How Native Modules Work Under the New Architecture

Before diving into code, it helps to understand the foundation all three frameworks share: the JavaScript Interface (JSI).

In the old React Native architecture, JavaScript communicated with native code through an asynchronous bridge. Every call was serialized to JSON, sent across the bridge, deserialized on the other side, processed, and the result sent back the same way. It worked, but honestly? It was slow — especially for high-frequency operations like animations or real-time data processing.

JSI changes this fundamentally. It's a C++ API that lets JavaScript hold direct references to native objects. When your JavaScript code calls a native method through JSI, it's not sending a message across a bridge — it's invoking a C++ function pointer directly. No serialization. No asynchronous queuing. Just a direct function call.

All three frameworks we'll cover — Turbo Modules, Expo Modules, and Nitro Modules — are built on top of JSI. Where they differ is in how much abstraction they layer on top, what languages they support directly, and what trade-offs they make between performance, developer experience, and ecosystem integration.

The Architecture Stack

Here's how the layers relate to each other:

┌─────────────────────────────────┐
│         Your JavaScript         │
├─────────────────────────────────┤
│     Turbo / Expo / Nitro API    │  ← You choose this layer
├─────────────────────────────────┤
│    JSI (JavaScript Interface)   │
├─────────────────────────────────┤
│  Hermes / JavaScriptCore Engine │
├─────────────────────────────────┤
│   Native Platform (iOS/Android) │
└─────────────────────────────────┘

Approach 1: Turbo Modules — React Native's Built-In System

Turbo Modules are React Native's official replacement for the old native module system. They're part of the core framework, so you don't need any additional dependencies. If you're building a library intended for the broadest possible audience, Turbo Modules are the most universal choice.

Key Characteristics

  • Lazy initialization: Turbo Modules are loaded only when first accessed, unlike old native modules which were all eagerly initialized at startup
  • Codegen-driven type safety: You define a TypeScript or Flow specification, and React Native's Codegen tool generates the native interface code
  • No extra dependencies: Everything you need is included in React Native itself
  • Language support: Objective-C and Java/Kotlin natively; Swift requires an Objective-C bridging header (yes, it's a bit annoying)

Building a Battery Status Module with Turbo Modules

Let's build something practical: a module that reads battery level and charging status from the device. First, create the TypeScript specification that Codegen will use:

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

export interface Spec extends TurboModule {
  getBatteryLevel(): Promise<number>;
  isCharging(): Promise<boolean>;
  // Constants are synchronous and available immediately
  getConstants(): {
    BATTERY_STATUS_UNKNOWN: number;
    BATTERY_STATUS_CHARGING: number;
    BATTERY_STATUS_FULL: number;
  };
}

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

This spec is the contract between JavaScript and native code. Codegen reads it and generates C++ interfaces that your native implementation must conform to. You can trigger Codegen manually to generate the native interface:

# Codegen runs automatically during the build, but you can
# trigger it manually for development:
cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install

iOS Implementation (Objective-C++)

Here's one quirk of Turbo Modules worth calling out: the iOS side currently requires Objective-C (or Objective-C++), not pure Swift. You can call into Swift code from Objective-C, but the module entry point itself has to be in Objective-C:

// ios/BatteryStatusModule.mm
#import <React/RCTBridgeModule.h>
#import <ReactCommon/RCTTurboModule.h>

// Generated by Codegen from your TypeScript spec
#import <NativeBatteryStatusSpec/NativeBatteryStatusSpec.h>
#import <UIKit/UIKit.h>

@interface BatteryStatusModule : NSObject <NativeBatteryStatusSpec>
@end

@implementation BatteryStatusModule

RCT_EXPORT_MODULE(BatteryStatus)

- (void)getBatteryLevel:(RCTPromiseResolveBlock)resolve
                 reject:(RCTPromiseRejectBlock)reject {
  dispatch_async(dispatch_get_main_queue(), ^{
    [UIDevice currentDevice].batteryMonitoringEnabled = YES;
    float level = [UIDevice currentDevice].batteryLevel;
    resolve(@(level * 100));
  });
}

- (void)isCharging:(RCTPromiseResolveBlock)resolve
            reject:(RCTPromiseRejectBlock)reject {
  dispatch_async(dispatch_get_main_queue(), ^{
    [UIDevice currentDevice].batteryMonitoringEnabled = YES;
    UIDeviceBatteryState state = [UIDevice currentDevice].batteryState;
    BOOL charging = (state == UIDeviceBatteryStateCharging ||
                     state == UIDeviceBatteryStateFull);
    resolve(@(charging));
  });
}

- (NSDictionary *)getConstants {
  return @{
    @"BATTERY_STATUS_UNKNOWN": @(UIDeviceBatteryStateUnknown),
    @"BATTERY_STATUS_CHARGING": @(UIDeviceBatteryStateCharging),
    @"BATTERY_STATUS_FULL": @(UIDeviceBatteryStateFull),
  };
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::
    NativeBatteryStatusSpecJSI>(params);
}

@end

Android Implementation (Kotlin)

// android/src/main/java/com/batterystatus/BatteryStatusModule.kt
package com.batterystatus

import android.content.Context
import android.os.BatteryManager
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.turbomodule.core.interfaces.TurboModule

class BatteryStatusModule(
  private val reactContext: ReactApplicationContext
) : NativeBatteryStatusSpec(reactContext) {

  override fun getName() = NAME

  override fun getBatteryLevel(promise: Promise) {
    val manager = reactContext.getSystemService(
      Context.BATTERY_SERVICE
    ) as BatteryManager
    val level = manager.getIntProperty(
      BatteryManager.BATTERY_PROPERTY_CAPACITY
    )
    promise.resolve(level.toDouble())
  }

  override fun isCharging(promise: Promise) {
    val manager = reactContext.getSystemService(
      Context.BATTERY_SERVICE
    ) as BatteryManager
    val charging = manager.isCharging
    promise.resolve(charging)
  }

  override fun getTypedExportedConstants(): Map<String, Any> {
    return mapOf(
      "BATTERY_STATUS_UNKNOWN" to 0,
      "BATTERY_STATUS_CHARGING" to 1,
      "BATTERY_STATUS_FULL" to 2
    )
  }

  companion object {
    const val NAME = "BatteryStatus"
  }
}

Using the Module in JavaScript

import NativeBatteryStatus from './specs/NativeBatteryStatus';

async function checkBattery() {
  const level = await NativeBatteryStatus.getBatteryLevel();
  const charging = await NativeBatteryStatus.isCharging();
  const constants = NativeBatteryStatus.getConstants();

  console.log(`Battery: ${level}%`);
  console.log(`Charging: ${charging}`);
  console.log(`Status codes:`, constants);
}

Turbo Modules: When to Choose This Approach

Turbo Modules are the right choice when you need zero additional dependencies, you're building a library for the community that should work in any React Native project, or you need C++ interop (Turbo Modules offer the most direct path to writing cross-platform C++ native code). The downside? The Objective-C requirement on iOS and the verbose boilerplate — especially compared to the next two options.

Approach 2: Expo Modules API — The Developer Experience Winner

The Expo Modules API is Expo's answer to the verbosity of Turbo Modules. It's built on the same JSI foundation, but it abstracts away most of the boilerplate and lets you write native code in Swift and Kotlin directly — no Objective-C bridging required. As of Expo SDK 55, all Expo Modules automatically support the New Architecture and are backwards compatible with older React Native versions.

I'll be honest: this is where the developer experience really starts to feel modern.

Key Characteristics

  • Swift and Kotlin first: Write your native code in modern languages without bridging headers
  • No Codegen step: The API handles type bridging at the framework level, which avoids those common (and frustrating) Codegen failures
  • Automatic backwards compatibility: Modules work with both old and New Architecture apps
  • Integrated tooling: create-expo-module scaffolds everything including example apps for testing
  • Requires the expo dependency: Your project (or consumers of your library) must have Expo installed

Scaffolding a New Expo Module

# Create a new module project
npx create-expo-module expo-battery-status

# This generates:
# ├── src/                    # TypeScript source
# │   ├── BatteryStatusModule.ts
# │   └── index.ts
# ├── ios/                    # Swift implementation
# │   └── BatteryStatusModule.swift
# ├── android/                # Kotlin implementation
# │   └── src/main/java/.../BatteryStatusModule.kt
# ├── example/                # Example app for testing
# └── expo-module.config.json

iOS Implementation (Swift)

This is where the difference in developer experience becomes immediately obvious. Compare this to the Turbo Module version above:

// ios/BatteryStatusModule.swift
import ExpoModulesCore
import UIKit

public class BatteryStatusModule: Module {
  public func definition() -> ModuleDefinition {
    Name("BatteryStatus")

    Constants {
      [
        "BATTERY_STATUS_UNKNOWN": UIDevice.BatteryState.unknown.rawValue,
        "BATTERY_STATUS_CHARGING": UIDevice.BatteryState.charging.rawValue,
        "BATTERY_STATUS_FULL": UIDevice.BatteryState.full.rawValue
      ]
    }

    AsyncFunction("getBatteryLevel") { () -> Double in
      await MainActor.run {
        UIDevice.current.isBatteryMonitoringEnabled = true
        return Double(UIDevice.current.batteryLevel * 100)
      }
    }

    AsyncFunction("isCharging") { () -> Bool in
      await MainActor.run {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let state = UIDevice.current.batteryState
        return state == .charging || state == .full
      }
    }

    // Events — built-in support for sending events to JS
    Events("onBatteryLevelChanged")

    OnStartObserving {
      UIDevice.current.isBatteryMonitoringEnabled = true
      NotificationCenter.default.addObserver(
        self,
        selector: #selector(batteryLevelDidChange),
        name: UIDevice.batteryLevelDidChangeNotification,
        object: nil
      )
    }

    OnStopObserving {
      NotificationCenter.default.removeObserver(self)
    }
  }

  @objc private func batteryLevelDidChange() {
    let level = UIDevice.current.batteryLevel * 100
    sendEvent("onBatteryLevelChanged", [
      "level": Double(level)
    ])
  }
}

Android Implementation (Kotlin)

// android/src/main/java/expo/modules/batterystatus/BatteryStatusModule.kt
package expo.modules.batterystatus

import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class BatteryStatusModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("BatteryStatus")

    Constants {
      mapOf(
        "BATTERY_STATUS_UNKNOWN" to 0,
        "BATTERY_STATUS_CHARGING" to 1,
        "BATTERY_STATUS_FULL" to 2
      )
    }

    AsyncFunction("getBatteryLevel") {
      val manager = appContext.reactContext
        ?.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
      manager?.getIntProperty(
        BatteryManager.BATTERY_PROPERTY_CAPACITY
      )?.toDouble() ?: -1.0
    }

    AsyncFunction("isCharging") {
      val manager = appContext.reactContext
        ?.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
      manager?.isCharging ?: false
    }

    Events("onBatteryLevelChanged")
  }
}

TypeScript Wrapper

// src/BatteryStatusModule.ts
import { requireNativeModule } from 'expo-modules-core';

interface BatteryStatusModuleType {
  getBatteryLevel(): Promise<number>;
  isCharging(): Promise<boolean>;
  BATTERY_STATUS_UNKNOWN: number;
  BATTERY_STATUS_CHARGING: number;
  BATTERY_STATUS_FULL: number;
}

export default requireNativeModule<BatteryStatusModuleType>(
  'BatteryStatus'
);
// src/index.ts
import BatteryStatusModule from './BatteryStatusModule';
import { EventEmitter, Subscription } from 'expo-modules-core';

const emitter = new EventEmitter(BatteryStatusModule);

export function getBatteryLevel(): Promise<number> {
  return BatteryStatusModule.getBatteryLevel();
}

export function isCharging(): Promise<boolean> {
  return BatteryStatusModule.isCharging();
}

export function addBatteryLevelListener(
  listener: (event: { level: number }) => void
): Subscription {
  return emitter.addListener('onBatteryLevelChanged', listener);
}

export const Constants = {
  BATTERY_STATUS_UNKNOWN: BatteryStatusModule.BATTERY_STATUS_UNKNOWN,
  BATTERY_STATUS_CHARGING: BatteryStatusModule.BATTERY_STATUS_CHARGING,
  BATTERY_STATUS_FULL: BatteryStatusModule.BATTERY_STATUS_FULL,
};

Expo Modules: When to Choose This Approach

For most teams in 2026, the Expo Modules API is the best choice. Period. Choose it when you want the fastest development velocity, your project already uses Expo (which, let's face it, most new projects do), you prefer Swift and Kotlin over Objective-C and Java, or you want built-in event support and other niceties. The main trade-off is the dependency on the expo package — but given Expo's dominance in the ecosystem (over 70% of new React Native projects use Expo as of 2026), that's rarely a real concern anymore.

Approach 3: Nitro Modules — Maximum Performance

Nitro Modules, created by Marc Rousavy (the developer behind react-native-vision-camera), take a fundamentally different philosophical approach. Where Expo Modules optimize for developer experience and Turbo Modules optimize for universality, Nitro Modules optimize for raw performance. They use direct C++ to Swift interop (bypassing Objective-C entirely) and compile the JSI bindings statically at build time rather than at runtime.

If you're thinking "do I really need that kind of performance?" — you probably don't for most features. But when you do, nothing else comes close.

Key Characteristics

  • Extreme performance: Benchmarks show Nitro is up to 59x faster than Expo Modules and 15x faster than Turbo Modules for numerical operations
  • Hybrid Objects: Native objects that live in JavaScript with full lifecycle management — create, pass around, and garbage-collect them naturally
  • No Objective-C: Uses Swift's C++ interop directly, eliminating an entire layer of overhead
  • First-class function support: Pass JavaScript callbacks to native code and keep them in memory with automatic cleanup
  • Third-party dependency: Maintained by Margelo, not by React Native or Expo

Setting Up a Nitro Module

# Initialize a new Nitro module
npx nitro-codegen@latest init react-native-battery-status-nitro

# Or add Nitro to an existing library:
npm install react-native-nitro-modules

Defining the TypeScript Interface

Nitro uses TypeScript interfaces that extend HybridObject to define native module shapes. The Nitrogen codegen tool then generates the native bindings:

// src/specs/BatteryStatus.nitro.ts
import { type HybridObject } from 'react-native-nitro-modules';

export interface BatteryStatus extends HybridObject<{
  ios: 'swift';
  android: 'kotlin';
}> {
  getBatteryLevel(): Promise<number>;
  isCharging(): Promise<boolean>;

  // Nitro supports synchronous methods with near-zero overhead
  getBatteryLevelSync(): number;

  // First-class callback support
  onBatteryLevelChanged(
    callback: (level: number) => void
  ): () => void;  // Returns an unsubscribe function
}
# Run Nitrogen to generate native bindings
npx nitrogen generate

iOS Implementation (Swift)

// ios/HybridBatteryStatus.swift
import NitroModules
import UIKit

class HybridBatteryStatus: HybridBatteryStatusSpec {
  // Nitro manages the memory size hint for the garbage collector
  var hybridContext = margelo.nitro.HybridContext()
  var memorySize: Int { return getSizeOf(self) }

  func getBatteryLevel() async throws -> Double {
    await MainActor.run {
      UIDevice.current.isBatteryMonitoringEnabled = true
      return Double(UIDevice.current.batteryLevel * 100)
    }
  }

  func isCharging() async throws -> Bool {
    await MainActor.run {
      UIDevice.current.isBatteryMonitoringEnabled = true
      let state = UIDevice.current.batteryState
      return state == .charging || state == .full
    }
  }

  // Synchronous — executes on the JS thread with minimal overhead
  func getBatteryLevelSync() -> Double {
    UIDevice.current.isBatteryMonitoringEnabled = true
    return Double(UIDevice.current.batteryLevel * 100)
  }

  func onBatteryLevelChanged(
    callback: @escaping (Double) -> Void
  ) -> (() -> Void) {
    UIDevice.current.isBatteryMonitoringEnabled = true
    let observer = NotificationCenter.default.addObserver(
      forName: UIDevice.batteryLevelDidChangeNotification,
      object: nil,
      queue: .main
    ) { _ in
      let level = Double(UIDevice.current.batteryLevel * 100)
      callback(level)
    }

    // Return cleanup function — Nitro handles the lifecycle
    return {
      NotificationCenter.default.removeObserver(observer)
    }
  }
}

Android Implementation (Kotlin)

// android/src/main/java/com/batterystatus/HybridBatteryStatus.kt
package com.batterystatus

import android.content.Context
import android.os.BatteryManager
import com.margelo.nitro.batterystatus.HybridBatteryStatusSpec
import com.margelo.nitro.core.Promise

class HybridBatteryStatus(
  private val context: Context
) : HybridBatteryStatusSpec() {

  override val memorySize: Long
    get() = 0L

  override fun getBatteryLevel(): Promise<Double> {
    return Promise.async {
      val manager = context.getSystemService(
        Context.BATTERY_SERVICE
      ) as BatteryManager
      manager.getIntProperty(
        BatteryManager.BATTERY_PROPERTY_CAPACITY
      ).toDouble()
    }
  }

  override fun isCharging(): Promise<Boolean> {
    return Promise.async {
      val manager = context.getSystemService(
        Context.BATTERY_SERVICE
      ) as BatteryManager
      manager.isCharging
    }
  }

  override fun getBatteryLevelSync(): Double {
    val manager = context.getSystemService(
      Context.BATTERY_SERVICE
    ) as BatteryManager
    return manager.getIntProperty(
      BatteryManager.BATTERY_PROPERTY_CAPACITY
    ).toDouble()
  }

  override fun onBatteryLevelChanged(
    callback: (Double) -> Unit
  ): () -> Unit {
    // Register a battery level listener
    // (simplified — production code would use BroadcastReceiver)
    return { /* cleanup */ }
  }
}

Using the Nitro Module in JavaScript

import { NitroModules } from 'react-native-nitro-modules';
import type { BatteryStatus } from './specs/BatteryStatus.nitro';

// Create a Hybrid Object instance
const battery = NitroModules.createHybridObject<BatteryStatus>(
  'BatteryStatus'
);

// Async usage
const level = await battery.getBatteryLevel();

// Synchronous — runs directly on the JS thread via JSI
const levelSync = battery.getBatteryLevelSync();

// Callback-based events with automatic cleanup
const unsubscribe = battery.onBatteryLevelChanged((level) => {
  console.log(`Battery level changed: ${level}%`);
});

// Later: clean up
unsubscribe();

Nitro Modules: When to Choose This Approach

Nitro Modules shine in performance-critical scenarios: real-time image processing, audio/video manipulation, hardware sensor data, cryptographic operations, or any case where you're making thousands of JS-to-native calls per second. They're also excellent when you need synchronous native calls (something the other two frameworks don't support well) or when you want to model your native code as proper objects with lifecycle management. The trade-off? Nitro is a third-party library maintained by a smaller team, so you're taking on more ecosystem risk compared to the Meta- or Expo-backed options.

Head-to-Head Comparison

Alright, so let's get to the part you've probably been scrolling for. Here's a detailed comparison of all three approaches.

Performance

Based on the NitroBenchmarks project, here's how the three frameworks compare for raw JS-to-native call overhead:

Operation         | Turbo Modules | Expo Modules | Nitro Modules
──────────────────┼───────────────┼──────────────┼──────────────
Number (100k)     |  ~4.5ms       |  ~17.5ms     |  ~0.3ms
String (100k)     |  ~7.0ms       |  ~18.0ms     |  ~1.4ms
Object (100k)     |  ~12.0ms      |  ~35.0ms     |  ~2.1ms
Array (100k)      |  ~15.0ms      |  ~40.0ms     |  ~3.0ms

These benchmarks measure the overhead of 100,000 JS-to-native round trips. Impressive numbers, but here's the thing — in most real-world scenarios, the native work itself dominates. So the framework overhead really only matters in tight loops or high-frequency calls. For a typical "call a native API, get a result" pattern, all three are fast enough that you won't notice the difference.

Developer Experience

Feature              | Turbo Modules | Expo Modules | Nitro Modules
─────────────────────┼───────────────┼──────────────┼──────────────
Swift support        | Via ObjC      | Native       | Native (C++)
Kotlin support       | Native        | Native       | Native
Codegen required     | Yes           | No           | Yes (Nitrogen)
Boilerplate level    | High          | Low          | Medium
Event support        | Manual        | Built-in     | Callbacks
Sync methods         | Limited       | No           | Full support
Scaffold tool        | Manual setup  | create-expo  | nitro init
                     |               | -module      |

Ecosystem and Dependencies

Aspect               | Turbo Modules | Expo Modules | Nitro Modules
─────────────────────┼───────────────┼──────────────┼──────────────
Maintainer           | Meta          | Expo         | Margelo
Extra dependencies   | None          | expo package | nitro package
Works without Expo   | Yes           | No           | Yes
Community adoption   | Broad         | Growing fast | Niche
Documentation        | Official docs | Excellent    | Good
Production use       | Instagram,    | Expo SDK,    | VisionCamera,
                     | Facebook      | many apps    | select libs

Practical Decision Framework

After spending time with all three frameworks (and shipping production code with each), here's a practical decision tree that I think holds up well:

Choose Turbo Modules When:

  • You're building a library that must work in any React Native project, with or without Expo
  • You need to write cross-platform C++ code that compiles on both iOS and Android
  • You want zero third-party dependencies and maximum long-term stability
  • Your team is comfortable with Objective-C and doesn't mind the extra boilerplate

Choose Expo Modules When:

  • Your project uses Expo (the majority of new projects do)
  • Developer velocity is your top priority — you want to ship native features fast
  • You prefer working in Swift and Kotlin without bridging hassles
  • You want built-in event support, automatic backwards compatibility, and great tooling
  • Per-call performance overhead isn't a critical concern (and it rarely is for most apps)

Choose Nitro Modules When:

  • You're building something performance-critical where every microsecond counts
  • You need synchronous native method calls (camera processing, audio buffers, real-time sensors)
  • You want Hybrid Objects — native objects that behave like JavaScript objects with proper lifecycle management
  • You're building on top of libraries that already use Nitro (VisionCamera, Wishlist, etc.)

Advanced Patterns: Wrapping Third-Party SDKs

One of the most common reasons to write a native module is wrapping a third-party SDK. Let's look at how this works with the Expo Modules API, since it provides the smoothest experience for this particular use case.

Example: Wrapping a Hypothetical Analytics SDK

// ios/AnalyticsModule.swift
import ExpoModulesCore
import AnalyticsSDK  // The third-party framework

public class AnalyticsModule: Module {
  private var client: AnalyticsClient?

  public func definition() -> ModuleDefinition {
    Name("Analytics")

    Function("initialize") { (apiKey: String, options: [String: Any]?) in
      let config = AnalyticsConfig(apiKey: apiKey)

      if let flushInterval = options?["flushInterval"] as? Int {
        config.flushInterval = flushInterval
      }

      self.client = AnalyticsClient(config: config)
    }

    AsyncFunction("track") { (event: String, properties: [String: Any]?) in
      guard let client = self.client else {
        throw AnalyticsError.notInitialized
      }
      try await client.track(event: event, properties: properties ?? [:])
    }

    AsyncFunction("identify") { (userId: String, traits: [String: Any]?) in
      guard let client = self.client else {
        throw AnalyticsError.notInitialized
      }
      try await client.identify(userId: userId, traits: traits ?? [:])
    }

    AsyncFunction("flush") {
      try await self.client?.flush()
    }

    OnDestroy {
      self.client?.shutdown()
    }
  }
}

enum AnalyticsError: Error {
  case notInitialized
}

The pattern is pretty straightforward: hold a reference to the SDK client, expose its methods through the module definition, and handle cleanup in OnDestroy. The Expo Modules API handles all the type conversion between JavaScript and Swift automatically — which is honestly one of its best features.

Common Pitfalls and How to Avoid Them

After building native modules with all three frameworks, here are the mistakes I see most often (and have made myself more than once):

1. Threading Issues

This is the big one. Native module methods are called from the JavaScript thread by default. If you access UI APIs (like UIDevice on iOS), you must dispatch to the main thread:

// Wrong — may crash or return incorrect values
AsyncFunction("getScreenBrightness") { () -> Double in
  return Double(UIScreen.main.brightness)  // UIKit is not thread-safe
}

// Correct — dispatch to main thread
AsyncFunction("getScreenBrightness") { () -> Double in
  await MainActor.run {
    return Double(UIScreen.main.brightness)
  }
}

2. Memory Leaks with Event Listeners

Always provide a way to unsubscribe from events and clean up observers. It sounds obvious, but it's surprisingly easy to forget. In Expo Modules, use OnStartObserving and OnStopObserving lifecycle hooks. In Nitro, return cleanup functions from your callback registration methods.

3. Forgetting to Handle Errors

Native code can throw exceptions that crash your app if not handled properly. Always wrap potentially failing operations in try-catch blocks on the native side, and reject promises with meaningful error messages:

AsyncFunction("readFile") { (path: String) -> String in
  guard FileManager.default.fileExists(atPath: path) else {
    throw FileError.notFound(path: path)
  }
  return try String(contentsOfFile: path, encoding: .utf8)
}

4. Codegen Mismatches (Turbo Modules)

If your TypeScript spec doesn't match your native implementation exactly, you'll get cryptic build errors that can take ages to debug. Double-check that method names, parameter types, and return types match precisely between the spec and the native code. Trust me on this one.

5. Testing Native Modules

Don't skip testing. For Expo Modules, use the example app that create-expo-module generates — it's there for a reason. For manual testing across both platforms:

# Test on iOS
cd example && npx expo run:ios

# Test on Android
cd example && npx expo run:android

# Run native unit tests
cd example/ios && xcodebuild test \
  -workspace example.xcworkspace \
  -scheme example \
  -destination 'platform=iOS Simulator,name=iPhone 16'

The Future of Native Modules

The native module ecosystem is converging around a few clear trends in 2026:

  • Expo Modules API adoption is accelerating. With Expo SDK 55 dropping legacy architecture support entirely and the expo-brownfield package making it easier to add Expo to existing apps, the barrier to adopting Expo Modules keeps getting lower.
  • Turbo Modules remain the foundation. As part of React Native's core, they're not going anywhere. The working group continues to improve the developer experience, and Codegen support for Swift is on the roadmap.
  • Nitro Modules are pushing performance boundaries. Libraries like VisionCamera prove there's a real need for the kind of extreme performance Nitro provides. Expect more community libraries to adopt Nitro for performance-sensitive features.
  • Hermes V1 will change the equation. Available as an opt-in in Expo SDK 55, the next major version of the Hermes engine promises further performance improvements that will benefit all three frameworks.

The good news? All three approaches are built on JSI, so they can coexist in the same project. You can use Expo Modules for your everyday native integrations and reach for Nitro when you need maximum performance — they're not mutually exclusive.

Wrapping Up

Building native modules in 2026 is dramatically better than it was even two years ago. The New Architecture has given us a solid, performant foundation in JSI. Turbo Modules provide a universal, dependency-free option. The Expo Modules API delivers an outstanding developer experience with Swift and Kotlin. And Nitro Modules push the performance envelope for the most demanding use cases.

For most teams, start with the Expo Modules API. It minimizes boilerplate, supports modern languages, and integrates seamlessly with the Expo ecosystem that most projects already use. When (and only when) you hit a performance ceiling, consider reaching for Nitro Modules. And if you need maximum compatibility across the entire React Native ecosystem, Turbo Modules remain the universal standard.

Whichever approach you choose, the key insight is the same: native modules aren't something to fear. They're a powerful escape hatch that lets you access the full capabilities of iOS and Android while keeping the productivity benefits of React Native. With the tools available today, you can go from idea to working native integration in an afternoon.

About the Author Editorial Team

Our team of expert writers and editors.