Back to writing
Blog · 06 / 18APR 22, 2026BUILD9 min read

Building Native Expo Modules in Anger

The Expo Modules API replaced 80% of the reasons you used to eject. Swift + Kotlin DSLs, type-safe TS bindings, no codegen YAML.

Building Native Expo Modules in Anger

For most of React Native's life, "I need a native module" meant pain. You'd either eject from Expo, fight with react-native link, write .podspec files by hand, or maintain twin Java/Objective-C bindings that drifted from each other. The whole reason "ejecting" became a verb was that the alternative was unmaintainable.

The Expo Modules API quietly killed that pain in 2023, matured through 2024-2025, and by 2026 is the only way I write native code for React Native. The TurboModule revolution under the hood is real. The DX on top of it is what actually changed my life.

What The API Actually Is

The Expo Modules API is a Swift and Kotlin DSL for declaring native modules and views. You describe what your module exposes—functions, properties, events, view components—and the API generates the JSI bindings, the TypeScript types, and the autolinking glue.

A complete native module that does something real is now genuinely small. Here's a sketch of one that exposes the device's pasteboard with change notifications:

// ios/PasteboardModule.swift
import ExpoModulesCore
import UIKit
 
public class PasteboardModule: Module {
  public func definition() -> ModuleDefinition {
    Name("Pasteboard")
 
    Function("getString") { () -> String? in
      UIPasteboard.general.string
    }
 
    Function("setString") { (value: String) in
      UIPasteboard.general.string = value
    }
 
    Events("onChange")
 
    OnStartObserving {
      NotificationCenter.default.addObserver(
        self,
        selector: #selector(self.handleChange),
        name: UIPasteboard.changedNotification,
        object: nil
      )
    }
 
    OnStopObserving {
      NotificationCenter.default.removeObserver(self)
    }
  }
 
  @objc private func handleChange() {
    sendEvent("onChange", ["value": UIPasteboard.general.string ?? ""])
  }
}

The Kotlin equivalent is similar in spirit. The TypeScript side is auto-typed:

import { requireNativeModule } from "expo-modules-core";
 
const Pasteboard = requireNativeModule<{
  getString(): string | null;
  setString(value: string): void;
  addListener(name: "onChange", cb: (e: { value: string }) => void): Subscription;
}>("Pasteboard");

That's the whole module. No codegen YAML. No .podspec. No Android.bp. The autolinker finds it from the package's expo-module.config.json, registers it on both platforms, and exposes a typed JS interface.

What Changed vs. The Old World

If you wrote native modules before 2023, the contrast is dramatic:

  • One language per platform, with a real DSL. Swift on iOS, Kotlin on Android. No Objective-C unless you want it. No Java. No JNI handwritten.
  • Concurrent execution by default. Function blocks can be async on Swift and suspend on Kotlin. The result lands back on the JS thread without you wiring callbacks.
  • View modules that aren't a science project. Custom native views used to require fiddling with RCTViewManager. Now View(MyViewClass.self) { Prop("color") { ... } } and you're done.
  • Type safety end-to-end. Function signatures are introspected. The TS types are generated. If you change the Swift signature, the JS side fails to typecheck.
  • No more eject. Expo's "config plugins" let you patch the iOS and Android projects at build time without checking in the native folders. Most modules don't even need a config plugin.

The architectural choice that makes all this work is that Expo Modules sits on top of JSI and TurboModules. The DSL produces TurboModule-compatible bindings. You get the bridgeless performance for free.

The Patterns That Work

After writing a dozen of these in production, the patterns that consistently pay off:

Keep the surface area small. A native module with 4 functions is easier to maintain than one with 14. If you're tempted to add convenience methods, write them in TS instead.

Push the platform-specific logic deep. The DSL makes it tempting to handle every iOS edge case in Swift. Don't. Expose the minimum primitive, then implement the convenience layer in TypeScript where the iteration loop is faster.

Use Events for everything stateful. Don't poll. Don't store state in the module. Expose an event stream and let JS hold the state. This makes the module testable and avoids the worst category of native bugs.

Lean on view modules for anything visual. Wrapping a native UI component in a view module is dramatically easier than reinventing it in JS. MapKit, native cameras, AR scenes, skia surfaces—all of these are now small modules instead of months of work.

Ship the config plugin if you need permissions. A 30-line config plugin that adds NSCameraUsageDescription is the difference between a one-line install and a wiki page of instructions for your users.

Where The API Still Hurts

Honest about the rough edges:

  • Build time. The first build of a project with custom native modules is still 8-12 minutes on a fast machine. EAS Build helps with caching, but the local feedback loop on native iteration is real.
  • Debugging native crashes. When you get a SIGABRT in Swift, the React Native error overlay tells you nothing. You're back to Xcode and Android Studio. The Expo team has improved this with better source maps but it's still rough.
  • Library compatibility. Some older native libraries that haven't been migrated to the Expo Modules API are still painful to wrap. The community has a "expo-modules-bridge" package that helps but it's not magic.
  • Documentation gaps. The DSL is powerful enough that the surface area is genuinely large. Edge cases like async generators, native callbacks, and concurrent event delivery are under-documented.

None of these are dealbreakers. They're the difference between "two days to ship" and "one day to ship."

Why It Matters

The reason "eject" was a verb wasn't that ejecting was hard. It was that ejecting was required the moment your app needed anything Expo didn't ship. Native modules were the wall.

The Expo Modules API tore the wall down. You can stay in the managed flow, write Swift and Kotlin where it makes sense, and ship to the App Store and Play Store with EAS Build. The "we had to eject" stories from 2022 are stories the next generation of devs won't have.

Native modules used to be the moat. Now they're a Tuesday afternoon.

That changes what's worth building.