DEV Community

Beksultan Sagnaev
Beksultan Sagnaev

Posted on

Building a React Native SDK Inside a Super-App with 800K Daily Users

The problem: an SDK is not an app

Most React Native tutorials teach you to build a standalone app. You control the root navigation, the splash screen, the lifecycle — everything. But what happens when your React Native code has to live inside someone else's native app, one you don't control and definitely can't crash?

That's the brief I got at Kwaaka: embed a restaurant-coupons module into Onay, a Kazakhstan-wide super-app with roughly 800,000 daily active users. Onay users needed to discover nearby restaurant discounts without installing a separate app — the coupon experience had to feel native to an app we never touched the codebase of.
I was the sole frontend developer on this: architecture, performance, and shipping were entirely on me, working closely with Kwaaka's backend team and Onay's iOS/Android engineers who wired the SDK into their production app.

Why React Native (and not native-native)

Kwaaka's whole product suite runs on React on the web. Choosing React Native meant the team could share mental models and logic between web and mobile without hiring separate iOS/Android frontend specialists. State management ran on Effector — again, for consistency with Kwaaka's web stack, plus it's well-documented and widely used across the CIS developer community. In my experience, Effector is a genuinely simpler and more modern state manager than old-guard Redux (or Redux Toolkit, for that matter) — no action types to name, no reducers to switch over, no selectors to memoize by hand. You just declare events and stores, and derive everything else from them. Less boilerplate, less ceremony, and the dependency graph between stores stays explicit instead of hiding behind connect() or useSelector. Farfetched sat on top of Effector to handle caching, retries, and stale-while-revalidate out of the box.

The constraint that shaped everything: an SDK has no root navigation, no lifecycle of its own, and cannot crash the host app. A single unhandled JS exception inside a normal React Native app just crashes that app. Inside an SDK, it would take down Onay itself, for 800K people.

Shipping fixes without App Store review

Native review cycles are slow, and coupon logic changes fast. So I built a custom over-the-air update system: the JS bundle gets built, pushed to Cloudflare R2, and picked up by the SDK on next launch — no App Store review, no native app update required.
The system runs on two tiers:

latest.json — checked roughly every 30 minutes for routine updates
critical.json — a lightweight signal file checked every ~5 minutes; it doesn't carry the fix itself, it just tells the SDK "go check latest.json right now"

Every bundle is verified with a SHA-256 hash before being applied. If the native SDK version changes, the cache invalidates automatically, so an old JS bundle never tries to call native APIs that no longer exist. A small CI/CD script handles the whole pipeline: build → upload to R2 → update manifests → invalidate CDN cache. One script run, and the fix reaches every user with zero store releases.
Bridging into a host app without asking users to log in again
Onay users are already authenticated in the native app. Rather than showing a login screen inside the SDK, I pull the JWT, coordinates, and language straight from the native layer via NativeModules. From the user's perspective, the coupon module just opens — no second login, no friction.

Getting there meant close coordination with Onay's iOS and Android teams: agreeing on the native bridge API, the SDK's lifecycle, deep-link handling, and how the modal should dismiss. Neither side changed their existing architecture — the SDK had to fit into Onay's existing flow, not the other way around.

The platform-specific glue: Swift and Kotlin

On iOS, the public API is a single line for the host app:

swiftCouponsSDK.present(from: viewController, token: jwt, onTokenRefresh: cb, onDismiss: cb)
Enter fullscreen mode Exit fullscreen mode

Underneath, a singleton bridge manager caches the RCT bridge by token — same token, same bridge instance reused; new token, fresh bridge. The SDK ships as an XCFramework via Swift Package Manager.
One detail I'm particularly glad I built: a temporary override of RCTFatalHandler. If the SDK's JS throws something unhandled, instead of crashing the host app, the SDK swaps in its own fatal handler, shows an error screen with a "Close" button, and restores the original handler on dismiss. Without this, any JS bug becomes an Onay crash.

