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 indist. If this is wrong, you’ll get a blank white screen.androidScheme: 'https'makes the app load fromhttps://localhostinside 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
buildcreates thedistfolder.synccopies it into the Android project and updates plugins.openlaunches 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.
- In
capacitor.config.ts:
server: {
androidScheme: 'https',
cleartext: true,
},
- In
android/app/src/main/AndroidManifest.xml, on the<application>tag:
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
- 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:apkproduces an.apkfile you can install directly on a phone for testing.android:buildproduces 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,usesCleartextTrafficand the network security config - Point the app at an
httpsbackend - 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://localhostandcapacitor://localhostin your CORS settings, and allow requests with no origin. - Cleartext HTTP is for local testing only.
- Always
build, thensync, 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