Capacitor + React — Build Cross-Platform Mobile Apps from Your Web App (2026)

Your React code, running natively on iOS and Android. Capacitor bridges the gap between web and mobile — giving you full access to native APIs like camera, geolocation, push notifications, and biometrics while keeping your existing React codebase intact.
What You Will Learn
By the end of this tutorial, you will be able to:
- Understand how Capacitor works and how it differs from React Native or Cordova
- Add Capacitor to an existing React project (Vite-based)
- Access native device features: camera, filesystem, haptics, and local storage
- Build and run your app on iOS and Android simulators
- Handle platform-specific code cleanly
- Configure app icons, splash screens, and deep links
- Prepare your app for App Store and Google Play submission
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (
node --version) - Solid knowledge of React and TypeScript
- Xcode 16+ (for iOS — macOS only)
- Android Studio with an emulator configured (for Android)
- CocoaPods installed (
sudo gem install cocoapods) for iOS - A code editor like VS Code
Why Capacitor?
Capacitor is Ionic's runtime that lets you deploy web apps as native mobile apps. Unlike React Native, you keep your standard DOM-based React code — HTML, CSS, and all. Unlike Cordova (which Capacitor replaces), it uses a modern plugin architecture, first-class TypeScript support, and can even run as a Progressive Web App simultaneously.
| Feature | Capacitor | React Native | Cordova |
|---|---|---|---|
| UI rendering | WebView (your HTML/CSS) | Native views | WebView |
| Plugin system | Modern, TypeScript-first | Bridge modules | Legacy XML |
| PWA support | Built-in | None | Limited |
| Ecosystem | Growing, Ionic-backed | Massive | Declining |
| Learning curve | Low (if you know web) | Medium-High | Low |
Step 1: Create a React + Vite Project
We will start with a fresh Vite + React + TypeScript project. If you already have one, skip to Step 2.
npm create vite@latest my-mobile-app -- --template react-ts
cd my-mobile-app
npm installVerify it works:
npm run devOpen http://localhost:5173 — you should see the default Vite + React page.
Step 2: Install Capacitor
Add Capacitor core and CLI to your project:
npm install @capacitor/core
npm install -D @capacitor/cliInitialize Capacitor:
npx cap initYou will be prompted for:
- App name:
My Mobile App - App Package ID:
com.example.mymobileapp(use reverse-domain notation)
This creates a capacitor.config.ts file at the root of your project:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.mymobileapp",
appName: "My Mobile App",
webDir: "dist",
server: {
androidScheme: "https",
},
};
export default config;The webDir: "dist" tells Capacitor where your built web assets live — for Vite, that is dist.
Step 3: Add Native Platforms
Add iOS and Android platforms:
npm install @capacitor/ios @capacitor/android
npx cap add ios
npx cap add androidThis creates ios/ and android/ directories with full native Xcode and Android Studio projects.
Build your web app and sync it to the native projects:
npm run build
npx cap syncAlways run npx cap sync after changing your web code or installing new Capacitor plugins. This copies your built dist/ into the native projects and updates native dependencies.
Step 4: Run on Simulators
iOS (macOS only)
npx cap open iosThis opens the project in Xcode. Select a simulator (e.g., iPhone 15 Pro) and click the Run button, or run from the terminal:
npx cap run iosAndroid
npx cap open androidThis opens the project in Android Studio. Select an emulator and click Run, or:
npx cap run androidYou should see your React app running inside a native shell on both platforms.
Step 5: Access the Camera with a Native Plugin
Let us build something real — a photo capture feature using the Capacitor Camera plugin.
Install the plugin:
npm install @capacitor/camera
npx cap syncCreate a custom hook src/hooks/useCamera.ts:
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
export function useCamera() {
const takePhoto = async () => {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Camera,
});
return photo.webPath;
};
const pickFromGallery = async () => {
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Photos,
});
return photo.webPath;
};
return { takePhoto, pickFromGallery };
}Now use it in a component src/components/PhotoCapture.tsx:
import { useState } from "react";
import { useCamera } from "../hooks/useCamera";
export function PhotoCapture() {
const { takePhoto, pickFromGallery } = useCamera();
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const handleTakePhoto = async () => {
try {
const url = await takePhoto();
if (url) setPhotoUrl(url);
} catch (error) {
console.error("Camera error:", error);
}
};
const handlePickPhoto = async () => {
try {
const url = await pickFromGallery();
if (url) setPhotoUrl(url);
} catch (error) {
console.error("Gallery error:", error);
}
};
return (
<div className="photo-capture">
<h2>Photo Capture</h2>
<div className="buttons">
<button onClick={handleTakePhoto}>Take Photo</button>
<button onClick={handlePickPhoto}>Pick from Gallery</button>
</div>
{photoUrl && (
<div className="preview">
<img src={photoUrl} alt="Captured" />
</div>
)}
</div>
);
}iOS Permissions
For iOS, you need to add permission descriptions. Open ios/App/App/Info.plist and add:
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to select images</string>Android Permissions
For Android, Capacitor handles permissions automatically, but verify in android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />Rebuild and sync:
npm run build
npx cap sync
npx cap run iosStep 6: Local Storage with Preferences
Capacitor's Preferences plugin provides a key-value store that persists across app launches — the mobile equivalent of localStorage but more reliable.
npm install @capacitor/preferences
npx cap syncCreate a storage utility src/lib/storage.ts:
import { Preferences } from "@capacitor/preferences";
export const storage = {
async get<T>(key: string): Promise<T | null> {
const { value } = await Preferences.get({ key });
if (!value) return null;
return JSON.parse(value) as T;
},
async set(key: string, value: unknown): Promise<void> {
await Preferences.set({
key,
value: JSON.stringify(value),
});
},
async remove(key: string): Promise<void> {
await Preferences.remove({ key });
},
async clear(): Promise<void> {
await Preferences.clear();
},
};Use it to persist photos:
import { storage } from "../lib/storage";
// Save a photo URL
await storage.set("lastPhoto", photoUrl);
// Retrieve it later
const lastPhoto = await storage.get<string>("lastPhoto");Step 7: Haptic Feedback
Add tactile feedback to button presses — a small touch that makes apps feel native:
npm install @capacitor/haptics
npx cap syncCreate a haptics utility src/lib/haptics.ts:
import { Haptics, ImpactStyle } from "@capacitor/haptics";
import { Capacitor } from "@capacitor/core";
export const haptics = {
async impact(style: ImpactStyle = ImpactStyle.Medium) {
if (Capacitor.isNativePlatform()) {
await Haptics.impact({ style });
}
},
async notification(type: "success" | "warning" | "error") {
if (Capacitor.isNativePlatform()) {
await Haptics.notification({ type: type as any });
}
},
async vibrate() {
if (Capacitor.isNativePlatform()) {
await Haptics.vibrate();
}
},
};Notice the Capacitor.isNativePlatform() check — this ensures haptics only fire on real devices, not in the browser. Use it in your photo button:
import { haptics } from "../lib/haptics";
import { ImpactStyle } from "@capacitor/haptics";
const handleTakePhoto = async () => {
await haptics.impact(ImpactStyle.Light);
const url = await takePhoto();
if (url) setPhotoUrl(url);
};Step 8: Platform Detection and Adaptive UI
Your app runs on web, iOS, and Android. Sometimes you need platform-specific behavior:
import { Capacitor } from "@capacitor/core";
export function getPlatformInfo() {
return {
platform: Capacitor.getPlatform(), // 'web' | 'ios' | 'android'
isNative: Capacitor.isNativePlatform(),
isIOS: Capacitor.getPlatform() === "ios",
isAndroid: Capacitor.getPlatform() === "android",
isWeb: Capacitor.getPlatform() === "web",
};
}Use this for adaptive styling:
import { getPlatformInfo } from "../lib/platform";
function AppHeader() {
const { isIOS } = getPlatformInfo();
return (
<header
style={{
paddingTop: isIOS ? "env(safe-area-inset-top)" : "0",
}}
>
<h1>My App</h1>
</header>
);
}Safe Areas
iOS devices with notches need safe area handling. Add this CSS:
:root {
--sat: env(safe-area-inset-top);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
}
body {
padding-top: var(--sat);
padding-bottom: var(--sab);
padding-left: var(--sal);
padding-right: var(--sar);
}In capacitor.config.ts, enable the status bar overlay:
const config: CapacitorConfig = {
// ...
ios: {
contentInset: "automatic",
},
};Step 9: Push Notifications
Add push notifications with the Capacitor Push Notifications plugin:
npm install @capacitor/push-notifications
npx cap syncCreate a notifications service src/lib/notifications.ts:
import { PushNotifications } from "@capacitor/push-notifications";
import { Capacitor } from "@capacitor/core";
export async function initPushNotifications() {
if (!Capacitor.isNativePlatform()) {
console.log("Push notifications not available on web");
return;
}
// Request permission
const permStatus = await PushNotifications.requestPermissions();
if (permStatus.receive !== "granted") {
console.warn("Push permission not granted");
return;
}
// Register with Apple/Google
await PushNotifications.register();
// Listen for registration token
PushNotifications.addListener("registration", (token) => {
console.log("Push registration token:", token.value);
// Send this token to your backend
});
// Listen for incoming notifications
PushNotifications.addListener(
"pushNotificationReceived",
(notification) => {
console.log("Push received:", notification);
}
);
// Listen for notification taps
PushNotifications.addListener(
"pushNotificationActionPerformed",
(action) => {
console.log("Push action:", action);
// Navigate to relevant screen
}
);
}Call this from your App.tsx:
import { useEffect } from "react";
import { initPushNotifications } from "./lib/notifications";
function App() {
useEffect(() => {
initPushNotifications();
}, []);
return <>{/* your app */}</>;
}Step 10: App Icons and Splash Screen
Generating Icons
Use the @capacitor/assets package to generate all required icon sizes from a single source image:
npm install -D @capacitor/assetsPlace your source images:
assets/icon-only.png— 1024x1024 app icon (no background)assets/icon-background.png— 1024x1024 icon backgroundassets/splash.png— 2732x2732 splash screen
Generate all platform icons:
npx capacitor-assets generateThis creates all required sizes for both iOS and Android in the native projects.
Splash Screen Configuration
Install the splash screen plugin:
npm install @capacitor/splash-screen
npx cap syncConfigure in capacitor.config.ts:
const config: CapacitorConfig = {
// ...
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: "#1a1a2e",
showSpinner: false,
androidScaleType: "CENTER_CROP",
splashFullScreen: true,
splashImmersive: true,
},
},
};Dismiss it programmatically in your app:
import { SplashScreen } from "@capacitor/splash-screen";
// When your app is ready
await SplashScreen.hide();Step 11: Live Reload During Development
Typing npm run build && npx cap sync every time you change code is slow. Enable live reload to speed up development:
Update capacitor.config.ts for development:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.mymobileapp",
appName: "My Mobile App",
webDir: "dist",
server: {
// Enable live reload in development
url: "http://YOUR_LOCAL_IP:5173",
cleartext: true,
},
};
export default config;Find your local IP:
# macOS
ipconfig getifaddr en0Now start Vite and run on device:
npm run dev
npx cap run ios --livereload --externalRemember to remove the server.url before building for production! You do not want your release app pointing to a development server.
Step 12: Build for Production
iOS
- Open in Xcode:
npx cap open ios - Select Product then Archive
- In the Organizer, click Distribute App
- Follow the signing and upload flow to App Store Connect
Android
Generate a signed APK or AAB:
cd android
./gradlew bundleReleaseThe AAB file will be at android/app/build/outputs/bundle/release/app-release.aab.
Sign it with your keystore:
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release.aab alias_nameUpload the AAB to the Google Play Console.
Complete App Example
Here is a full App.tsx putting it all together:
import { useEffect, useState } from "react";
import { SplashScreen } from "@capacitor/splash-screen";
import { PhotoCapture } from "./components/PhotoCapture";
import { storage } from "./lib/storage";
import { initPushNotifications } from "./lib/notifications";
import { getPlatformInfo } from "./lib/platform";
import "./App.css";
function App() {
const [platform, setPlatform] = useState("");
const [savedPhotos, setSavedPhotos] = useState<string[]>([]);
useEffect(() => {
const init = async () => {
const info = getPlatformInfo();
setPlatform(info.platform);
// Load saved photos
const photos = await storage.get<string[]>("photos");
if (photos) setSavedPhotos(photos);
// Initialize push notifications
await initPushNotifications();
// Hide splash screen
await SplashScreen.hide();
};
init();
}, []);
return (
<div className="app">
<header className="app-header">
<h1>My Mobile App</h1>
<span className="platform-badge">{platform}</span>
</header>
<main>
<PhotoCapture />
{savedPhotos.length > 0 && (
<section className="gallery">
<h2>Saved Photos</h2>
<div className="photo-grid">
{savedPhotos.map((url, i) => (
<img key={i} src={url} alt={`Photo ${i + 1}`} />
))}
</div>
</section>
)}
</main>
</div>
);
}
export default App;Troubleshooting
"Unable to determine the current platform" error
Make sure you ran npx cap sync after installing new plugins.
iOS build fails with signing errors
Open Xcode, go to Signing & Capabilities, and select your development team. For free Apple Developer accounts, you can test on a physical device but not publish.
Android emulator is slow
Enable hardware acceleration (HAXM on Intel, Hypervisor on Apple Silicon). Use an x86_64 system image for better performance.
Camera does not work in browser
Some Capacitor plugins fall back gracefully on web, others do not. The Camera plugin opens a file picker on the web as a fallback.
Hot reload not connecting on device
Make sure your device and development machine are on the same Wi-Fi network, and check firewall settings.
Next Steps
- Explore the Capacitor Plugin Registry for more native features
- Add biometric authentication with
@capacitor/biometrics - Integrate deep linking with
@capacitor/appfor URL-based navigation - Set up CI/CD with Appflow or GitHub Actions for automated builds
- Consider adding Ionic Framework on top for pre-built mobile UI components
Conclusion
Capacitor lets you leverage your React and web skills to build real native mobile apps — without learning Swift or Kotlin. You keep your familiar tooling (Vite, TypeScript, CSS), gain access to native APIs through a clean plugin system, and can even ship the same code as a PWA.
The approach is pragmatic: for apps that are primarily content-driven, form-heavy, or CRUD-oriented, Capacitor delivers native-quality results with a fraction of the effort of a fully native build. For GPU-intensive games or apps needing deep platform integration, native development or React Native might be better suited.
Start with your existing React app, add Capacitor, and ship to both app stores — all from one codebase.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Cross-Platform Mobile App with Expo, React Native, and TypeScript in 2026
Learn how to build a production-ready cross-platform mobile app from scratch using Expo SDK 52, React Native, and TypeScript. This tutorial covers file-based routing, native APIs, state management, and deployment to both App Store and Google Play.

Building Local-First Collaborative Apps with Yjs and React
Learn how to build real-time collaborative applications that work offline using Yjs CRDTs and React. This tutorial covers conflict-free data synchronization, offline-first architecture, and building a shared document editor from scratch.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.