Skip to content

How to: wire up an Expo native module

You installed @symbiote-native/sensors (or any other package built on expo-modules-core) and the app crashes at first use — typically Cannot read property 'EventEmitter' of undefined, or native module "X" not found (… bridgeless=object). Neither is a bug in the package: expo-modules-core-based native code is discovered by expo-modules-autolinking, a completely separate mechanism from the react-native.config.cjs/podspec autolinking every other SymbioteNative wrapper (slider, splash-screen) uses — it needs its own one-time native wiring per app.

Do this once per app, not once per package

Section titled “Do this once per app, not once per package”

Every step below wires the app itself, not @symbiote-native/sensors specifically. Once done, any future expo-modules-core-based package (a camera module, a barcode scanner, …) is discovered with zero further native changes — the app-level linker set up in step 6 regenerates the Android registration for whatever is installed, so a new package costs one npm install and nothing else.

1. Add expo-modules-autolinking as a direct devDependency

Section titled “1. Add expo-modules-autolinking as a direct devDependency”
Terminal window
npm install -D expo-modules-autolinking

Without this, the tooling below can resolve expo-sensors/expo-modules-core (they come in transitively through @symbiote-native/sensors) but not the autolinking CLI itself — the Podfile’s require.resolve('expo-modules-autolinking/...') and the Android settings.gradle script below both need it as a direct edge in your app’s own dependency graph, not a transitive one.

Add this near the top of ios/Podfile, before platform :ios, ...:

# Resolve expo-modules-autolinking's Ruby integration directly — normally provided by the
# `expo` package's own scripts/autolinking.rb, which this project never installs.
autolinking_root = File.dirname(`node --print "require.resolve('expo-modules-autolinking/package.json', { paths: ['#{__dir__}'] })"`)
require File.join(autolinking_root, 'scripts/ios/autolinking_manager')
require File.join(autolinking_root, 'scripts/ios/xcode_env_generator')
# expo-modules-autolinking's own Ruby integration hardcodes `require('expo/bin/autolinking')`
# in three call sites, assuming the `expo` package provides that resolution anchor. Point all
# three at expo-modules-autolinking's real entry file instead.
module ::Expo
class AutolinkingManager
private def node_command_args(command_name)
['node', '--no-warnings', '--eval',
'require(\'expo-modules-autolinking/bin/expo-modules-autolinking\')',
'expo-modules-autolinking', command_name, '--platform', 'apple']
.concat(base_command_args())
end
end
module PrecompiledModules
class << self
private def invoke_autolinking(subcommand, platform:)
args = ['node', '--no-warnings', '--eval',
"require('expo-modules-autolinking/bin/expo-modules-autolinking')",
'expo-modules-autolinking', subcommand, '--platform', platform, '--json']
JSON.parse(IO.popen(args, &:read))
rescue => error
raise "Failed to invoke `expo-modules-autolinking #{subcommand}`: #{error}"
end
end
end
module ProjectIntegrator
# Renders the "[Expo] Configure project" Xcode build-phase script — regenerated on every
# `pod install` but executed on every build, so this needs the same fix as above or every
# build fails at that phase, even after a green `pod install`. expo-modules-autolinking
# 57.0.8's own project_integrator.rb calls this with 4 args — it added `target_name`
# (forwarded below as `--target-name`) between the first and the `modules_provider_path`
# arg. A 3-arg override (no `target_name`) raises `ArgumentError: wrong number of arguments
# (given 4, expected 3)` on `pod install` against this version — verify the real call site's
# arity in your installed copy (`grep -n "def self.generate_support_script"
# node_modules/expo-modules-autolinking/scripts/ios/project_integrator.rb`) before assuming
# this exact signature still matches a future release.
def self.generate_support_script(autolinking_manager, target_name, modules_provider_path, entitlement_path)
args = autolinking_manager.base_command_args.map { |a| "\"#{a}\"" }
package_names = autolinking_manager.packages_to_generate.map { |p| "\"#{p.name}\"" }
entitlement_param = entitlement_path.nil? ? '' : "--entitlement \"#{entitlement_path}\""
app_root_param = autolinking_manager.custom_app_root.nil? ? '' : "--app-root \"#{autolinking_manager.custom_app_root}\""
podfile_properties_param = "--podfile-properties-file-path \"#{autolinking_manager.get_podfile_properties_path()}\""
<<~SCRIPT
#!/usr/bin/env bash
set -eo pipefail
NODE_BINARY=$(command -v node)
"$NODE_BINARY" --no-warnings --eval "require('expo-modules-autolinking/bin/expo-modules-autolinking')" \\
expo-modules-autolinking generate-modules-provider #{args.join(' ')} \\
--target "#{modules_provider_path}" --target-name "#{target_name}" \\
#{entitlement_param} #{app_root_param} \\
#{podfile_properties_param} --platform "apple" --packages #{package_names.join(' ')}
SCRIPT
end
end
end
def use_expo_modules!(options = {})
return if @current_target_definition.autolinking_manager.present?
@current_target_definition.autolinking_manager =
::Expo::AutolinkingManager.new(self, @current_target_definition, options).use_expo_modules!
maybe_generate_xcode_env_file!()
generate_or_remove_xcode_env_updates_file!()
end
# expo-sensors pins a higher iOS deployment target than react-native's own minimum. CocoaPods
# checks pod-vs-target compatibility against this line specifically (not just the Xcode
# project's own setting) — skip it and every Expo pod is silently dropped with a
# "requires iOS 16.4 but app targets 15.1"-style warning, easy to miss.
platform :ios, [min_ios_version_supported.to_f, 16.4].max.to_s
prepare_react_native_project!

