Skip to main content

Chapter 10: Capacitor Setup

This is the chapter you've been building toward. Your web app is about to become a real iOS app.

Everything you built in Part 4 was a web app running in the browser. That changes now. In this chapter, we install Capacitor --- the bridge that wraps your web app in a native iOS shell --- and run it in the iOS Simulator. Same code you already wrote. Real native app.


What You'll Do

  • Install Capacitor and the iOS platform
  • Configure your project for iOS builds
  • Learn the build, sync, run workflow
  • Open your project in Xcode for the first time
  • Run the app in the iOS Simulator

What You'll Learn

  • How Capacitor works under the hood (it's a WKWebView, not a compiler)
  • The relationship between your web build and the native project
  • The Capacitor sync workflow
  • Xcode project structure basics

Before You Start: Install Xcode

You Need the Full Xcode App

This is the chapter where you need the full Xcode app (not just Command Line Tools). If you haven't installed it yet, open the Mac App Store, search for Xcode, and install it. It's roughly 8 GB, so start the download now if you haven't already.

After the install finishes, open Xcode once. It will ask you to accept the license agreement and install additional components --- say yes to both. This one-time setup takes a few minutes. You cannot proceed with this chapter until Xcode is fully set up.

Once Xcode is ready, verify it from your terminal:

xcode-select -p

You should see something like /Applications/Xcode.app/Contents/Developer. If you see a path pointing to CommandLineTools instead, run:

sudo xcode-select -s /Applications/Xcode.app

Step 1: How Capacitor Works

Before installing anything, let's understand what Capacitor actually does. This is important because it affects how you think about the entire iOS workflow.

Your React App (HTML/CSS/JS)
|
v
+--------------------+
| Capacitor |
| |
| +---------------+ |
| | WKWebView | | <-- Safari's rendering engine
| | | |
| | Your app | |
| | runs here | |
| +---------------+ |
| |
| Native Bridge | <-- Connects JS to native APIs
| (Camera, etc.) |
| |
+--------------------+
|
v
iOS App (.ipa)

Capacitor is not a compiler. It does not convert your React code into Swift or Objective-C. Here's what actually happens:

  1. Your app's built files (the dist/ folder from npm run build) are copied into an iOS project
  2. The iOS app loads those files inside a WKWebView --- essentially an embedded Safari browser without the address bar
  3. Capacitor provides a JavaScript bridge to native device APIs (camera, haptics, filesystem, and more)
  4. The result is a genuine iOS app that you can submit to the App Store

This is the same approach used by many production apps. Your web skills transfer directly --- no Swift required.

Why not just use a PWA? Progressive Web Apps can't reliably access the camera, can't trigger haptic feedback, can't appear in the App Store, and can't use TestFlight for beta testing. Capacitor gives you all of that without changing a single line of your existing web code.


Step 2: Send the Prompt to Claude Code

Let Claude Code handle the installation and setup:

>_Claude Code — Step 2
Send this to Claude Code:
> I want to bundle this app for iOS using capacitor. Please help with this.
Why: This prompt tells Claude Code to install Capacitor, initialize it, add the iOS platform, and configure the project. Claude Code will handle the package installs, config file creation, and iOS project generation.
What to expect: Claude Code will install @capacitor/core, @capacitor/cli, and @capacitor/ios. It will run cap init and cap add ios, creating a capacitor.config.ts file and an ios/ directory containing a full Xcode project.
Tip: If any terminal commands fail during installation, copy the error output and paste it directly to Claude Code. It can diagnose npm errors, CocoaPods issues, and Xcode path problems.
$ claude "I want to bundle this app for iOS using capacitor. Please help with this."

What Claude Code will do

When you send that prompt, Claude Code will run several commands. Here's what to expect:

Install the packages:

npm install @capacitor/core @capacitor/cli
npm install @capacitor/ios

Initialize Capacitor --- this creates the config file:

npx cap init

During initialization, you'll be asked for:

  • App name: GratitudeTree (or whatever you named your app)
  • App Package ID: something like com.yourname.gratitudetree --- this is the unique identifier for your app on the App Store, using reverse domain notation

Add the iOS platform --- this creates the Xcode project:

npx cap add ios

This command creates an entire ios/ folder in your project containing a full Xcode workspace. You won't need to manually edit most of these files --- Capacitor manages them for you.

Terminal Issues? Post Them to Claude Code

Throughout this chapter, if any terminal command fails or produces unexpected output, copy the full error message and paste it to Claude Code. It's particularly good at diagnosing Capacitor setup issues, CocoaPods errors, and Xcode configuration problems. This applies to every step from here on.


Step 3: Understand What Capacitor Created

After Claude Code finishes, take a look at what changed in your project.

The config file

Claude Code created a capacitor.config.ts file in your project root. This file tells Capacitor everything it needs to know about your app. The key settings are:

SettingWhat it does
appIdYour app's unique identifier on the App Store (reverse domain notation, e.g., com.yourname.gratitudetree)
appNameThe name displayed under the app icon on the home screen
webDirThe folder containing your built web files --- this should be dist since Vite outputs there
server.url(Optional) During development, points to Vite's dev server for live reload
server.cleartext(Optional) Allows HTTP connections --- needed for local development since localhost isn't HTTPS

The server block is for development only. It tells the iOS app to load your UI from http://localhost:5173 instead of from the bundled files. This means changes you make in your React code will appear instantly in the Simulator without rebuilding. You'll remove this block later when building for TestFlight.

Dev Server vs. Production

The server block with the url property is a development convenience. When you build for TestFlight or the App Store, you must remove it so the app uses the bundled dist/ files. We'll handle that in Part 6.

The ios/ folder

The ios/ directory is a complete Xcode project. Here's what's inside:

ios/
└── App/
├── App/
│ ├── AppDelegate.swift <-- Native app entry point
│ ├── Info.plist <-- iOS app configuration
│ ├── Assets.xcassets <-- App icon and images
│ └── public/ <-- Your built web files land here
├── Podfile <-- CocoaPods dependencies
└── App.xcworkspace <-- Open this in Xcode

You don't need to understand all of these files. The important thing to know is that Capacitor generated and manages this project for you.


Step 4: The Build-Sync-Run Workflow

Capacitor has a three-step workflow. You'll use this every time you want to test changes in the Simulator:

npm run build
npx cap sync
npx cap open ios

Why three steps?

  1. Build --- Vite compiles your TypeScript and React into plain HTML, CSS, and JavaScript in the dist/ folder
  2. Sync --- Capacitor copies that dist/ folder into the iOS project and installs or updates any native plugins (via CocoaPods)
  3. Open --- Launches Xcode with your project so you can run it in the Simulator

The dev server shortcut

During development, you can skip the build step because the server.url setting in your Capacitor config points to Vite's dev server. This means the Simulator loads your app directly from localhost:5173. The workflow becomes:

  1. Start Vite in one terminal: npm run dev
  2. Sync and open in another terminal: npx cap sync then npx cap open ios
  3. Run in the Simulator from Xcode
  4. Edit your React code --- changes hot-reload in the Simulator automatically

This is the workflow you'll use most during development. The full build-sync-run workflow is for production builds and when you need to test the bundled app.

Terminal Issues? Post Them to Claude Code

If npx cap sync fails with pod install errors, or if npx cap open ios can't find Xcode, paste the full error into Claude Code. These are common first-time setup issues that Claude Code knows how to resolve.


Step 5: Run It in the Simulator

This is the big moment.

Everything you've built across Parts 1 through 4 --- the idea, the scaffolding, the UI shell, the entries, authentication, polish, streaks --- all of it is about to come to life as a real iOS app.

Start the dev server

In your first terminal:

npm run dev

Sync and open Xcode

In a second terminal:

npx cap sync
npx cap open ios

Xcode will open with your project loaded.

Run the app

  1. In the Xcode toolbar at the top, find the device selector dropdown. Click it and choose a Simulator --- for example, iPhone 15 or iPhone 16
  2. Click the Play button (the triangle icon in the top-left, or press Cmd + R)
  3. Wait for the Simulator to boot up and the app to install. The first build takes longer because Xcode needs to compile native dependencies and set up the Simulator. Subsequent builds are much faster.
  4. Your GratitudeTree app appears --- running inside an iOS app, not a browser

Your app is running on iOS.

Take a second to appreciate what just happened. You started this tutorial with an idea. You built a complete web application with a UI shell, journal entries, authentication, streaks, and polish. And now that same application is running as a native iOS app in the Simulator.

There's no browser address bar. No navigation controls. It looks and feels like every other app on your phone. Because it is one.

Take a screenshot of the Simulator. You earned this moment.


What to verify in the Simulator

  • The app loads and shows your login screen (or the main app if you have an active session)
  • Tab navigation works --- tap Feed, New, and Profile
  • Creating and viewing entries works (as long as your Vite dev server is running)
  • The app fills the screen naturally --- no browser chrome, no address bar
  • The status bar (time, battery, signal) appears above your app like a real iOS app
First Build Takes Longer

The first Xcode build downloads and compiles CocoaPods dependencies, which can take a minute or two. Don't worry if it seems slow. Subsequent builds reuse the cached artifacts and finish much faster.

If you see an infinite loading spinner

Fix This Before Moving On

If your app loads in the Simulator but gets stuck on an infinite loading spinner, this is a known compatibility issue between Firebase's persistentMultipleTabManager and Capacitor's WKWebView. The multi-tab manager depends on BroadcastChannel and SharedWorker, which WKWebView does not support --- so Firebase silently fails to initialize.

Send this prompt to Claude Code to fix it:

>_Claude Code — Fix
Send this to Claude Code:
> My app uses Firebase Firestore with persistentLocalCache. I'm about to build it for iOS using Capacitor. Before I do, apply these two preemptive fixes for known Capacitor + WKWebView compatibility issues: 1. Find where Firestore is initialized and replace persistentMultipleTabManager with persistentSingleTabManager. WKWebView does not support BroadcastChannel or SharedWorker which the multi-tab manager depends on — leaving it in will silently break Firebase init and cause an infinite loading spinner on iOS. 2. Find the auth hook that uses onAuthStateChanged and add a 5-second timeout fallback that sets loading to false if the callback never fires. This prevents users getting permanently stuck on a loading screen if Firebase is slow to initialize on device. Do not change anything else.
Why: WKWebView (the browser engine inside Capacitor) does not support BroadcastChannel or SharedWorker. Firebase's multi-tab persistence manager relies on these APIs, so it silently breaks inside a native iOS app. The fix is to switch to the single-tab manager, which works perfectly in Capacitor since your app only ever runs in one tab anyway.
What to expect: Claude Code will update your Firebase config to use persistentSingleTabManager instead of persistentMultipleTabManager, and add a timeout fallback to your auth hook.
Tip: If you don't see the spinner, you can still send this prompt as a preventive fix. It won't hurt anything and prevents a confusing bug later.
$ claude "My app uses Firebase Firestore with persistentLocalCache. I'm about to build it ..."

If sign-in hangs (button spins forever)

Firebase Auth Deadlocks in WKWebView

If tapping "Sign In" causes the button to spin forever --- the network request succeeds (you can verify in Safari Web Inspector) but the signInWithEmailAndPassword promise never resolves or rejects --- this is a different WKWebView compatibility issue.

Firebase Auth's default initialization (getAuth) auto-detects which storage mechanism to use for session persistence. In a normal browser this is instant. In Capacitor's WKWebView, this detection can deadlock: Firebase receives the auth token but gets stuck trying to persist it, and the promise hangs silently. No error, no timeout, just nothing.

The fix is to replace getAuth with initializeAuth and explicitly tell Firebase to use IndexedDB. Send this prompt to Claude Code:

>_Claude Code — Fix
Send this to Claude Code:
> In our Firebase config file, replace getAuth(app) with initializeAuth(app, { persistence: indexedDBLocalPersistence }). Import initializeAuth and indexedDBLocalPersistence from 'firebase/auth' instead of getAuth. This fixes a silent deadlock where Firebase Auth hangs in Capacitor's WKWebView because the automatic persistence detection fails. Do not change anything else.
Why: Firebase Auth's getAuth auto-detects storage mechanisms for session persistence. In WKWebView, this detection deadlocks — the sign-in network call succeeds but the promise never resolves because Firebase gets stuck persisting the session. Using initializeAuth with explicit IndexedDB persistence skips the detection entirely.
What to expect: Claude Code will update your Firebase config to use initializeAuth with indexedDBLocalPersistence instead of getAuth.
Tip: How to spot this: sign-in works in the browser but hangs in the Simulator. The network request succeeds in Safari Web Inspector but the app never moves past loading. No errors in the console.
$ claude "In our Firebase config file, replace getAuth(app) with initializeAuth(app, { per..."

Step 6: Understand the Xcode Project

You don't need to become an Xcode expert. But knowing the basics will save you time when configuring native features in the next chapters.

Files you'll touch during this tutorial

FileWhen you'll need it
ios/App/App/Info.plistAdding camera and photo library permission descriptions
ios/App/App/Assets.xcassetsSetting the app icon
capacitor.config.tsChanging Capacitor configuration (lives in your project root, not in ios/)

Files Capacitor manages (don't edit manually)

FileWhat it is
AppDelegate.swiftThe iOS app lifecycle entry point
PodfileCocoaPods dependency list (updated by cap sync)
ios/App/App/public/Your built web files (overwritten every sync)

The golden rule

Never manually edit files inside ios/App/App/public/. That folder gets completely overwritten every time you run npx cap sync. All your code changes happen in your React project. Capacitor copies them into the iOS project during sync.

Xcode Signing (quick setup)

When you first open the project, Xcode may show a signing error. To fix it:

  1. Click on App in the Xcode project navigator (the left sidebar)
  2. Select the Signing & Capabilities tab
  3. Check Automatically manage signing
  4. Select your Team from the dropdown --- if you don't have an Apple Developer account, select your personal team (your Apple ID works for Simulator testing)

This is only needed for running on physical devices and for TestFlight. The Simulator usually works without signing configuration, but it's good to set it up now.


Step 7: Add Convenience Scripts

Ask Claude Code to add shortcuts to your package.json:

>_Claude Code — Step 7
Send this to Claude Code:
> Add convenience scripts to package.json for our Capacitor iOS workflow. I want a script to sync, a script to open Xcode, and a combined script that syncs and then opens Xcode.
Why: These scripts simplify the three-step Capacitor workflow so you don't have to remember the full commands each time.
What to expect: Claude Code will add cap:sync, cap:open, and ios:dev scripts to the scripts section of package.json.
Tip: If Claude Code makes changes you don't expect in package.json, paste the diff back and ask it to explain or fix the issue.
$ claude "Add convenience scripts to package.json for our Capacitor iOS workflow. I want a..."

After Claude Code adds the scripts, your development workflow becomes:

npm run dev

In a second terminal:

npm run ios:dev

That single command syncs the project and opens Xcode. From there, press Cmd + R to run in the Simulator. Two terminals, two commands, and you're developing an iOS app.


Step 8: Commit Your Progress

Your project now has a complete iOS setup. Save it:

git add -A
git commit -m "Add Capacitor: iOS project setup and dev workflow"
git push
What to commit

The ios/ folder should be committed --- it contains your Xcode project configuration, Info.plist, and other settings you'll modify later. However, the ios/App/Pods/ directory should be in .gitignore (Capacitor sets this up by default). If Claude Code created or updated your .gitignore, verify that Pods/ is listed.


📁Updated project structure
gratitude-tree/
├── ios/ <-- NEW (entire Xcode project)
│ └── App/
│ ├── App/
│ │ ├── AppDelegate.swift
│ │ ├── Info.plist
│ │ ├── Assets.xcassets
│ │ └── public/ <-- Built web files (auto-generated)
│ ├── Podfile
│ └── App.xcworkspace
├── src/
│ └── ... <-- Unchanged
├── capacitor.config.ts <-- NEW
├── package.json <-- UPDATED (new scripts)
└── ...
⚠️Common Issues

Checkpoint

Checkpoint — End of Chapter 10

Your app should now:

  • Capacitor is installed and initialized with your app ID
  • The ios/ folder contains a full Xcode project
  • Your Capacitor config is set up with the dev server URL for live reload
  • Running npx cap sync copies web files to the iOS project
  • The app runs in the iOS Simulator and looks like a native app
  • Hot reload works --- React code changes appear in the Simulator
  • You understand the build, sync, run workflow
  • Convenience scripts are added to package.json

What's Next

Your app runs on iOS --- but it's still using web APIs for everything. In the next chapter, we'll add Capacitor plugins for the camera and haptic feedback. These are your first real native features --- things that a browser simply can't do as well. The app will start to feel like it belongs on a phone.

𝕏

Follow @parvsondhi for build threads, tips, and updates on this tutorial.

Next: Chapter 11 — Native Plugins: Camera & Haptics →