Building Custom Expo Modules with the Expo Modules API | Mironsoft
AI generated
RN
native
React Native / Expo Modules API
Building Custom Expo Modules
with the Expo Modules API

The Expo Modules API replaces hand-written React Native bridging with a declarative Swift and Kotlin DSL for building custom native modules without manual bridging boilerplate. This article covers the architecture, a full hands-on example, and the limits of testing inside Expo Go.

12 min read Swift Kotlin Autolinking

1. Why the Expo Modules API replaces classic bridging

Classic React Native bridging via NativeModules required hand-written Objective-C headers, manual promise handling, and a strict separation between the JavaScript thread and native thread across the bridge. Building a custom native module meant wrestling with RCT_EXPORT_METHOD macros, manual type conversion, and a dedicated podspec file before writing a single line of actual business logic. The Expo Modules API flips that ratio: it provides a declarative Swift and Kotlin DSL that derives method signatures, async functions, and events from a single module class, without developers wiring the bridge mechanics themselves.

The shift is not just a convenience gain but a structural simplification: type conversion between JavaScript and native code, memory management of callback references, and registration in the autolinking system are all handled by the framework. For teams that regularly build small native extensions, say access to a vendor SDK or a sensor API, this significantly lowers the barrier to entry, because deep knowledge of the old bridge architecture is no longer a prerequisite.

2. Architecture: ModuleDefinition as a declarative description

At the center of every Expo module sits a class that inherits from Module and overrides a definition property. Inside this ModuleDefinition, you declaratively describe which functions, constants, and events the module exposes. The module name, its functions, and its events are defined through a builder-like DSL block that the expo-modules-core library evaluates at runtime and automatically wires up to the JavaScript layer.

This architecture works nearly identically on iOS through Swift and on Android through Kotlin, which drastically reduces the mental context switch between platforms. Once you understand the structure on iOS, you recognize the same concepts on Android under almost identical names, such as Function, AsyncFunction, and Events. That noticeably lowers maintenance cost, because a module is conceptually designed in one place and then implemented in parallel on both platforms, instead of maintaining two completely different native codebases.

3. Project setup with create-expo-module

The official entry point is the CLI npx create-expo-module, which generates a standalone module repository with iOS, Android, and TypeScript folders, including an example project ready for immediate testing. The generated module automatically follows the naming convention and folder structure that Expo's autolinking system expects, so it can be wired into an existing app without any extra manual configuration once it is referenced as a local dependency.

An important point for teams with existing bare workflow projects: the Expo Modules API is not tied to Expo Go or the managed workflow. Once the expo-modules-core package is added as a dependency, autolinking works in a classic React Native project without any Expo configuration too, as long as npx expo install expo-modules-core has been run and native configuration has been synced once. That makes migrating away from pure legacy bridging incremental, instead of forcing a full switch to the managed workflow.

4. Practical example: a flashlight module from scratch

A small module that controls the camera flashlight independently of the camera preview works well as a running example, a use case with no official Expo API. On the TypeScript side, a thin wrapper file that loads the native module via requireNativeModule and forwards it with full type safety is enough. The actual business logic, meaning direct access to AVCaptureDevice on iOS and CameraManager on Android, stays fully encapsulated inside the respective native implementation.

This setup illustrates the core advantage of the Expo Modules API particularly well: app-side usage looks no different from a built-in Expo package, even though it is entirely custom native code. Developers who later use the module do not need to know it is a custom module, nor deal with any bridging details. They import the function, call it, and get back a typed promise, exactly like with any other Expo SDK function.


// modules/expo-torch/index.ts
import { requireNativeModule } from "expo-modules-core";

type ExpoTorchModule = {
  setTorchEnabled(enabled: boolean): Promise<void>;
  isTorchAvailable(): boolean;
};

const ExpoTorch = requireNativeModule<ExpoTorchModule>("ExpoTorch");

export async function toggleFlashlight(enabled: boolean): Promise<void> {
  await ExpoTorch.setTorchEnabled(enabled);
}

5. Swift side: Functions, AsyncFunctions, and Constants

