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.
Table of Contents
- 1. Why the Expo Modules API replaces classic bridging
- 2. Architecture: ModuleDefinition as a declarative description
- 3. Project setup with create-expo-module
- 4. Practical example: a flashlight module from scratch
- 5. Swift side: Functions, AsyncFunctions, and Constants
- 6. Kotlin side: the identical module for Android
- 7. The TypeScript layer: type-safe usage in the app
- 8. Testing and debugging: the limits of Expo Go
- 9. Publishing, versioning, and maintenance
- 10. Summary
- 11. FAQ
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.