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”npm install -D expo-modules-autolinkingWithout 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.
2. iOS — Podfile
Section titled “2. iOS — Podfile”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 endend
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_sprepare_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}/..") # ...end3. iOS — install the runtime hook
Section titled “3. iOS — install the runtime hook”Add two files next to your AppDelegate.swift (Objective-C++, since the hook needs
facebook::jsi::Runtime& directly, which Swift can’t express):
#import <React_RCTAppDelegate/RCTReactNativeFactory.h>
NS_ASSUME_NONNULL_BEGIN@interface SymbioteExpoModulesFactory : RCTReactNativeFactory@endNS_ASSUME_NONNULL_END#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];}
@endThen use it in AppDelegate.swift instead of the stock factory:
internal import ExpoModulesCore // matches ExpoModulesProvider.swift's own import level
@mainclass 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.
4. Android — settings.gradle
Section titled “4. Android — settings.gradle”// 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) }}5. Android — root build.gradle
Section titled “5. Android — root build.gradle”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") }}6. Android — app/build.gradle
Section titled “6. Android — app/build.gradle”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')}7. Android — MainApplication.kt
Section titled “7. Android — MainApplication.kt”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.ModuleRegistryAdapterimport expo.modules.adapters.react.ReactAdapterPackageimport expo.modules.adapters.react.ReactModuleRegistryProviderimport expo.modules.kotlin.ModulesProviderimport 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.LocalAuthenticationModuleimport expo.modules.sensors.modules.AccelerometerModuleimport 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(), ), ) }, ) } // ...}8. Permission strings
Section titled “8. Permission strings”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 |
9. Android <application> attributes
Section titled “9. Android <application> attributes”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.
Verify it actually linked
Section titled “Verify it actually linked”Don’t take the wiring on faith — each of these is a real, falsifiable check:
# iOS: confirm autolinking resolves the sensor modulesnode ./node_modules/expo-modules-autolinking/bin/expo-modules-autolinking.js resolve --platform ios --json
# iOS: pod install succeeds, and the module's source is referencedcd ios && pod installgrep -c AccelerometerModule Pods/Pods.xcodeproj/project.pbxproj # > 0
# Android: the two Expo projects are really includedcd android && ./gradlew projects # lists Project ':expo-modules-core' and ':expo-sensors'
# Android: a real debug build succeeds./gradlew :app:assembleDebugWhat this unlocks for free
Section titled “What this unlocks for free”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.