For the longest time, "go native" in a React Native app meant one of two unpleasant choices: eject and own the iOS/Android projects forever, or write a fragile bridge module and pray the next React Native upgrade didn't break it. Expo modules quietly fixed this and I didn't notice for a year.
This post is about what changed for me when I started using them in Where, and why I'd reach for them earlier next time.
The problem I was actually solving
In Where, push notifications need to ring through on a locked phone, even when the app is killed. That means custom notification channels on Android, critical alerts on iOS, and tight control over what happens when the user taps an action. None of that is a "JS feature." It's all native plumbing.
I started with the usual JS-side libraries. They got me 80% of the way there. The last 20% — the part the user actually notices in an emergency — was exactly the part that needed native code.
What an Expo module actually is
An Expo module is just a small Swift/Kotlin package, with a thin TypeScript
shim, that lives inside your project like any other folder. No ejecting,
no Pods, no Gradle anxiety. You write it once, you import it from JS, and
the build process picks it up.
import { NativeModule, requireNativeModule } from "expo";
class WhereAlerts extends NativeModule {
triggerCriticalAlert(payload: { title: string; body: string }): void {
// implemented in Swift / Kotlin
}
}
export default requireNativeModule<WhereAlerts>("WhereAlerts");
That's the entire JS surface. The native side is a normal Swift class with
@Function-decorated methods. It feels like writing a small library, not
forking a framework.
What clicked for me
A few things I didn't expect:
- The dev loop is fast. Native code rebuilds in seconds, not minutes, because Expo's build system caches per module.
- Types are real. The TS shim gives you proper types into native land —
no more
(NativeModules as any).Whatever. - You can stop writing bridges. Expo modules use the new architecture (Fabric/TurboModules) under the hood, so you get the perf without the ceremony.
What it cost me
It's not free. Two things bit me:
- You still need to know the platform. A blessing isn't a free pass. iOS critical alerts require a special entitlement and an Apple-approved reason. No JS abstraction will save you from filling out that form.
- CI gets slightly heavier. EAS Build is fine, but if you were on Expo Go, you've now graduated to a development build. That's the right move, but it's a step you have to take consciously.
Would I do it again
In a heartbeat. The next time I have a feature where a JS-only solution feels 75% right, I'll just write the module on day one instead of spending two weeks pretending the wrapper is enough.
If you're on the fence, the smallest useful first module is something like "return the device's locale list correctly" — small, scoped, and you'll understand the toolchain by the time you're done.