On iOS, the Swift ModuleDefinition describes every exported function through the Function or AsyncFunction builders. Synchronously evaluable operations, such as reading a current state, use Function, while anything involving I/O or delay runs through AsyncFunction and automatically arrives as a JavaScript promise. Errors are simply thrown using Swift's native throws, without manually wiring reject callbacks. The framework automatically translates thrown errors into a rejected promise with a structured error message.

Constants, such as platform feature flags or hardware capabilities, are exported through the Constants block, which is evaluated once synchronously when the module loads and becomes available in JavaScript as a plain object. For recurring events like status changes, you additionally declare events through Events and send them with sendEvent, consumed on the JavaScript side through an addListener call on the module, entirely without manual NativeEventEmitter configuration.


// ios/ExpoTorchModule.swift
import ExpoModulesCore
import AVFoundation

public class ExpoTorchModule: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoTorch")

    Function("isTorchAvailable") { () -> Bool in
      guard let device = AVCaptureDevice.default(for: .video) else {
        return false
      }
      return device.hasTorch
    }

    AsyncFunction("setTorchEnabled") { (enabled: Bool) in
      guard let device = AVCaptureDevice.default(for: .video), device.hasTorch else {
        throw TorchNotAvailableException()
      }
      try device.lockForConfiguration()
      device.torchMode = enabled ? .on : .off
      device.unlockForConfiguration()
    }
  }
}

internal final class TorchNotAvailableException: Exception {
  override var reason: String {
    "No torch-capable camera device found on this hardware"
  }
}

6. Kotlin side: the identical module for Android

On Android, the Kotlin implementation follows the same ModuleDefinition DSL with nearly identical syntax, which makes switching between platforms unusually smooth compared to classic bridging, where iOS and Android implementations were often structured completely differently. Access to the Android flashlight goes through CameraManager and its setTorchMode method, wrapped in an AsyncFunction that internally relies on a coroutine context to avoid blocking the main thread.

One detail that is often overlooked: Android requires a valid camera ID for flashlight access, which has to be determined in advance through cameraManager.cameraIdList and filtered for the rear camera, since not every device has a flashlight on the same camera ID. Errors, such as when no matching camera ID is found, are thrown as a regular Kotlin exception, which the framework translates into a rejected JavaScript promise exactly like on Swift, keeping error handling on the app side identical across platforms.


// android/src/main/java/expo/modules/torch/ExpoTorchModule.kt
package expo.modules.torch

import android.hardware.camera2.CameraManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoTorchModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("ExpoTorch")

    AsyncFunction("setTorchEnabled") { enabled: Boolean ->
      val manager = appContext.reactContext
        ?.getSystemService(android.content.Context.CAMERA_SERVICE) as CameraManager
      val cameraId = manager.cameraIdList.firstOrNull { id ->
        manager.getCameraCharacteristics(id)
          .get(android.hardware.camera2.CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
      } ?: throw TorchNotAvailableException()
      manager.setTorchMode(cameraId, enabled)
    }
  }
}

7. The TypeScript layer: type-safe usage in the app

The TypeScript side of an Expo module typically consists of two files: an interface describing the module API, and a wrapper file that calls requireNativeModule with the exact native module name set in Swift and Kotlin through Name. This separation lets you add extra validation, default values, or platform-specific behavior with Platform.OS inside the wrapper before the call reaches the native layer.

Because requireNativeModule is generically typed in TypeScript, every caller across the rest of the app code gets full autocomplete and compile-time parameter checking, with no extra type declaration files to maintain. That eliminates an entire class of runtime bugs that were typical of classic bridging, such as mistyped method names or misordered parameters in a NativeModules.MyModule.someMethod() call with no type checking at all.

8. Testing and debugging: the limits of Expo Go

A point teams commonly underestimate when switching: custom native modules fundamentally do not work inside the standard Expo Go app, because Expo Go is a precompiled binary with a fixed set of native modules baked in. Developing and testing a custom module strictly requires a development build via npx expo run:ios or npx expo run:android, or EAS Build, that actually compiles the module in.

