TL;DR: You can build a VPN app with React Native, but the VPN itself will not be written in React Native. The tunnel lives in native code the operating system controls: a VpnService on Android and a Network Extension on iOS, which runs as a separate process from your app. React Native is the remote control. I built a React Native VPN at Geonode alongside the Repocket SDK, and the lessons that mattered were all about that boundary: keep the JavaScript layer thin, treat the OS as the source of truth for connection state, design for the app being dead while the tunnel is alive, and plan for store review and real-device testing from day one, because both platforms treat VPN apps as a special category.
Most React Native questions are UI questions. How do I make this list scroll smoothly, how do I share this screen between iOS and Android. A VPN is different. The part of the product that users actually pay for, traffic going through a tunnel, is something JavaScript cannot touch. If you go in expecting to write it once in TypeScript, you will lose weeks. If you go in expecting a thin cross-platform shell over two very different native engines, it is a very reasonable way to ship.
Where the tunnel actually runs
The single most useful mental model is that a VPN app is two programs pretending to be one.
- The app is what the user opens. It shows a connect button, a server list, account state, maybe usage. This is the part React Native is genuinely good at.
- The tunnel is what moves packets. It is started, kept alive and stopped by the operating system, with rules the OS sets and you do not get to negotiate.
On Android the tunnel is a VpnService. The user has to grant consent through a system dialog the first time, the service creates a virtual network interface, and from then on the system routes traffic to it. It runs as a service, 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 your Xcode project, with its own bundle, its own entitlements and its own process. The main app does not run the tunnel. It asks the system to start a configuration, and the system launches the extension. The extension also works under a much tighter memory budget than a normal app, which rules out a lot of "just bundle a library" shortcuts.
So the React Native part of the project is honestly the smallest. The native modules and the extension are where the engineering is.
Keep the JavaScript layer thin
The rule I used on the Repocket SDK carried straight over: share the logic, never the platform. For a VPN that translates into a deliberately small bridge between JavaScript and native.
The surface I would expose to React Native is roughly this:
connect(config)anddisconnect()getStatus()returning a small, fixed set of states- a status event stream so the UI can react when the OS changes things
- a permission check and request, because the consent step is its own state
That is it. No packet handling, no routing decisions, no reconnection policy in JavaScript. Every time logic about the tunnel creeps into the JS side, you have written something that only runs while the app is open, and the whole point of a VPN is that it keeps working when the app is not.
One state machine, defined once
Where you can share is the vocabulary. Android and iOS report connection state differently, with different names, different intermediate states and different failure reasons. The native module on each side should map those into the same handful of states, something like disconnected, connecting, connected, disconnecting, and a permission-required or error state with a reason.
Doing that mapping in native code, per platform, means the UI has one enum to render and one set of transitions to test. It is the same instinct as the one source of truth across Unity, mobile and web at RAQTS: many windows, one truth, and the windows never invent their own version.
The OS owns the state, not your app
This is the bug class that catches almost everyone the first time. The app keeps a variable that says "connected," the user closes the app, the tunnel keeps running, and when the app comes back it either believes it is disconnected or believes it is connected when the system tore the tunnel down an hour ago.
The fix is simple to state: 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. Treat anything cached in JavaScript as a hint for the first frame, never as a fact.
A few situations to design for explicitly:
- The app was killed but the tunnel is up. Relaunching must show connected, and disconnect must still 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 has to go through the permission step again instead of failing silently.
This is the mobile version of what I called designing for being paused in the SDK work. Assume your process will disappear at the worst moment and make that a non-event.
Talking between the app and the tunnel
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 up the tunnel. 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 without your UI existing at all, for example when the user has enabled always-on VPN.
The practical rule: anything the tunnel needs to start, it must be able to read on its own, without the React Native app running. If the tunnel can only start because JavaScript handed it a value in memory, it will fail the first time the system starts it without you.
Permissions, entitlements and store review
VPN apps are a policy category on both stores, not just a technical one, and this shapes the project 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 that they be offered by a developer enrolled as an organization rather than an individual account. If the company plans to ship under a personal developer account, find that out in week one, not the week you submit.
On Android, apps that use VpnService are subject to 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 it or not. Design the onboarding around the system consent step instead of hoping 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 if nobody reads it up front.
Test on real devices, and build it in CI
VPN behavior on simulators and emulators is not something I would trust 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 that has to be exercised on real phones, and it has to be exercised on both platforms after every meaningful change to the native layer.
The build itself is also more fragile than a normal React Native app, because there are more moving parts: an extra iOS target, extra entitlements, provisioning profiles for both the app and the extension, and native modules that break on upgrades. That is the same argument I made in the Unity CI/CD piece: 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.
When React Native is the right call for a VPN
React Native makes sense for a VPN app when the team already writes JavaScript or TypeScript, the product has a real amount of UI beyond a connect button (accounts, server selection, usage, settings, onboarding), and you are willing to own 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. At that point the UI you are sharing is tiny and the native work dominates, so two small native apps can be the simpler path.
Either way, the decision is not "React Native or native." The tunnel is native no matter what. The decision is only about what draws the buttons.
What I would tell a team starting one
- Budget for native work first. The
VpnServiceand the Network Extension are the project. The React Native screens are the easy part. - Keep the bridge small. Connect, disconnect, status, events, permission. No tunnel logic in JavaScript.
- Let the OS be the source of truth. Ask for status on every launch and foreground. Never trust a cached "connected."
- Make the tunnel self-sufficient. It must be able to start without your app running.
- Read the store rules in week one. Account type, entitlements and disclosure requirements change the plan.
- Test on real phones, build on CI. Network transitions and process death do not show up on a simulator or a laptop build.
The through line from this to everything else I have shipped, from the Repocket SDK to AR platforms to AI voice agents, is the same: find the part the platform owns, respect it, and put the thinnest possible layer of your own code on top.
I build cross-platform mobile software, SDKs and AI systems for teams that need the platform-specific parts done properly. If you are planning a VPN, networking or background-heavy mobile app, more about me is here, or book a call.