Then, inside your app target block, call it — excluding the phantom expo package tree that pnpm/npm’s auto-install-peers pulls in purely to satisfy expo-sensors’ unmarked-optional expo peer dependency (left un-excluded, the Expo pod fails to build: ExpoModulesCore/ExpoModulesCore.h file not found):

target 'YourApp' do
config = use_native_modules!
use_expo_modules!(
exclude: [
'expo', 'expo-asset', 'expo-constants', 'expo-file-system',
'expo-font', 'expo-keep-awake', '@expo/dom-webview', '@expo/log-box',
]
)
use_react_native!(:path => config[:reactNativePath], :app_path => "#{Pod::Config.instance.installation_root}/..")
# ...
end

Add two files next to your AppDelegate.swift (Objective-C++, since the hook needs facebook::jsi::Runtime& directly, which Swift can’t express):

SymbioteExpoModulesFactory.h
#import <React_RCTAppDelegate/RCTReactNativeFactory.h>
NS_ASSUME_NONNULL_BEGIN
@interface SymbioteExpoModulesFactory : RCTReactNativeFactory
@end
NS_ASSUME_NONNULL_END
SymbioteExpoModulesFactory.mm
#import "SymbioteExpoModulesFactory.h"
#if __has_include(<ExpoModulesCore/ExpoModulesCore-Swift.h>)
#import <ExpoModulesCore/ExpoModulesCore-Swift.h>
#else
#import "ExpoModulesCore-Swift.h"
#endif
#import <ExpoModulesCore/EXHostWrapper.h>
#import <ExpoModulesCore/EXReactSchedulerDispatch.h>
#import <ReactCommon/RCTHost.h>
#import <react/renderer/runtimescheduler/RuntimeSchedulerBinding.h>
@implementation SymbioteExpoModulesFactory {
EXAppContext *_appContext;
}
- (void)host:(nonnull RCTHost *)host didInitializeRuntime:(facebook::jsi::Runtime &)runtime
{
_appContext = [[EXAppContext alloc] init];
auto binding = facebook::react::RuntimeSchedulerBinding::getBinding(runtime);
auto scheduler = binding ? binding->getRuntimeScheduler() : nullptr;
void *schedulerHandle = expo::createReactSchedulerHandle(scheduler);
[_appContext setRuntime:&runtime
scheduler:schedulerHandle
dispatch:schedulerHandle ? reinterpret_cast<const void *>(&expo::dispatchOnReactScheduler) : nullptr];
[_appContext setHostWrapper:[[EXHostWrapper alloc] initWithHost:host]];
[_appContext registerNativeModules];
}
@end

Then use it in AppDelegate.swift instead of the stock factory:

internal import ExpoModulesCore // matches ExpoModulesProvider.swift's own import level
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
let delegate = ReactNativeDelegate()
let factory = SymbioteExpoModulesFactory(delegate: delegate) // was RCTReactNativeFactory
delegate.dependencyProvider = RCTAppDependencyProvider()
// ... rest unchanged
return ExpoAppDelegateSubscriberManager.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Forward the rest of UIApplicationDelegate's lifecycle to ExpoAppDelegateSubscriberManager
// so any autolinked Expo module that registers a subscriber keeps working — most sensors
// register none, but this keeps future expo-modules-core packages working for free.
func applicationDidBecomeActive(_ application: UIApplication) {
ExpoAppDelegateSubscriberManager.applicationDidBecomeActive(application)
}
// ...applicationWillResignActive / applicationDidEnterBackground / applicationWillEnterForeground
// / applicationWillTerminate / the background-URL-session and open-url callbacks all forward the
// same way.
}