For day-to-day iteration, a locally referenced module inside the app's modules/ folder pays off, combined with the example project shipped by create-expo-module, which already includes a working test environment with fast refresh for the TypeScript side and native rebuilds for Swift and Kotlin changes. Native code changes still require a full rebuild, whereas JavaScript-side changes to the wrapper benefit normally from Metro fast refresh, which noticeably speeds up the overall development cycle compared to pure bridging debugging through Xcode and Android Studio logs.

9. Publishing, versioning, and maintenance

Once a module is stable, it can be published as a standalone npm package, either privately within an organization or publicly if the use case is generic enough. The folder structure generated by create-expo-module is already publish-ready, including package.json, a podspec, and an Android build.gradle, so no additional tooling like react-native-builder-bob is needed to produce a cleanly consumable package.

For versioning, strict semantic versioning tied to the supported Expo SDK version is recommended, since the internal expo-modules-core API occasionally changes between major SDK versions. A module built against SDK 51 does not automatically work against SDK 54, so a CI pipeline that tests the module against several supported SDK versions before a new release goes out pays off.

Aspect Classic Bridging Expo Modules API
iOS language Objective-C with RCT_EXPORT_METHOD Swift with ModuleDefinition DSL
Android language Java with ReactContextBaseJavaModule Kotlin with ModuleDefinition DSL
Type conversion Manual via bridge types Automatic via the DSL
Autolinking Manual podspec and Gradle upkeep Automatic via expo-modules-autolinking
Expo Go compatibility Not applicable, pure bare setup Only usable with a development build

Mironsoft

React Native app development and Magento integration

A mobile app for the Magento shop that actually runs smoothly?

We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.

App Concept

Plan the architecture and feature scope of a Magento-connected app together.

Magento API Integration

Cleanly connect product catalog, cart, and checkout to the shop API.

Store Publishing

Guide the App Store and Google Play release process without pitfalls.

10. Summary

Expo Modules API: The Essentials at a Glance

Who it's for

Teams building their own native SDK integrations without classic bridging.

Requirement

A development build instead of Expo Go, since custom modules do not work there.

Core benefit

Identical ModuleDefinition DSL on Swift and Kotlin, automatic type conversion.

Effort

Noticeably lower than classic bridging, but native know-how is still required.

11. FAQ: Expo Modules API: The Essentials at a Glance

1Does the Expo Modules API work in a classic React Native project without Expo?
Yes. Once the expo-modules-core package is installed as a dependency and synced natively once, autolinking works in the bare workflow without a full switch to Expo.
2Can I test an Expo module inside Expo Go?
No. Custom native modules strictly require a development build via expo run or EAS Build, because Expo Go only ships a fixed, precompiled set of native modules.
3Do I need to know both Swift and Kotlin?
For a complete cross-platform module, yes, but the identical ModuleDefinition DSL on both platforms significantly lowers the barrier compared to classic bridging with completely different concepts.
4What is the difference between Function and AsyncFunction?
Function suits synchronously evaluable operations with no delay, AsyncFunction handles anything involving I/O or delay and automatically returns a JavaScript promise.
5How are errors from native code handled?
Errors thrown in Swift or Kotlin are automatically translated by the framework into a rejected JavaScript promise with a structured error message, with no manual reject callbacks needed.
6Can I send events from the native module to JavaScript?
Yes, through the Events block in the ModuleDefinition and a call to sendEvent, consumed on the JavaScript side through an addListener call on the module.
7How do I publish a custom module for use across multiple projects?
The structure generated by create-expo-module is already publish-ready and can be released as a private or public npm package with semantic versioning.
8Can a module break after an Expo SDK upgrade?
Yes, since the internal expo-modules-core API can change between major SDK versions, which is why a CI pipeline testing against several supported SDK versions pays off.
9Does the Expo Modules API fully replace TurboModules?
It builds on the same JSI foundations but offers a noticeably higher abstraction level and is the more practical choice for most use cases compared to hand-written TurboModules.
10What does an Android device need for flashlight access?
A valid camera ID with flash support, determined in advance through cameraManager.cameraIdList and filtered, since not every camera ID has a flashlight attached.