The Glorified Iframe: Embedding a Flutter SDK in an Expo App

Implementing Expo apps can sometimes feel boring. Yet another view, yet another component, yet another Zustand store. Not often do you get a chance to venture out from the safe and comfortable Expo stack.

But sometimes, you get the opportunity to do something you never thought would happen: embed a full native view into an Expo app.

That’s right. Native UI running inside an Expo view, with its own navigation stack, push notifications, deep linking, the whole thing. Seamlessly rendered inside the Expo app, surrounded by the Expo header, footer, and navigation, while every other screen was plain TypeScript. Expo Modules can get you there. The docs just don’t cover what happens when the native view brings its own runtime.

Here’s how we got there, and what broke along the way.

The constraint we didn’t choose

The client’s app was built in Expo: React Native, TypeScript, and the managed workflow. Solid choice for the team, solid choice for the codebase.

Then they picked a third-party proprietary mobile ticketing solution for one specific feature. A solution designed exclusively for native apps. Not React Native. Not Expo. Native.

The SDK shipped as two separate libraries, iOS and Android, each one internally running a full Flutter engine. The entire UI lived inside the SDK. From our side, it was a black box: no access to the engine, no access to the widget tree, no access to the channel.

Making it work meant bridging three languages (TypeScript, Swift/Kotlin, Dart), three build systems (Metro, Xcode, Gradle), and two message-passing boundaries: one from RN to native, one from native into Flutter.

So where do you even start? You’ve got the SDK libraries. Now what?

Step one: get it into the build

Ejecting the app was never an option. Ejecting also wouldn’t have solved the actual problem: you’d still have a Flutter-backed SDK to deal with, just with more build configuration to own.

Expo has a proper answer for this: config plugins. You stay in the managed workflow, and instead of manually editing android/ and ios/ directories, you write config plugins that patch the build configuration during the prebuild step. Finicky, because being one line off in a config plugin is a special kind of frustrating, but once it’s done, expo prebuild it handles everything. No manual Xcode project editing on every new machine, no Gradle mysteries that only reproduce in CI.

But once the app compiles and starts using the brand-new third-party dependency, you can declare victory… and move to the next battle: actually using the SDK and getting the two worlds to talk to each other.

Writing the module

The native bridge has rules. Everything you pass across it has to be a JSON-serialisable type. Every call is asynchronous. You can’t pass functions from Expo into native and call them back later: the bridge doesn’t work that way.

What you can do is fire events from the native side back to Expo and listen for them in TypeScript. That direction works cleanly.

As it turned out, I needed both: function calls from Expo to native to initialise and interact with the SDK, and events from native back to Expo to react to whatever the SDK was doing internally. Between those two directions, the full communication surface was covered.

One thing that genuinely surprised me: the Expo module code for iOS (Swift) and Android (Kotlin) was almost identical. The DSL Expo provides maps so closely between the two platforms that it was close to a copy-paste. The only real difference was the syntax for anonymous functions. Credit to the Expo team for thinking that through. Writing native modules usually means two completely separate mental contexts; this felt like one.

The glorified iframe

Sometimes when working on a piece of code I get that feeling: “I’ve seen this somewhere before.” While implementing the ExpoView on iOS, it hit me.

We were rendering a foreign view inside another view. Giving it a window, letting it draw its own UI, handling its own scrolling, managing its own navigation stack. We had no control over what happened inside it.

That’s an iframe. Just without the HTML.

Same concept, different medium. And like an iframe, it came with iframe problems. On Android, React Native’s layering model is essentially flat and ExpoView always renders on top. That meant some of our UI elements were hiding behind the SDK view when they shouldn’t have been. There’s no clean declarative fix for this; React Native doesn’t integrate native z-ordering without some persuasion on the Android side. We persuaded it.

After some persuasion, some async rendering handling, and careful layering of components, the glorified iframe was running on both platforms.

The teardown that wasn’t

So the glorified iframe was working. Both platforms. Data flowing in, events flowing out, z-ordering persuaded. Time to ship, right?

Not quite. The initial integration took about a week. Then two more weeks of chasing edge cases: z-index fights, state sync, deep link handling, push notifications arriving before the view was ready. The kind of stuff you expect. You fix it, you move on.

We tested the whole integration internally. Everything worked. We released it to TestFlight for the client to review, almost at the finish line.

Then QA ran one more round of manual tests.

They came back with a single line: the app crashes when the user closes the ticketing screen.

That crash, iOS only, intermittent, invisible in development, took three months and forty-eight builds to track down.

That battle deserves its own post.

Leave a Reply

Your email address will not be published. Required fields are marked *