Native Modules
A native module is a TypeScript/JavaScript surface backed by Kotlin/Swift code. Reach for one when the JS bridge cannot reach the platform feature you need (StoreKit, Bluetooth, native camera filters, accelerated maths). Expo dev clients + Turbo Modules (the new architecture) make this less painful than the old bridge era.
A TurboModule, exposed to JS, on iOS and Android
EXAMPLE
// ===== 1) Define the JS interface (codegen target) =====
// src/specs/NativeBiometrics.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
isAvailable(): Promise<boolean>;
authenticate(reason: string): Promise<{ ok: boolean; error?: string }>;
getConstants(): { biometryType: 'TouchID' | 'FaceID' | 'Fingerprint' | 'None' };
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeBiometrics');
// ===== 2) Register in package.json so codegen runs =====
// {
// "codegenConfig": {
// "name": "NativeBiometricsSpec",
// "type": "modules",
// "jsSrcsDir": "src/specs"
// }
// }
// ===== 3) Android (Kotlin) implementation =====
// android/src/main/java/com/shop/NativeBiometricsModule.kt
package com.shop
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import com.facebook.react.bridge.*
import com.facebook.react.module.annotations.ReactModule
import java.util.concurrent.Executors
@ReactModule(name = NativeBiometricsModule.NAME)
class NativeBiometricsModule(reactContext: ReactApplicationContext) :
NativeBiometricsSpec(reactContext) {
companion object { const val NAME = "NativeBiometrics" }
override fun getName() = NAME
override fun getTypedExportedConstants(): Map<String, Any> {
val bm = BiometricManager.from(reactApplicationContext)
val type = if (bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
== BiometricManager.BIOMETRIC_SUCCESS) "Fingerprint" else "None"
return mapOf("biometryType" to type)
}
override fun isAvailable(promise: Promise) {
val bm = BiometricManager.from(reactApplicationContext)
promise.resolve(
bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
== BiometricManager.BIOMETRIC_SUCCESS
)
}
override fun authenticate(reason: String, promise: Promise) {
val activity = currentActivity as? FragmentActivity
?: return promise.reject("NO_ACTIVITY", "missing FragmentActivity")
val exec = Executors.newSingleThreadExecutor()
val prompt = BiometricPrompt(activity, exec,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) {
promise.resolve(Arguments.createMap().apply { putBoolean("ok", true) })
}
override fun onAuthenticationError(code: Int, msg: CharSequence) {
promise.resolve(Arguments.createMap().apply {
putBoolean("ok", false); putString("error", "$code: $msg")
})
}
})
activity.runOnUiThread {
prompt.authenticate(BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build())
}
}
}
// ===== 4) iOS (Swift) implementation =====
// ios/NativeBiometrics.swift
import LocalAuthentication
import React
@objc(NativeBiometrics)
class NativeBiometrics: NSObject, NativeBiometricsSpec {
func getConstants() -> [String: Any] {
let ctx = LAContext()
var error: NSError?
let supported = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
let type: String = supported ? (ctx.biometryType == .faceID ? "FaceID" : "TouchID") : "None"
return ["biometryType": type]
}
@objc func isAvailable(_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
let ctx = LAContext()
var error: NSError?
let ok = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
resolve(ok)
}
@objc func authenticate(_ reason: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
let ctx = LAContext()
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { ok, err in
DispatchQueue.main.async {
resolve(["ok": ok, "error": err?.localizedDescription as Any])
}
}
}
}
// ===== 5) Use from JS =====
// import Biometrics from '../specs/NativeBiometrics';
// const ok = await Biometrics.isAvailable();
// const r = await Biometrics.authenticate('Approve transfer of $50');
// ===== 6) Expo path =====
// Expo Modules API hides much of this — same shape:
// npx create-expo-module my-biometrics
// src/MyBiometricsModule.web.ts / .android.kt / .ios.swift
// Then 'expo install ../my-biometrics' from your app.
// ===== 7) Gotchas =====
// - New Architecture (TurboModules): codegen MUST run; check ios/build logs.
// - iOS Info.plist: NSFaceIDUsageDescription required for FaceID prompt.
// - Android: depend on androidx.biometric; activity must be FragmentActivity.
// - Threading: bridge calls happen off the UI thread; switch to UI thread for prompts.
Why it matters
Reach for Expo Modules whenever you can. They give you the same TurboModule capability with a single source-of-truth API surface, a generator for the boilerplate, and a clean local dev workflow. The bare-RN native module path is fine but historically the source of the most "works on iOS, broken on Android" reports.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Drop down to Swift/Kotlin to access platform APIs not in JS.
// Modern path: Expo Modules API.
import { requireNativeModule } from 'expo-modules-core';
const MyMod = requireNativeModule('MyMod');
MyMod.doNativeThing();
Try it Yourself »
Discussion
Loading…