How to Turn a React (Vite) App into an Android APK with Capacitor

Turning a React app into an Android app sounds like it should take an afternoon. The first part does: a few commands, and your app runs inside a phone. The hard part comes right after, when the app opens and nothing loads.

I built a React and Vite app for the web, then packaged it for Android with Capacitor. The setup took minutes. The network problems took days. This guide covers both, so you can skip the days.

What Capacitor does

Capacitor wraps your web app in a native Android shell. Your React code runs inside a WebView, which is basically a browser window without the address bar. You can use the same code for the website and the Android app.

The catch is that your app is now running on the phone, not on a server. That one fact causes almost every problem in this post.

What you need

  • A React project built with Vite (the steps are similar for other bundlers)
  • Node.js and npm
  • Android Studio, with its SDK installed

Step 1: Install Capacitor

From your project folder:

npm install @capacitor/core @capacitor/cli
npx cap init

The init command asks for an app name and an app ID. The ID is a reverse-style name like com.yourname.myapp. Choose it carefully, because it becomes the permanent identity of your app on the Play Store.

Then add the Android package and platform:

npm install @capacitor/android
npx cap add android

This creates an android folder in your project. It’s a normal Android Studio project that Capacitor manages for you.

Step 2: Check your config

Open capacitor.config.ts. Mine looks like this:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.myapp',
  appName: 'MyApp',
  webDir: 'dist',
  server: {
    androidScheme: 'https',
  },
};

export default config;

Two lines matter most:

  • webDir: 'dist' tells Capacitor where your built files are. Vite puts them in dist. If this is wrong, you’ll get a blank white screen.
  • androidScheme: 'https' makes the app load from https://localhost inside the WebView. Modern web features, like secure storage and WebSockets, behave better this way.

Step 3: Build, sync and run

Every time you change your web code, you do three things:

npm run build
npx cap sync android
npx cap open android
  • build creates the dist folder.
  • sync copies it into the Android project and updates plugins.
  • open launches Android Studio, where you press Run to start the app on a phone or emulator.

Forgetting the sync step is the most common reason people see old code on the device.

Now for the part tutorials skip.

Problem 1: Your API calls go to the phone itself

In development, you probably call your API with a relative path:

fetch('/api/auth/login');

Vite’s dev server quietly forwards that to your backend. Inside Capacitor, there’s no dev server. The app is served from https://localhost on the phone, so that request goes to https://localhost/api/auth/login, on the phone. Nothing is there, and you get net::ERR_CONNECTION_REFUSED.

The fix: in the native app, always use a full backend address. I wrote one small function that decides which address to use:

import { Capacitor } from '@capacitor/core';

export const isNative = Capacitor.isNativePlatform();

const PRODUCTION_URL = 'https://api.your-domain.com';

export function getServerUrl(): string {
  // 1. Build-time setting
  const fromEnv = import.meta.env.VITE_API_BASE_URL;
  if (fromEnv) return fromEnv.replace(/\/api\/?$/, '');

  // 2. Browser on localhost: use relative paths and let Vite proxy them
  if (!isNative && window.location.hostname === 'localhost') return '';

  // 3. Native app or deployed site: use the real backend
  return PRODUCTION_URL;
}

export function getApiBase(): string {
  const base = getServerUrl();
  return base ? `${base}/api` : '/api';
}

Use getApiBase() everywhere you make a request, and the same URL for your Socket.IO connection. Capacitor.isNativePlatform() is the simplest way to know whether you’re inside the app.

Problem 2: CORS blocks the app

Your backend probably allows your website’s address and localhost:3000. But the Android app sends a different origin: https://localhost, because of the androidScheme setting above. iOS apps use capacitor://localhost.

Some native requests and Socket.IO handshakes send no origin at all, and many CORS setups reject those too.

The fix: allow those origins on both Express and Socket.IO:

const allowedOrigins = [
  'https://localhost',
  'capacitor://localhost',
  'http://localhost:3000',
  'https://your-website.com',
];

function corsOrigin(origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) {
  // No origin = native app or tools like curl
  if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
  callback(new Error('Not allowed by CORS'));
}

app.use(cors({ origin: corsOrigin }));

const io = new Server(httpServer, { cors: { origin: corsOrigin } });

List your exact domains. Avoid wildcards like “any .vercel.app site,” because anyone can host a page on those domains.