Android mirrors the same ideas with its own constraints — a warmUp(application, token) call pre-warms the bridge on a daemon thread (roughly 60-80MB RAM, non-blocking), and the token gets read from the launching Intent before super.onCreate() so the RN bridge has it ready before the first render.

Both platforms share the same token-refresh contract: on a 401 from the coupons backend, the SDK calls back into native code, waits up to 30 seconds for a fresh token, retries once, and closes itself if that also fails. The host app doesn't know any of this is happening — it just supplies a callback.

Debugging without a safety net

Being the only frontend developer on a project means there's no code review above you, no second opinion before you merge. That's not a comfort claim — it's a constraint that forces you to think two steps ahead, because a wrong architectural call has no one to catch it.

Two bugs stood out as genuinely instructive:

The re-rendering header. Swiping between coupons was reloading the restaurant page's entire header — hero image, logo, info block — on every swipe. The chain: switching a coupon updated localActiveCouponId, which caused renderListHeader to be recreated, which remounted the coupon slider pager, which reset initialScrollIndex back to the first slide. The fix was to memoize the static parts of the header so the JSX object stayed stable, and move the coupon detail sections out of the header component into the SectionList's own sections — so renderListHeader no longer depended on the active coupon at all.

The empty home screen. After switching location away from Almaty and back, the home page stayed blank. Farfetched was serving cached — and empty — data for anything under 30 seconds old, skipping the network call entirely. The fix: call refresh() instead of start() on coordinate changes, which deliberately bypasses the cache.

The coupon logic itself

This turned out to be one of the trickiest parts of the whole project. Six coupon types — percentage off, fixed amount, free gift, paid gift, 2-for-1, and X+Y bundles — each with its own combination of unlock conditions: minimum order value, minimum item count, presence of specific products, or a minimum number of unique line items. All of it validated client-side in real time.

A widget I'm fairly happy with, CouponThresholdHint, shows a live progress bar and exact copy for whatever's still missing ("add 2 more items" vs. "add 1500₸ more"), correctly declined for Russian grammar (1 item / 2 items / 5 items follow different noun cases). Once the condition is met, it flips into a success state with a different visual treatment.

Checkout runs double validation: client-side first (to block the button and explain the problem before any request goes out), then server-side (validateCouponCart) right before order creation — if the backend rejects it, the error surfaces directly in the payment sheet. For gift and bundle coupons, the frontend has to construct part of the payload itself: resolving which product is the "free" one from the coupon's conditions, calculating quantity, and injecting it into the cart items before submission.

Where it stands
The SDK is live in a test rollout in Almaty, with the first partner locations connected and a geo-based coupon feed keyed to the user's coordinates. The architecture was deliberately built so that adding a new city doesn't require any client-side changes. It supports Russian, Kazakh, and English, auto-detected from the JWT and switchable on the fly.

Distribution through Onay solved the hardest problem a new product usually faces: building an audience from zero. The 800K users were already there — the job was making sure the coupon experience felt like it belonged.


I work as a full-stack developer based in Astana, Kazakhstan, focused on React, React Native, and mobile SDK integrations. If you're dealing with a similar "embed our thing inside someone else's app" problem, I'd be glad to compare notes — reach me on Telegram or LinkedIn.

Top comments (2)

Collapse
 
frank_signorini profile image
Frank

How did you handle module duplication between the SDK and the main app, did you use a separate package manager or rely on React Native's built-in linking? I'd love to swap ideas on this, following for more insights.

Collapse
 
topstar_ai profile image
Luis

Great engineering story. Building a React Native SDK inside a large-scale super app is a completely different challenge from building a standalone mobile feature — you’re designing infrastructure that other teams depend on, not just shipping UI.

The interesting part is balancing developer experience, performance, versioning, and stability while keeping the integration simple. A well-designed SDK should hide complexity, provide clear contracts, and evolve without constantly breaking consumers. React Native’s ability to integrate with existing native applications makes these hybrid architectures increasingly practical.

At 800K daily users, observability, backward compatibility, and release discipline become just as important as the code itself. Great example of how mobile architecture decisions change when you move from an app to a platform mindset.