The first time someone asks "can we build our VPN in React Native?", the honest answer is yes, and the useful answer is that the VPN will not be written in React Native. JavaScript never touches a single packet. The tunnel lives in native code the operating system starts, keeps alive and kills on its own schedule. React Native gets to be the remote control.
I learned this building a React Native VPN at Geonode, alongside the Repocket C# SDK. Every lesson worth keeping from that project was about one boundary: where your app ends and the part the OS owns begins. If you go in expecting to write it once in TypeScript, you lose weeks. If you go in expecting a thin cross-platform shell over two very different native engines, it is a perfectly reasonable way to ship.
A VPN app is two programs pretending to be one
This is the mental model that saved me the most time.
- The app is what the user opens: a connect button, a server list, account state, maybe usage. React Native is genuinely good at this part.
- The tunnel is what moves traffic. The OS starts it, keeps it alive and stops it, under rules you do not get to negotiate.
On Android the tunnel is a VpnService. The first time, the user grants consent through a system dialog. The service creates a virtual network interface, the system routes traffic to it, it runs with a persistent notification, and it keeps running after the user swipes your app away.
On iOS the tunnel is a packet tunnel provider inside a Network Extension. That is a separate target in the Xcode project, with its own bundle, its own entitlements and its own process. Your app does not run the tunnel. It asks the system to start a configuration, and the system launches the extension. The extension also runs under a much tighter memory budget than a normal app, which kills a lot of "just bundle a library" shortcuts.
So the React Native part of the project is honestly the smallest part. The native modules and the extension are where the engineering lives.
Keep the bridge embarrassingly small
On the Repocket SDK my rule was "share the logic, never the platform." For a VPN, that turns into a deliberately tiny bridge between JavaScript and native. Roughly this, and nothing more:
interface VpnBridge {
connect(config: TunnelConfig): Promise<void>;
disconnect(): Promise<void>;
getStatus(): Promise<VpnStatus>;
onStatusChange(cb: (s: VpnStatus) => void): () => void;
checkPermission(): Promise<boolean>;
requestPermission(): Promise<boolean>;
}
No packet handling, no routing decisions, no reconnection policy in JavaScript. Every time tunnel logic creeps into the JS side, you have written something that only runs while the app is open. The whole point of a VPN is that it keeps working when the app is not.
One state vocabulary, mapped natively
The thing you can share is vocabulary. Android and iOS report connection state differently, with different names, different intermediate states and different failure reasons. Each native module should map its platform's states into the same small set:
type VpnStatus =
| { state: "disconnected" }
| { state: "connecting" }
| { state: "connected" }
| { state: "disconnecting" }
| { state: "permission_required" }
| { state: "error"; reason: string };
Doing that mapping in native code, per platform, means the UI renders one enum and you test one set of transitions. Many windows, one truth, and no window invents its own version of it.
The OS owns the state. Your app only asks.
This is the bug class that gets almost everyone the first time. The app keeps a variable that says "connected". The user closes the app. The tunnel keeps running. When the app comes back, it either thinks it is disconnected, or it thinks it is connected when the system tore the tunnel down an hour ago.
The fix is easy to say: the app never assumes, it asks. On every launch and every return to the foreground, query the real status from the OS through the native module and render that. Anything cached in JavaScript is a hint for the first frame, never a fact.
Three situations worth writing down as test cases before you write the UI:
- The app was killed but the tunnel is up. Relaunch has to show connected, and disconnect still has to work.
- The system stopped the tunnel. Another VPN app took over, the user turned it off in system settings, or the network changed. The UI has to reflect that without the user doing anything.
- Consent was revoked. On both platforms the user can remove your VPN permission outside your app. The next connect must route through the permission step again instead of failing silently.
In the SDK work I called this designing for being paused. Assume your process disappears at the worst possible moment, and make that a non-event.
The tunnel has to be able to start without you
Because the tunnel is a separate process on iOS, and effectively an independent service on Android, the app and the tunnel need a way to share configuration and report back.
On iOS that usually means an App Group, a shared container both the app and the extension can read, plus the provider configuration you save through the system when you set the tunnel up. On Android the service lives in your app package, which makes sharing easier, but it still has to cope with being started by the system when your UI does not exist at all, for example when the user has turned on always-on VPN.
The practical rule: anything the tunnel needs to start, it must be able to read on its own. If the tunnel only starts because JavaScript handed it a value in memory, it fails the first time the system starts it without you. And that first time will be on a user's phone, not yours.
Store policy is part of the architecture
VPN apps are a policy category on both stores, not just a technical one, and that shapes the plan more than people expect.
On iOS, the packet tunnel needs the Network Extension capability on both the app and the extension, and Apple's review guidelines hold VPN apps to specific rules, including being offered by a developer enrolled as an organization rather than an individual account. If the plan is to ship under a personal developer account, find out in week one, not in the week you submit.
On Android, apps using VpnService fall under Google Play policy on how the VPN is used and disclosed, and the persistent notification plus the consent dialog are part of the experience whether the design likes them or not. Design onboarding around the system consent step instead of trying to hide it.
None of this is hard. It is just the kind of thing that turns a two-week estimate into a six-week one when nobody reads it up front.
Real phones, and CI from day one
I would not trust VPN behavior on a simulator or emulator for anything that matters. Network changes, sleep, switching from Wi-Fi to mobile data, the system killing the app, another VPN taking over: all of it has to be exercised on real phones, on both platforms, after every meaningful change to the native layer.
The build is also more fragile than a normal React Native app. There is an extra iOS target, extra entitlements, provisioning profiles for both the app and the extension, and native modules that break on upgrades. If the only machine that can produce a working signed build is one engineer's laptop, you do not have a product, you have a dependency. Put both platforms on CI early, and make a broken extension build fail loudly the same day it breaks.
So, React Native or native?
React Native makes sense for a VPN when the team already writes TypeScript, the product has real UI beyond a connect button (accounts, server selection, usage, settings, onboarding), and you accept that you are owning two native tunnel implementations anyway. You get one app shell and one state vocabulary, and you pay for it with a bridge you must keep thin and honest.
It makes less sense when the app really is one button. Then the UI you share is tiny, the native work dominates, and two small native apps can be the simpler path.
Either way, the question is slightly wrong. The tunnel is native no matter what. The only decision is what draws the buttons.
The short version
-
Budget for native work first.
VpnServiceand the Network Extension are the project. - Keep the bridge small. Connect, disconnect, status, events, permission.
- Let the OS be the source of truth. Ask on every launch and foreground.
- Make the tunnel self-sufficient. It must start without your app running.
- Read the store rules in week one. Account type, entitlements and disclosure change the plan.
- Real phones, CI builds. Process death and network transitions do not show up on a laptop.
The thing I keep relearning, from that SDK to AR work to the voice agents I build now, is the same: find the part the platform owns, respect it, and put the thinnest possible layer of your own code on top of it.
I originally wrote this for my site, with a few more links into the related SDK and CI posts: nabeelbaghoor.com.
Top comments (1)
Good breakdown. "The OS owns the state. Your app only asks." is the kind of rule every VPN project should pin to the wall before touching UI code. The part about the tunnel needing to start on its own without the app running is the clearest argument I have seen for keeping tunnel config in a shared container rather than passing it through the bridge at runtime.