NNabeel Hassan

Blog · September 27, 2026 · 8 min read

Building a VPN App in React Native: Why the Tunnel Is Never in JavaScript

By Nabeel Hassan — AI Engineer · ICPC World Finalist

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.

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:

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:

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

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.

FAQ

Can you build a VPN app with React Native?

Yes, but only the app shell is React Native. The tunnel itself runs in native code the operating system controls: a VpnService on Android and a packet tunnel provider in a Network Extension on iOS. React Native draws the UI and talks to that native layer through a small bridge.

Why does my React Native VPN app show the wrong connection status?

Usually because the app trusts its own cached state. The tunnel keeps running after the app is closed and the OS can stop it at any time, so the app should query the real status from the OS on every launch and every return to the foreground and render that.

What should the JavaScript side of a React Native VPN handle?

Very little: connect, disconnect, get status, a status event stream and the permission check. Reconnection policy, routing and packet handling belong in native code so they keep working when the JavaScript app is not running.

Building something in this space?

I take on AI-agent, automation and product work directly — scoped fast, shipped fast.

Book a discovery call →

Keep reading