Also needs a SWIFT_OBJC_BRIDGING_HEADER pointing at a bridging header that imports SymbioteExpoModulesFactory.h, since Swift app targets don’t auto-bridge their own Objective-C++ sources the way pod targets do.

// pluginManagement {} must stay the file's first statement — Gradle enforces this at parse
// time, even ahead of a plain `def`.
pluginManagement {
includeBuild("../node_modules/@react-native/gradle-plugin")
def resolvedExpoModulesJson = providers.exec {
workingDir(new File(settingsDir, ".."))
// The real entry file, not the `.bin/` shim (a shell script `node` can't parse as JS).
commandLine("node", "./node_modules/expo-modules-autolinking/bin/expo-modules-autolinking.js",
"resolve", "--platform", "android", "--json")
}.standardOutput.asText.get()
def resolvedExpoModules = new groovy.json.JsonSlurper().parseText(resolvedExpoModulesJson)
// auto-install-peers pulls in the whole phantom `expo` tree too (same unmet-peer cause as
// the iOS Podfile exclude list) — filter down to exactly the packages you actually want.
def wantedExpoModules = resolvedExpoModules.modules
.findAll { ["expo-modules-core", "expo-sensors"].contains(it.packageName) }
wantedExpoModules.collect { it.plugins ?: [] }.flatten()
.each { plugin -> includeBuild(new File(plugin.sourceDir as String)) }
gradle.ext.wantedExpoModules = wantedExpoModules
}
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'YourApp'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
gradle.ext.wantedExpoModules.each { module ->
// `proj`, not `project` — a closure param named `project` would shadow Settings' own
// `project(path)` lookup used below.
module.projects.each { proj ->
include(":${proj.name}")
project(":${proj.name}").projectDir = new File(proj.sourceDir as String)
}
}
buildscript {
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
// expo-modules-core's and expo-sensors' own android/build.gradle both `apply plugin:
// 'expo-module-gradle-plugin'` (old-style) and `import` its ExpoModuleExtension directly —
// old-style `apply plugin:` only resolves an external plugin's classes via this classic
// shared root-buildscript-classpath convention, not via pluginManagement's composite-build
// substitution alone (step 4 handles the composite-build inclusion; this line is separate
// and both are required).
classpath("expo.modules:expo-module-gradle-plugin")
}
}
dependencies {
// SYMBIOTE-EXPO-LINK:BEGIN DEPENDENCIES (generated by symbiote-expo-link - do not edit, regenerated on every install)
implementation project(':expo-local-authentication')
implementation project(':expo-sensors')
// SYMBIOTE-EXPO-LINK:END DEPENDENCIES
// No `expo` aggregator project to depend on transitively — :app depends on each Expo module
// project directly. `expo-modules-core` has no wrapper package of its own, so this line stays
// hand-written, outside the generated region.
implementation project(':expo-modules-core')
}

A wrapper package doesn’t register itself. It ships a passive native-link.json manifest next to its own package.json, and the app-level run collects them. Here’s @symbiote-native/local-auth’s — the simplest, single-module shape:

{
"android": {
"gradleProjectName": "expo-local-authentication",
"modules": [
{
"importPath": "expo.modules.localauthentication.LocalAuthenticationModule",
"className": "LocalAuthenticationModule",
"nativeName": "ExpoLocalAuthentication"
}
]
}
}

nativeName still has to match that module’s own definition() { Name("...") } string exactly — that part hasn’t changed, only who types it. Get it wrong and it’s the same failure as before: a clean compile, then a runtime Cannot find native module '<Name>'.

Authoring a new wrapper package needs nothing beyond that manifest — no postinstall, no dependency on @symbiote-native/expo-modules-link. Consuming an already-published one like @symbiote-native/sensors needs nothing beyond the app-level setup from step 6.

One pass scans node_modules for every installed manifest, sorts them by package name, and regenerates both regions from that list. Regions are rewritten rather than appended to, so uninstalling a package really does drop its entry, and the order doesn’t depend on which package happened to install first. Anything outside the markers is yours and is never touched:

import expo.modules.adapters.react.ModuleRegistryAdapter
import expo.modules.adapters.react.ReactAdapterPackage
import expo.modules.adapters.react.ReactModuleRegistryProvider
import expo.modules.kotlin.ModulesProvider
import expo.modules.kotlin.modules.Module
// SYMBIOTE-EXPO-LINK:BEGIN IMPORTS (generated by symbiote-expo-link - do not edit, regenerated on every install)
import expo.modules.localauthentication.LocalAuthenticationModule
import expo.modules.sensors.modules.AccelerometerModule
import expo.modules.sensors.modules.BarometerModule
// ...one import per module
// SYMBIOTE-EXPO-LINK:END IMPORTS
private class ExpoModulesProvider : ModulesProvider {
override fun getModulesMap(): Map<Class<out Module>, String?> = mapOf(
// SYMBIOTE-EXPO-LINK:BEGIN MODULES-MAP (generated by symbiote-expo-link - do not edit, regenerated on every install)
AccelerometerModule::class.java to "ExponentAccelerometer",
BarometerModule::class.java to "ExpoBarometer",
LocalAuthenticationModule::class.java to "ExpoLocalAuthentication",
// ...one entry per module
// SYMBIOTE-EXPO-LINK:END MODULES-MAP
)
}
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages.apply {
// expo-modules-core has no react-native.config.js of its own, so RN's autolinking
// never finds it — ModuleRegistryAdapter is the standard expo-modules-core/React
// bridge, wired manually like any package autolinking can't reach.
add(
ModuleRegistryAdapter(
ReactModuleRegistryProvider(listOf(ReactAdapterPackage())),
ExpoModulesProvider(),
),
)
},
)
}
// ...
}

Permission handling itself ships inside each sensor’s native module — nothing to reimplement. The platform permission string used to be yours to declare by hand; it now comes from the same manifest as the Android module (see step 7) — a package that needs one declares it under ios.infoPlistKeys, and the app-level run inserts a generic default into Info.plist if that key isn’t already there.

Info.plist is the one file with no generated region. Xcode rewrites it through its own plist serializer whenever target settings change, and that serializer drops XML comments — a lost END marker would mean a second block and a duplicate key, an invalid plist. So iOS stays purely additive, with the presence of <key>NAME</key> as the whole idempotency check. Write your own wording into Info.plist, before or after install, and it stands permanently; the run only prints a one-line notice when your string and the package’s default disagree.

Sensor iOS Info.plist Android
DeviceMotion, Pedometer NSMotionUsageDescription ACTIVITY_RECOGNITION (merged automatically from expo-sensors’ own manifest)
Accelerometer, Gyroscope, Magnetometer, Barometer, LightSensor none required none required

A few packages need an attribute on your app’s own <application> element rather than a Gradle line — secure store is the first, and it needs the two Auto Backup rules that stop Android uploading encrypted entries whose Keystore keys it can’t upload with them. Those come from the same manifest too, under android.manifestApplicationAttributes, and the app-level run adds them to android/app/src/main/AndroidManifest.xml:

<application
android:name=".MainApplication"
android:label="@string/app_name"
android:dataExtractionRules="@xml/secure_store_data_extraction_rules"
android:fullBackupContent="@xml/secure_store_backup_rules">

Like the iOS permission strings and unlike the two generated regions, this is additive-only: an attribute is unique per element by construction, so its presence is the whole idempotency check. An attribute your app already sets is kept as-is and reported in a one-line notice — backup rules decide what leaves the device, so your own value wins.

Don’t take the wiring on faith — each of these is a real, falsifiable check:

Terminal window
# iOS: confirm autolinking resolves the sensor modules
node ./node_modules/expo-modules-autolinking/bin/expo-modules-autolinking.js resolve --platform ios --json
# iOS: pod install succeeds, and the module's source is referenced
cd ios && pod install
grep -c AccelerometerModule Pods/Pods.xcodeproj/project.pbxproj # > 0
# Android: the two Expo projects are really included
cd android && ./gradlew projects # lists Project ':expo-modules-core' and ':expo-sensors'
# Android: a real debug build succeeds
./gradlew :app:assembleDebug

Every step above targets the app, not @symbiote-native/sensors. A future expo-modules-core package needs only step 7’s map extended with its own module classes — everything else (Podfile, settings.gradle, build.gradle) already covers “any Expo module”, not just sensors.