QUEST LOG · June 2, 2026· UPDATED October 29, 2026
How to Cross the Bridge Gracefully: Leaving SwiftUI for Expo
I always assumed cross-platform meant laggy and slow. Then I actually sat down with Expo and the tools in its ecosystem, and it turns out that's not true.
When I started building Solid(ar)ity (an identity page and business card app where you hold your own keys), the first version was written in SwiftUI. I started in September 2025: native, beautiful, Apple's own child. At the time, that felt like the obviously correct answer.
Then at the end of May 2026 I spun up an Expo project, and by the end of June I'd thrown out the entire SwiftUI app, the Xcode project, and all the Swift config. One month, and the whole thing was across the bridge (though a lot of the credit probably goes to Claude).
This post is a record of why I crossed, how I crossed, and what I found on the other side.
# Why cross the bridge
The most direct reason was Android. With something like business cards and identity, you have no control over what phone the other person uses. Shipping iOS-only means half the people can't scan your card. Sticking with SwiftUI meant rewriting the whole thing from scratch for Android, then keeping two codebases and two copies of the crypto logic in sync. Just thinking about it gave me a headache.
On top of that, Pixel suddenly started supporting AirDrop, which is our core mechanism. Being the kind of curious kid who chases every new shiny thing, I immediately built every feature on top of it to see whether it'd really work (later a friend of mine with a Pixel said it did XD)
The second reason was iteration speed. SwiftUI Previews work when they feel like it, and changing a bit of UI meant waiting on compiles until I questioned my life choices. With Expo, you save and the screen has already changed. Every UI change felt like doing time, and SwiftUI wasn't even faster: a bunch of places still had to fall back to UIKit..
# Fear of the bridge: the problems I expected from cross-platform
My impression of React Native was basically stuck from a few years ago:
- The bridge is slow, animations stutter
- The moment you touch native features (camera, Keychain, Face ID), you have to write your own native module until you cry (thank god I have AI now haha)
- Heavy computation like cryptography and zero-knowledge proofs is surely too much for JS, and I'm doing ZK on top of everything
Then I actually used it, and every one of those problems has a solution now.
# The actual solutions: eat Nitro, kid
1. Write the native stuff as Nitro Modules
Anywhere we need to touch native, like passport NFC verification, Keychain, and Apple Vision OCR, we write it as a Nitro Module. You write the spec in TypeScript, codegen produces the C++/Swift/Kotlin scaffolding, and you fill in the implementation. Calling native from JS feels like calling a regular function, without the serialization overhead of the old bridge.
2. VisionCamera v5 for the camera
QR scanning and passport MRZ recognition both rely on VisionCamera's frame processor. I stepped on enough landmines here to fill a whole separate post (and I did write one: "Hell Difficulty: Optimizing Camera Performance on Mobile").
3. Heavy computation stays native; JS only handles the flow
ZK proofs use an xcframework built with Mopro and run on the native side, while JS just receives the result. Performance is the same as pure native, because it is native.
# How to cross without falling off
The scariest part was: what happens to users' data from the old app? If the encryption format differs, every backup is toast.
What we did was write cross-check tests first. We added a FixtureExporter to the Swift version that exported AES-GCM encryption output and the business card data format as test data, then had the Expo version run the same data through bun test to make sure both sides produced exactly the same ciphertext and could decrypt each other's. Only after every cross-check passed did I dare delete the Swift version.
And it actually paid off later: backup files left over from the Swift era had a slightly different format from the new version, so I still had to add a fix so the new version could read old backups. Without the cross-checks, that kind of problem probably would've only surfaced after launch, with users yelling at me.
# On the other side of the bridge
Solid(ar)ity 2.0 is now on Expo SDK 57 and React Native 0.86, one codebase for iOS and Android. A few things I've noticed:
- Cross-platform doesn't mean slow. What's slow is usually putting heavy work in JS. Hand native work to native, let JS handle just the flow and UI, and it feels about the same as a native app
- But you don't get to skip native knowledge. Xcode Cloud builds, Metal shader precompilation, Android's minSdk: not one of those pitfalls went away. It's just gone from "the whole app is a minefield" to "there are mines along the boundary"
- SwiftUI isn't bad. If you're iOS-only and lean heavily on Apple's latest APIs, it's still the best choice. My app just happens not to be that kind of app
So the title says "crossing the bridge gracefully," but it wasn't graceful at all. It was lining up both banks first, making sure the footing would hold, and only then tearing down the old bridge. It could be graceful only because I first did some very ungraceful cross-checking.
That said, Solid(ar)ity still has a lot of Swift files very ungracefully stuffed inside it XD
Apps I ship
Free on iOS and Android.


