← Back to work
Case study

Native build engine for a multi-app fleet

~98%
less native config per app
5
apps, one owned foundation
2
generations, one rewrite

Most of the engineering I'm proudest of is invisible: infrastructure that makes a team's day quieter rather than a feature a user can see.

This is one of those systems — a native foundation that five React Native apps share instead of each maintaining its own. It's also a system I built twice, because the first version taught me what the second one needed to be. Here's what it does, why it exists, and what I'd defend about it out loud.

Related: this native-integrations work is mirrored upstream — a merged iOS native-bridge fix in react-native-onesignal (#1729) ↗
01

The problem

I was maintaining five React Native applications across iOS and Android. Every time we upgraded React Native or a shared library, I had to repeat the same native procedure five times — the same plumbing, the same edits, the same room for five slightly different mistakes.

The pain wasn't any single setup; it was that the cost multiplied by five and recurred on every upgrade. That is the shape of a problem worth abstracting: repetitive, multiplying, and not solvable by being more careful.

02

The constraints

This predates Expo being the recommended default for React Native. Adopting it wholesale wasn't an obvious or sanctioned move at the time. On top of that, the team needed full control over the native layer and minimal dependence on third-party tooling — the foundation had to be something we owned, not a black box we rented.

So the real requirement wasn't "make setup convenient." It was "give us a controlled, owned native foundation that five apps can share." My job was to turn that requirement into a working system.

Generation 1 A bare-workflow foundation

The first version touched no framework abstraction at all. I kept the native iOS and Android projects as hand-maintained folders, but moved them inside the engine library instead of each app carrying its own copy. A custom prebuild step read each app's configuration — icons, assets, the rest — and injected it into the folders inside the installed library; each app linked to those folders via symlinks. One native foundation, many consuming apps.

The hardest part was trust in reproducibility. "Works on mine" is worthless for shared infrastructure. Our developers run macOS, Windows, and Linux, so I stood up a VM for each, ran the scripts on all three, and verified output with the team lead on other machines until the "something will break" feeling stopped being true. That cross-platform verification — not the scripting — was the real work.

The teardown

Over time the bare-workflow approach aged — accumulating technical debt, a foundation I'd fight rather than build on. So I threw it out and rewrote the engine from scratch, deliberately, while it still worked. What I refused to lose were the properties the organization depended on: simple per-app configuration, fast roll-out across apps, and fast delivery to the stores and QA. The implementation changed completely; the invariants did not.

Generation 2 An Expo wrapper, same philosophy

By now Expo had matured and an owned layer on top of it was defensible. My first instinct — carry the old philosophy straight over with symlinks pointed at post-prebuild folders — I recognized fairly quickly as the wrong path, dropped, and moved toward Expo config plugins as the mechanism for our native libraries. The core idea never changed: centralize all native code for every app in one place.

configurationOne source for Expo app config, Metro config, and env handling (dev / staging / prod). A consuming app declares a few lines of glue.
config.pluginsCustom plugins apply our native requirements to iOS and Android at prebuild — replacing hand-edited native files in every app.
native.modulesShared native modules with extension points — including native logging feeding server-side analysis and crash-reporting integrations.
build.cliA CLI orchestrating build, run, OTA, archive, and store/QA distribution — locally and via cloud build — across five variants, with signing, symbolication, and incremental artifact distribution.
03

Two capabilities I added because nobody asked

Production symbolication

We weren't uploading source maps and debug symbols on builds and OTA updates. Nobody filed it; the team just debugged production the hard way. I wired symbol upload into the build and OTA flow automatically — production stack traces became readable by default.

Sub-second debug launch

The yarn ios/android workflow triggered a full native build every run — wasteful, since the native layer rarely changed. The engine ships prebuilt debug artifacts; launching only swaps the JS bundle, so the app starts in about a second.

04

Results

98%
smaller footprint
Native-config footprint

Per app, hand-written native config shrinks from ~1,890 lines across ~30 files to a ~30-line, 3-file glue layer. Across five apps, ~7,600 lines of copy-paste duplication that never comes into existence.

~1d
from ~1.5wk
Setup time

Greenfield native setup drops from ~a week-and-a-half to about a day — 4–9 dev-days saved per app even pessimistically. After the first app pays back the engine, each additional app is ~a week of native setup we don't repeat.

not 5×
Iteration speed

A shared native change — an RN or library upgrade — is now made once in the engine and propagates to all five apps, instead of repeated five times by hand. One change instead of five for the native foundation.

05

What it deliberately does not do

It doesn't acquire certificates or keystores (irreducible), doesn't replace per-app secrets or the per-app build matrix (it standardizes their shape, not their existence), and doesn't touch product code, screens, or QA. Stating the boundary is part of the point: the engine is a native-foundation layer, not magic.

06

Why it mattered

The reason this is more than tooling is the timing. I committed to a shared, owned native foundation before the ecosystem offered one out of the box — and when the ecosystem caught up, I had the discipline to tear down my own working v1 and rebuild on the matured platform without losing the invariants the business relied on. The same problem, solved twice, against a moving ecosystem. The current migration toward an Expo-native compatibility layer is the next step of exactly that arc.

Want the longer conversation?

I'm happy to go deeper on native infrastructure, React Native at scale, or any of the trade-offs above.

Email me  ↗ ← Back to selected work