
Learn how to write custom native modules in Expo using the new Native Modules API for seamless iOS and Android integration.
When building mobile applications with React Native and Expo, you can usually rely on the rich ecosystem of existing libraries. Whether you need camera access, secure storage, or push notifications, there is almost always an Expo module ready to install. But every now and then, you run into a very specific hardware integration, a legacy SDK, or a complex performance requirement that forces you to drop down to the native layer.
In the past, writing custom native modules meant ejecting from Expo or dealing with complex bridging code that often broke during upgrades. Today, thanks to the modern Expo Modules API, writing Swift and Kotlin code and exposing it safely to JavaScript is cleaner, faster, and fully compatible with Expo EAS Build. Let me walk you through how I approach building custom native modules in my client projects.
Before writing any native code, it is worth asking if you really need to. Writing native modules means maintaining Swift code for iOS and Kotlin code for Android, which adds overhead to your codebase.
Common valid use cases include:
If your requirement falls into one of these buckets, writing a custom native module using the Expo Modules API is the cleanest path forward.
Expo encourages developing local modules directly inside your project workspace before publishing them to npm. You can create a local module folder within your project directory, structure it correctly, and configure your app.json to link it.
A typical local module folder looks like this:
modules/
โโโ my-native-scanner/
โโโ android/
โโโ ios/
โโโ index.ts
โโโ expo-module.config.json
The expo-module.config.json tells the Expo autolinking system how to discover your native files for both iOS and Android during prebuilding.
Tagged with
Leave a comment
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["MyNativeScannerModule"]
},
"android": {
"modules": ["com.mynativescanner.MyNativeScannerModule"]
}
}
On iOS, Expo modules are written in Swift using the ExpoModulesCore framework. This framework provides clean macros that make exporting functions, constants, and event listeners straightforward.
Here is a simple example of an iOS module that retrieves device battery status or performs a native task:
import ExpoModulesCore
public class MyNativeScannerModule: Module {
public func definition() -> ModuleDefinition {
// Sets the name of the module in JavaScript
Name("MyNativeScanner")
// Defines synchronous functions callable from JS
Function("getSystemInfo") { () -> String in
return "iOS Native Bridge Active"
}
// Defines asynchronous functions using promises
AsyncFunction("scanCode") { (options: [String: Any], promise: Promise) in
DispatchQueue.main.async {
// Perform native scanning logic here
let success = true
if success {
promise.resolve("SCAN_RESULT_12345")
} else {
promise.reject("SCAN_ERROR", "Failed to scan code", nil)
}
}
}
}
}
On Android, you implement the corresponding module in Kotlin using the same architectural patterns. Kotlin makes interacting with Android system APIs concise and readable.
Here is how the Android counterpart looks:
package com.mynativescanner
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
public class MyNativeScannerModule : Module {
override fun definition() = ModuleDefinition {
Name("MyNativeScanner")
Function("getSystemInfo") {
return@Function "Android Native Bridge Active"
}
AsyncFunction("scanCode") { options: Map<String, Any>, promise: expo.modules.kotlin.promise.Promise ->
// Perform native Android scanning logic here
val success = true
if (success) {
promise.resolve("SCAN_RESULT_12345")
} else {
promise.reject("SCAN_ERROR", "Failed to scan code", null)
}
}
}
}
Once your Swift and Kotlin code are in place, you expose them through your TypeScript entry file using requireNativeModule provided by expo-modules-core.
import { requireNativeModule } from 'expo-modules-core';
// Import the native module by the name defined in Swift/Kotlin
const MyNativeScanner = requireNativeModule('MyNativeScanner');
export function getSystemInfo(): string {
return MyNativeScanner.getSystemInfo();
}
export async function scanCode(options: { timeout: number }): Promise<string> {
return await MyNativeScanner.scanCode(options);
}
Now, you can import this function anywhere in your React Native app just like any standard TypeScript module.
Because custom native modules contain compiled Swift and Kotlin code, standard Expo Go will not be able to execute your native changes directly. You will need to use development builds (npx expo run:ios and npx expo run:release or npx expo run:android) to test your code locally.
When you are ready for production, EAS Build handles compiling your custom Swift and Kotlin code automatically in the cloud, exactly as it does for any other native dependency.
Writing custom native modules used to be a major headache in cross-platform mobile development. Today, the Expo Modules API makes it remarkably clean and maintainable. By keeping your native code encapsulated and well-tested, you can extend your React Native apps to do almost anything a fully native app can do.
Need help building a custom mobile app or integrating complex native SDKs into your Expo project? Get in touch and let's talk about your requirements.