Problem 3: Android blocks plain HTTP

When I tested against my laptop’s address on Wi-Fi, such as http://192.168.1.6:5000, Android 9 and newer refused with ERR_CLEARTEXT_NOT_PERMITTED. Android blocks unencrypted traffic by default.

For local testing only, three changes allow it.

  1. In capacitor.config.ts:
server: {
  androidScheme: 'https',
  cleartext: true,
},
  1. In android/app/src/main/AndroidManifest.xml, on the <application> tag:
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
  1. Create android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

Important: remove all three before you release the app. Shipping with cleartext traffic allowed means your users’ data can travel unencrypted. For production, use an https backend only.

Problem 4: The IP changes every time you switch Wi-Fi

Your laptop gets a new local address on every network. Rebuilding and reinstalling the APK each time was slow, and my laptop’s firewall often blocked the port without telling me.

The fix: I added a small “Server settings” section on the login screen. It has:

  • an input to change the server address at runtime (saved in localStorage)
  • a Test button that pings the server and shows a clear message on timeout, which usually means a firewall
  • a Reset to default button that goes back to the production URL

The resolver checks the saved address first, then falls back to the build-time setting. It also deletes a saved local IP if the app is later opened on a real website, so a stale test address can’t break production. This is a debug tool, so hide it from your release build, or limit it to testers.

Problem 5: “Unexpected token ‘<‘”

If the address is wrong or a route is missing, some servers return your app’s index.html instead of an error. Your code then tries to read HTML as JSON and fails with a confusing message:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

The fix: check the response type before parsing it:

const contentType = res.headers.get('content-type') || '';

if (contentType.includes('text/html')) {
  throw new Error(
    'Server returned HTML instead of JSON. Check your API base URL.'
  );
}

Now a wrong address gives a clear error instead of a puzzle.

Building the APK

Once it works on your device, you can build installable files from the command line. I added two scripts to package.json:

{
  "scripts": {
    "android:apk": "npm run build && npx cap sync android && cd android && gradlew.bat assembleRelease",
    "android:build": "npm run build && npx cap sync android && cd android && gradlew.bat bundleRelease"
  }
}

On Mac or Linux, use ./gradlew instead of gradlew.bat.

  • android:apk produces an .apk file you can install directly on a phone for testing.
  • android:build produces an .aab (Android App Bundle). Google Play asks for this format when you publish.

Signing the release build

Release builds must be signed. Without it, command-line builds fail or give you an unsigned file. First, create a keystore once:

keytool -genkey -v -keystore release.keystore -alias myalias -keyalg RSA -keysize 2048 -validity 10000

Then tell Gradle to use it. In android/app/build.gradle:

signingConfigs {
    release {
        def keystoreFile = file(System.getenv("KEYSTORE_PATH") ?: "release.keystore")
        if (keystoreFile.exists()) {
            storeFile keystoreFile
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias System.getenv("KEY_ALIAS")
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
}

Make sure the release build type uses it with signingConfig signingConfigs.release.

Notice that the passwords come from environment variables. Don’t write them into the file as defaults, even as a convenience. Anything in your repo can leak, and a leaked signing key is a real problem. Also keep release.keystore out of Git, and back it up somewhere safe. If you lose it, you may not be able to update your app.

Before you release

  • Remove cleartext: true, usesCleartextTraffic and the network security config
  • Point the app at an https backend
  • Hide or remove the server settings screen
  • Use exact CORS origins
  • Keep your keystore and passwords out of the repo
  • Test the signed build on a real phone, not only the emulator

What to take away

  • Capacitor puts your web app on the phone, so relative API paths stop working.
  • Use Capacitor.isNativePlatform() and one function that decides the server URL.
  • Allow https://localhost and capacitor://localhost in your CORS settings, and allow requests with no origin.
  • Cleartext HTTP is for local testing only.
  • Always build, then sync, then run.
  • Keep signing passwords in environment variables, never in the code.

Related posts: Building a Multi-Platform React App with Capacitor (Web, Mobile and Admin), and How I Built Real-Time Chat in React with Socket.IO.

Building a Multi-Platform React App with Capacitor (Web Mobile & Admin) · knowabteverything.com

How I Built Real-Time Chat in React with Socket.IO · knowabteverything.com

Leave a Comment