Zero to App Store.
One focused day.
This guide takes you through every step of building and shipping an iPhone app. Every step is grounded in official Apple documentation, not forum posts or outdated blogs.
Downloads
What's in this guide
- βModule 01β03. Setup, project structure, planning your app
- βModule 04β05. Building screens in SwiftUI, data and persistence
- βModule 06β07. Monetization with StoreKit, backend and auth
- βModule 08β10. TestFlight, App Store submission, review process
- βModule 11. Rejection fix reference + Xcode error table
- βModule 12β14. Cloudflare Workers, Firebase, AI with Claude
- βModule 15. Widgets & Push Notifications
- βModule 16. App Store Optimization (ASO)
- βPost-Launch Guide. Day 1 through month 1 action plan
- βTemplates. Metadata template and privacy policy
Xcode only runs on macOS. If you're on Windows or Linux you have two options: rent a cloud Mac (easiest, costs about $1/hr) or run macOS in VirtualBox (free, more setup). Both work. Full setup instructions are at the top of Module 1. Once you have macOS running, every step in this kit is identical.
The fastest way to build: VS Code + Claude Code
You don't have to type every line yourself. This is the actual workflow for building faster:
- Download VS Code: free at code.visualstudio.com
- Install the Claude Code extension: open Extensions (ββ§X), search "Claude Code", install it
- Open your Xcode project folder in VS Code: File β Open Folder β select the folder containing your
.xcodeprojfile - Describe what you want in plain English: Claude reads your existing Swift files, matches your naming and structure, and writes the code directly into the right files
- Switch to Xcode β βB to build β run in simulator
The modules aren't there to make you memorize syntax. They exist so you understand what you're building well enough to direct Claude accurately, spot mistakes, and make architecture decisions. Without the knowledge, you can't tell if Claude's output is correct. With it, you move fast.
Adding assets to your Xcode project
Assets (images, icons, colors, fonts) live in Assets.xcassets inside Xcode, not in VS Code. Here's exactly how to add each type:
Images (PNG/JPG)
- In Xcode, open
Assets.xcassetsin the file navigator - Right-click in the left panel β New Image Set
- Name it without spaces (e.g.
heroBannerorhero-banner) - Drag your PNG into the 2Γ slot (or 3Γ for high-resolution artwork)
- Reference in SwiftUI:
Image("heroBanner")
Asset names are case-sensitive. Image("HeroBanner") and Image("heroBanner") are different. If your image shows as blank at runtime, check the name.
App Icon
- Export as 1024Γ1024 PNG: no alpha (transparency) channel. Apple rejects icons with transparency.
- In
Assets.xcassetsβ click AppIcon - Drag your 1024Γ1024 PNG into the single slot. Xcode generates all the required sizes automatically.
- Test on a real device before submitting: retina scaling can look different than the simulator.
Brand Colors
- In
Assets.xcassets, right-click β New Color Set - Name it (e.g.
brandPrimary) - Click Any Appearance to set the light mode color, and Dark to set dark mode, they can be different values
- In SwiftUI:
Color("brandPrimary"), adapts to dark mode automatically
Custom Fonts
- Drag your
.ttfor.otffont file into the Xcode project navigator - In the file import dialog, check "Add to target" for your app target
- Open
Info.plistβ add the key Fonts provided by application β add one entry per font file with the exact filename (e.g.SpaceGrotesk-Regular.ttf) - In SwiftUI:
.font(.custom("SpaceGrotesk-Regular", size: 16)) - The name in
Font.custom()is the PostScript name, not the filename. Check it by opening the font in Font Book on your Mac β select the font β press βI β look for "PostScript Name".
SF Symbols (free, no assets needed)
Apple provides 6,000+ icons built into iOS, no imports required. In SwiftUI: Image(systemName: "star.fill"). Download the SF Symbols app from Apple's developer site to browse all icons and copy their exact names.
Official Apple documentation referenced
- Apple Human Interface Guidelines: Design standards reviewers check your app against
- App Store Review Guidelines: The 200+ rules applied to every submission
- App Store Connect Help: Official submission portal guide
- SwiftUI Documentation: Every view and modifier used in the starter kit
- TestFlight User Guide: Apple's beta distribution platform
- StoreKit 2 Documentation: In-app purchases and subscriptions
Dev Environment Setup
Before you write a single line of code, get the toolchain in place. This module ends with a blank app running in the simulator.
Apple requires Xcode to build, sign, and submit iOS apps. Download only from the Mac App Store. developer.apple.com/xcode
Don't have a Mac? Set up your environment first
Xcode is macOS-only. If you're on Windows or Linux, pick one of the two options below, get macOS running, then jump back to Step 1 and follow the rest of this module normally.
You rent a real Mac that runs in a data center. Access it through your browser or Remote Desktop. No VM setup, no compatibility issues β it's just a Mac.
- Go to macincloud.com and sign up for a Pay Per Use plan (no monthly commitment)
- Launch a Mac mini M2 instance from the dashboard
- Connect via the browser-based desktop or the Remote Desktop client they provide
- You're on a real Mac. Open the App Store, download Xcode, and continue with Step 1 below
Run macOS as a virtual machine on your Windows or Linux PC using VirtualBox. Needs a reasonably fast machine (8+ GB RAM, 100 GB free disk).
- Download VirtualBox (free) from virtualbox.org and install it
- Get a macOS image: search YouTube for "VirtualBox macOS Monterey Windows" or "VirtualBox macOS Ventura" β there are well-maintained community guides with download links and exact settings. Monterey (macOS 12) has the widest VirtualBox compatibility
- Create the VM using the settings from that guide. Typical recommended specs:
- RAM: 8 GB minimum, 12 GB recommended
- CPU: 4 cores
- Disk: 100 GB dynamically allocated
- Display: 128 MB video memory, enable 3D acceleration
- Boot and install macOS inside the VM. Takes 30β60 minutes
- Once macOS is running, open the App Store inside the VM, download Xcode, and continue with Step 1
Step 1: Download Xcode
Open the Mac App Store
Search "Xcode" and download. It's free and about 12 GB, start it first while you read ahead.
Install Command Line Tools
After Xcode launches it prompts for additional components. Accept and wait, required before anything else works.
Verify macOS version
Xcode 16 requires macOS 14 (Sonoma) or later. Go to β About This Mac to check.
Step 2: Apple Developer Program
You need a Developer account to test on a real device and submit to the App Store. Cost: $99/year paid directly to Apple at developer.apple.com/programs.
Enrollment takes up to 48 hours for identity verification. Start it while Xcode downloads so it doesn't block you at Module 8.
Step 3: Find your Team ID
Your Team ID is a 10-character string (e.g. A1B2C3D4E5). Find it at developer.apple.com β Account β Membership Details β Team ID. You'll enter it in Xcode's Signing & Capabilities tab.
Step 4: Open and run the starter kit
Download and open iOSLabsStarter.xcodeproj
Double-click the .xcodeproj file. Xcode opens it automatically. If you see a "Trust and Open" dialog, click Trust.
Set your team in Signing & Capabilities
Click the project name in the file navigator (top of the left sidebar) β select the iOSLabsStarter target β Signing & Capabilities tab β set Team to your Apple Developer account. Set Bundle Identifier to something unique like com.yourname.yourapp.
Select a simulator and press βΆ
In the toolbar, choose any iPhone simulator (iPhone 16 is a good default). Press βR to build and run. First build takes 30β60 seconds. You should see the onboarding screen.
βR, build and run βB, build only βK, clean build folder (run this if you get strange errors after changing settings) ββ§K, clean build folder completely
"No account for team", you haven't added your Apple ID to Xcode yet. Go to Xcode β Settings (β,) β Accounts β + β Apple ID.
"Signing certificate not found", click Automatically manage signing in Signing & Capabilities. Xcode will create the certificate.
"Bundle identifier is already in use", your bundle ID must be unique across the App Store. Add your name or a number: com.yourname.ioslabsstarter2.
Build errors after opening, try Product β Clean Build Folder (ββ§K), then βR again. If errors persist, check that your macOS and Xcode versions meet the requirements.
Xcode layout: orientation for new developers
Xcode has five key areas. Knowing their names saves you from getting lost.
| Area | What it is | Toggle shortcut |
|---|---|---|
| Navigator (left) | File tree, search, git changes, breakpoints | β0 |
| Editor (center) | Where you write code | Always visible |
| Canvas (right of editor) | Live preview, shows UI as you type | β₯ββ© |
| Inspector (right panel) | Properties for selected file or UI element | ββ₯0 |
| Debug area (bottom) | Console output, variable inspector | ββ§Y |
Press β₯ββ© to open the Canvas preview. Click Resume (or β₯βP) to restart the preview. You can click buttons and scroll in the preview without running the simulator, it's much faster for UI work.
Build version numbers (important before Module 8)
Xcode uses two version numbers. Both matter for TestFlight and the App Store.
| Field | Where to set it | Rules |
|---|---|---|
| Version (Marketing) | Target β General β Identity β Version | User-facing (e.g. 1.0, 1.2.3). Each App Store release needs a new version. |
| Build (Internal) | Target β General β Identity β Build | Must increment with every upload to TestFlight. Start at 1. |
Common pattern: keep Version at 1.0 during testing, increment Build (1, 2, 3β¦) with each TestFlight upload. When you ship to the App Store, set Version to 1.0 Build to your latest number.
Your Project Foundation: The Starter Kit
Walk through every file so you understand what each piece does before you start changing anything.
File structure
iOSLabsStarter/ β unzip here, open the folder
βββ iOSLabsStarter.xcodeproj/ β double-click this to open in Xcode
β βββ project.pbxproj β Xcode manages β never edit manually
βββ iOSLabsStarter/ β all Swift source files live here
β βββ iOSLabsStarterApp.swift β entry point, @main, app setup
β βββ ContentView.swift β TabView routing (Home/Explore/Profile)
β βββ OnboardingView.swift β 3-slide first-launch flow
β βββ HomeView.swift β main landing screen
β βββ ExploreView.swift β searchable list/grid
β βββ DetailView.swift β item detail with hero image
β βββ ProfileView.swift β settings and account
β βββ AppViewModel.swift β shared @Observable state
β βββ Models.swift β data structs and AppColors
β βββ Components.swift β PrimaryButton, ItemRow, ItemCard
β βββ Assets.xcassets/ β images, icons, color definitions
βββ README.md β read this first β setup checklist
The README in the zip walks you through the exact first-run setup below. Keep it open while you do the initial configuration.
First-run setup (5 steps)
Open iOSLabsStarter.xcodeproj
Double-click the file. If Xcode asks "Trust and Open", click Trust. Never open project.pbxproj directly, always use the .xcodeproj.
Set your Bundle Identifier
In Xcode: click the project name at the top of the left sidebar β select the iOSLabsStarter target β General tab β change Bundle Identifier to com.yourname.yourapp. This must be unique across the App Store.
Sign in with your Apple Developer account
Same General/Signing & Capabilities tab β set Team to your developer account. If the list is empty, go to Xcode β Settings (β,) β Accounts β + β sign in with your Apple ID first.
Select a simulator and press βR
In the toolbar at the top, pick any iPhone (iPhone 16 is fine). Press βR. First build takes 30β60 seconds. You should see the iOS Labs onboarding screen.
Make it yours in under 5 minutes
- Open
Models.swiftβ changeAppColors.accentto your brand color - Open
OnboardingView.swiftβ update the slide titles and subtitles (3 lines) - Open
Models.swiftβ renameAppItemto match your data shape - Open
iOSLabsStarterApp.swiftβ update the app display name in the General tab (Product Name field)
MVVM flow
User taps β View re-renders β reads ViewModel β ViewModel holds Model data
Planning Your App Before You Code
The step most people skip. It's also the one that causes the most wasted work.
Apple's HIG states that the best iOS apps have a single, clear purpose and a minimal, focused feature set. Read the HIG
Screen mapping
Write every screen your app needs. For each screen answer: What does it do? How do users get here? What do they do next?
Data modeling
Define your data shape before writing View code. Open Models.swift and replace AppItem:
// Example for a restaurant app
struct MenuItem: Identifiable {
let id = UUID()
let name: String
let price: Double
let category: String // "Appetizer", "Main", "Dessert"
let isAvailable: Bool
}
HIG checklist: before you build
- βEvery screen has one clear purpose
- βNavigation follows iOS conventions (back button, tab bar, sheets)
- βTap targets are at least 44Γ44 points (Apple's minimum)
- βEmpty states are designed: no blank white screens
- βApp has a plan for offline/no-connection state
Building Screens in SwiftUI
Layout primitives
VStack(spacing: 16) { Text("Top"); Text("Bottom") } // vertical
HStack(spacing: 12) { Image(systemName: "star"); Text("Label") } // horizontal
ZStack { Color.blue; Text("Overlay") } // depth layers
SF Symbols: 6,000+ free icons
Image(systemName: "heart.fill")
.font(.title2)
.foregroundStyle(.red)
// Browse all icons: download "SF Symbols" app from developer.apple.com/sf-symbols
Typography: use semantic styles
Text("Large Title").font(.largeTitle) // 34pt, scales with Dynamic Type
Text("Headline").font(.headline) // 17pt bold
Text("Body").font(.body) // 17pt (default)
Text("Caption").font(.caption) // 12pt
NavigationStack
NavigationStack {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.title)
}
}
.navigationTitle("My List")
}
// Tapping a row pushes DetailView with a back button β automatic
Sheets
@State private var showingSheet = false
Button("Open") { showingSheet = true }
.sheet(isPresented: $showingSheet) { MySheetView() }
Animations
Button("Toggle") {
withAnimation(.spring(duration: 0.4)) { isExpanded.toggle() }
}
List and ForEach
// Scrollable list from an array
List(items) { item in
HStack {
Image(systemName: item.icon)
VStack(alignment: .leading, spacing: 4) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
}
}
}
.listStyle(.insetGrouped) // grouped sections, iOS style
// ForEach inside a ScrollView for more layout control
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding()
}
// LazyVGrid β card grid layout
let columns = [GridItem(.flexible()), GridItem(.flexible())]
ScrollView {
LazyVGrid(columns: columns, spacing: 14) {
ForEach(items) { item in ItemCard(item: item) }
}
.padding()
}
Conditional views
// Ternary (same type)
Text(isLoggedIn ? "Welcome back" : "Sign in")
// if/else blocks (different views)
if items.isEmpty {
ContentUnavailableView("Nothing here yet", systemImage: "tray")
} else {
List(items) { item in ItemRow(item: item) }
}
// Optional binding
if let user = viewModel.currentUser {
Text("Hello, \(user.name)")
} else {
ProgressView()
}
Button styles
// Filled (primary action)
Button("Get Started") { /* action */ }
.buttonStyle(.borderedProminent)
.tint(.blue)
// Bordered (secondary)
Button("Cancel") { /* action */ }
.buttonStyle(.bordered)
// Custom style
Button("Custom") { /* action */ }
.padding(.horizontal, 24)
.padding(.vertical, 14)
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
Images: local and remote
// From Assets.xcassets
Image("heroBanner")
.resizable()
.scaledToFill()
.frame(height: 200)
.clipped()
// From URL (AsyncImage β built in, no package needed)
AsyncImage(url: URL(string: imageURL)) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
Image(systemName: "photo").foregroundStyle(.secondary)
case .empty:
ProgressView()
@unknown default:
EmptyView()
}
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 10))
@ViewBuilder: custom reusable components
// Define once, use everywhere
struct SectionCard<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title).font(.headline)
content()
}
.padding(16)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
}
// Usage
SectionCard(title: "Recent Activity") {
ForEach(recentItems) { item in ItemRow(item: item) }
}
Toolbar and navigation items
NavigationStack {
ContentView()
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.large) // or .inline
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(action: openSettings) {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
}
}
Search
@State private var searchText = ""
List(filteredItems) { item in ItemRow(item: item) }
.searchable(text: $searchText, prompt: "Search items")
var filteredItems: [Item] {
searchText.isEmpty ? items : items.filter {
$0.title.localizedCaseInsensitiveContains(searchText)
}
}
Data, State & Persistence
@State: single view
@State private var searchText = ""
TextField("Search", text: $searchText) // $searchText is a two-way Binding
@Published: shared across views
class AppViewModel: ObservableObject {
@Published var items: [AppItem] = []
// Any view reading items re-renders when it changes
}
UserDefaults: lightweight persistence
UserDefaults.standard.set("James", forKey: "user_name")
let name = UserDefaults.standard.string(forKey: "user_name") ?? "Guest"
// Auto-save pattern:
@Published var userName: String {
didSet { UserDefaults.standard.set(userName, forKey: "user_name") }
}
UserDefaults is not encrypted. Use the Keychain for passwords and tokens.
SwiftData: structured local storage (iOS 17+)
@Model class Task {
var title: String
var isCompleted: Bool
init(title: String) { self.title = title; self.isCompleted = false }
}
// In your App entry point:
.modelContainer(for: Task.self)
// In a View:
@Query var tasks: [Task]
@Environment(\.modelContext) var context
// Add: context.insert(Task(title: "New task"))
Async/await: API calls
func fetchItems() async {
let (data, _) = try await URLSession.shared.data(from: url)
let items = try JSONDecoder().decode([AppItem].self, from: data)
await MainActor.run { self.items = items }
}
// In View: .task { await viewModel.fetchItems() }
@AppStorage: simplest way to persist settings
// Reads and writes UserDefaults automatically β no boilerplate
struct SettingsView: View {
@AppStorage("notifications_enabled") var notificationsEnabled = true
@AppStorage("user_name") var userName = ""
@AppStorage("selected_theme") var theme = "system"
var body: some View {
Form {
Toggle("Push Notifications", isOn: $notificationsEnabled)
TextField("Display Name", text: $userName)
}
}
}
// Changes persist immediately to UserDefaults. No save button needed.
SwiftData: full CRUD
// Create
let task = Task(title: "Buy groceries")
context.insert(task)
// Read (with sort and filter)
@Query(sort: \Task.createdAt, order: .reverse) var tasks: [Task]
@Query(filter: #Predicate { $0.isCompleted == false }) var pending: [Task]
// Update β just change the property, SwiftData auto-saves
task.isCompleted = true
// Delete
context.delete(task)
// Manual save (usually not needed β SwiftData auto-saves)
try? context.save()
Keychain: secure storage for tokens
// Add the KeychainAccess package (or use Apple's raw Security framework)
// File β Add Package Dependency β github.com/kishikawakatsumi/KeychainAccess
import KeychainAccess
let keychain = Keychain(service: "com.yourcompany.yourapp")
// Write
keychain["auth_token"] = userToken
// Read
let token = keychain["auth_token"]
// Delete
try? keychain.remove("auth_token")
@AppStorage / UserDefaults: user preferences, settings, onboarding completion, last-viewed tab.
SwiftData: structured app data, tasks, items, posts, saved content.
Keychain: any credentials or tokens, auth tokens, API keys, passwords.
Files (DocumentDirectory): large blobs, downloaded PDFs, exported files, cached media.
Network error handling
enum AppError: LocalizedError {
case networkUnavailable
case decodingFailed
case serverError(Int)
var errorDescription: String? {
switch self {
case .networkUnavailable: return "No internet connection"
case .decodingFailed: return "Unexpected data format"
case .serverError(let code): return "Server error \(code)"
}
}
}
// In View β show alert on error
.alert("Error", isPresented: $showError, presenting: viewModel.error) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
Monetization with StoreKit
Digital goods must use Apple's in-app purchase system. External payment links for digital content will result in rejection. Read Β§3.1
Four monetization models
- Free: Monetize with ads (AdMob, Apple's SKAdNetwork)
- Paid upfront: User pays once to download. Apple takes 30% (15% under $1M/yr)
- One-time in-app purchase: Free download, unlock features. Most flexible.
- Auto-renewable subscription: Monthly/annual. Highest LTV, highest scrutiny.
StoreKit 2 implementation
import StoreKit
class StoreManager: ObservableObject {
@Published var purchasedIDs: Set<String> = []
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
if case .success(let verification) = result,
case .verified(let tx) = verification {
purchasedIDs.insert(tx.productID)
await tx.finish()
}
}
// REQUIRED by Guideline 3.1.1 β you will be rejected without this
func restorePurchases() async {
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result {
purchasedIDs.insert(tx.productID)
}
}
}
}
Every app with in-app purchases must have a "Restore Purchases" button. Guideline 3.1.1. This is one of the most common rejections.
Set up products in App Store Connect
Before StoreKit can show a product, you have to create it in App Store Connect. This is separate from your code.
Go to App Store Connect β your app β In-App Purchases
Click the + button. Choose Non-Consumable (one-time unlock), Consumable (coins, credits), or Auto-Renewable Subscription.
Set a Product ID
This string is what your code uses to fetch the product. Convention: use reverse-domain like com.yourapp.pro_unlock. Write it down exactly, it's case-sensitive in code.
Add a localized name and description
Required before you can test. The name and description appear on the Stripe payment sheet.
Set price
App Store Connect uses price tiers, not free-form amounts. Tier 1 = $0.99, Tier 9 = $8.99, etc. Apple pays out in your local currency at a set exchange rate.
Loading products and checking entitlement
// Load products at app launch
class StoreManager: ObservableObject {
@Published var products: [Product] = []
@Published var isPro = false
let productIDs = ["com.yourapp.pro_unlock"]
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
} catch { print("StoreKit load error:", error) }
}
// Call this at app launch to restore state
func checkEntitlements() async {
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result, tx.productID == "com.yourapp.pro_unlock" {
await MainActor.run { isPro = true }
await tx.finish()
}
}
}
}
PaywallView pattern
struct PaywallView: View {
@StateObject private var store = StoreManager()
var body: some View {
VStack(spacing: 20) {
Text("Unlock Pro").font(.title.bold())
ForEach(store.products) { product in
Button {
Task { try? await store.purchase(product) }
} label: {
Text("Buy \(product.displayName) β \(product.displayPrice)")
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: 12))
}
}
// Required by Guideline 3.1.1
Button("Restore Purchases") {
Task { await store.restorePurchases() }
}
.foregroundStyle(.secondary)
}
.padding()
.task {
await store.loadProducts()
await store.checkEntitlements()
}
}
}
Testing in-app purchases
Use the StoreKit Configuration File in Xcode, no App Store Connect products needed during development.
- File β New β File β StoreKit Configuration File β name it
Products.storekit - Add a product entry matching your Product ID
- Edit scheme: Run β Options β StoreKit Configuration β select your file
- Purchases now work in the simulator with test cards: no real money charged
For real-device testing before submission, use a Sandbox account. App Store Connect β Users and Access β Sandbox Testers β + Create a test account. Sign into this account under iOS Settings β App Store β Sandbox Account (scroll to bottom). Sandbox purchases are free and don't hit your card.
Backend & Auth
Skip this if your app works offline. Come back when you're ready.
If your app offers Google/Facebook/Twitter login, it MUST also offer Sign in with Apple. Read Β§4.8
Sign in with Apple
import AuthenticationServices
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
if case .success(let auth) = result,
let cred = auth.credential as? ASAuthorizationAppleIDCredential {
let userID = cred.user // Stable anonymous ID
let email = cred.email // Only on first sign-in
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)
Firebase setup (3 steps)
- Go to console.firebase.google.com β New Project β Add iOS app (use your bundle ID)
- Download
GoogleService-Info.plistand add it to your Sources folder - Add Firebase via Xcode: File β Add Package Dependencies β paste the Firebase iOS SDK URL
Testing with TestFlight
Archive and upload
Set destination to "Any iOS Device (arm64)"
Change the Xcode device selector, archiving requires this.
Product β Archive
Builds a release version. 1β3 minutes. Organizer opens when done.
Distribute App β TestFlight & App Store
Xcode uploads to App Store Connect. Processing takes 5β30 minutes.
Internal vs external testers
| Type | Limit | Needs Apple review? | Use case |
|---|---|---|---|
| Internal | Up to 100 testers on your team | No | Immediate testing, dev team |
| External | Up to 10,000 testers | Yes (Beta App Review, ~24h) | Public beta, friends and family |
Start with an internal tester (yourself). After a few builds are stable, create an external group and invite people via email or a public TestFlight link.
Build version numbers before archiving
Every TestFlight upload requires a new Build number. Set it in Target β General β Build. If you upload the same build number, Xcode rejects it. Use 1, 2, 3β¦ or a date stamp like 20260802.
What testers see
After uploading, go to App Store Connect β TestFlight β Builds β click your build β add "What to Test" notes. Tell testers exactly what changed and what you want them to check. Builds without notes still work, but your feedback rate improves dramatically with a specific prompt.
What simulators miss: test on a real iPhone
- βCamera and microphone
- βFace ID / Touch ID
- βPush notifications (never work in simulator)
- βHaptic feedback feel
- βPerformance under memory pressure
- βLayout on real notch / Dynamic Island
Reading crash reports
TestFlight automatically collects crashes. App Store Connect β TestFlight β Crashes. Each crash has a symbolicated stack trace. The most useful line is usually the first frame under your app name. Look for the view name and line number, it's the first place to investigate.
To install directly on your iPhone without TestFlight: connect via cable, unlock the phone, select it in Xcode's device menu, press βR. The app installs and runs directly. The cable connection lets Xcode attach a debugger so you can see print() output and breakpoints live on the device.
App Store Connect: Complete Submission
App name & subtitle
- Name: 30 chars max. No keyword stuffing.
- Subtitle: 30 chars. Strongest benefit statement.
Keywords
100 characters, comma-separated. Don't repeat words already in your name or subtitle. No competitor names, no "best", "free", "#1".
Required screenshots
- βiPhone 6.9", 1320Γ2868px: Required
- βiPhone 6.5", 1242Γ2688px: Strongly recommended
- βiPhone 5.5", 1242Γ2208px: Recommended
- βiPad 12.9", 2048Γ2732px: Required if iPad supported
Take screenshots in Xcode Simulator with βS. Set the simulator to each required device size.
Privacy policy
Required for all apps. Host it on your website (a simple HTML page is fine). Use the Privacy Policy Template in your downloads.
App Privacy (nutrition label)
Declare every category of data your app collects. Cross-references your actual app behavior. Getting it wrong is a violation of Guideline 5.1.1.
Description field strategy
The long description (4,000 chars max) is not searchable by the App Store algorithm, it's only shown to users who tap "More" on your product page. Most users don't read it. Put the strongest copy in the first 3 lines (what shows before "More"). Use short paragraphs and bullet points.
What works: specific feature list, concrete use cases ("Track workouts without opening the app with Siri shortcuts"), social proof once you have it ("Rated 4.8 by 2,000+ users"). What doesn't: marketing buzzwords, vague descriptions of "beautiful design".
Promotional text field
This 170-character field appears above your description and can be updated without a new app review. Use it for time-sensitive copy: launch announcements, limited-time pricing, new feature highlights. It's the only piece of App Store content you can change instantly.
Age rating and content advisory
App Store Connect walks you through a questionnaire. Answer every category honestly. Misrepresenting content is a violation and can get your developer account terminated. If your app has even one piece of user-generated content (comments, posts), you must declare it.
Pricing and territories
Set the price for your app in Pricing and Availability. You can exclude specific territories (e.g., exclude countries with currency instability or where your in-app payments aren't supported). Leave all territories checked unless you have a specific reason to exclude one.
App Preview video (optional but powerful)
A 15β30 second video recorded from the device. Specs: up to 500MB, H.264 or HEVC, recorded at device native resolution. Take the recording in Xcode Simulator or directly from your iPhone (Control Center β Screen Recording). The first 3 seconds auto-play in search results without sound. Make them visual, not reliant on audio.
Review notes
If your app requires login, you MUST provide test credentials. If a feature needs specific conditions, explain them. A reviewer who can't test something will reject the app.
- At least one screenshot for iPhone 6.9" (1320Γ2868)
- App icon in Assets.xcassets (1024Γ1024, no alpha channel)
- Privacy policy URL: must be publicly accessible
- App Privacy data declarations filled in
- If app requires login: demo credentials in Review Notes
- Build uploaded via Xcode and showing "Ready to Submit" in App Store Connect
- Version number set (e.g. 1.0)
- Age rating questionnaire completed
The Apple Review Process
What happens after you submit
Automated checks (minutes)
Apple's systems scan for crashes, missing metadata, and known policy violations.
Human review (24β48 hours)
A reviewer installs your app and tests it against the App Store Review Guidelines.
Approved or Rejected
Email either way. Rejections include the specific guideline(s) violated.
Resolution Center
App Store Connect β your app β App Review β Resolution Center. Reply to the reviewer professionally and specifically. Explain the exact fix you made.
Expedited review
Request for critical bug fixes or time-sensitive events. App Store Connect β Contact Us β App Review β Request Expedited Review. Usually answered within 24 hours.
Phased release
Roll out to 1% β 2% β 5% β 10% β 20% β 50% β 100% of users over 7 days. Lets you catch critical bugs before they reach everyone. Enable in App Store Connect β Pricing and Availability.
Rejections & Errors Reference
Rejection reference
| Guideline | Rejection | Fix |
|---|---|---|
| 2.1 | App Completeness, crashes, placeholder content, debug mode left on | Test every flow end-to-end. Remove all TODOs and placeholder text. Provide a demo account in review notes. |
| 4.3 | Spam, too similar to existing apps or minimal value over a website | Add genuine native features: camera, haptics, offline mode, widgets, push notifications. Use original design. |
| 5.1.1 | Privacy, collecting data not disclosed in privacy policy or App Privacy section | Match App Privacy declarations to every SDK in your app. Update your privacy policy URL. |
| 3.1.1 | In-App Purchases, missing Restore button, or external payment link for digital goods | Add Restore Purchases button. Remove external payment links for digital content. |
| 4.8 | Missing Sign in with Apple, offers Google/Facebook login but no Apple login | Add SignInWithAppleButton alongside other social login options. |
| 1.5 | Developer Info, missing or unreachable support URL | Add a real, publicly accessible support URL. Even a page with your email address works. |
| 2.3.3 | Screenshots don't match app, screenshots show UI that doesn't exist | Retake screenshots from the actual submitted build. Don't use Figma mockups. |
| 5.1.5 | Location without justification, requesting location with no clear reason | Add a specific NSLocationWhenInUseUsageDescription string. "For app functionality" is not specific enough. |
| 2.1 | No offline state, app shows blank screen with no connection | Add offline detection. Show a clear error state, not a blank screen or crash. |
| 3.2.1 | Web view wrapper, app is just a thin shell around a website | Add genuine native features. Apple rejects apps that are better served by a Mobile Safari bookmark. |
| 5.2.5 | Misrepresentation, name or description implies features not present | Remove any claims about features not in the current version. |
| 4.0 | Copycat design, icon or UI too closely resembles Apple's apps | Create an original icon. Don't use SF Symbols directly as your app icon. |
Xcode build error reference
| Error | Cause | Fix |
|---|---|---|
| No account for team "XXXXXXXXXX" | Team ID not matching signed-in account | Xcode β Settings (β,) β Accounts β add your Apple ID. Then in Signing & Capabilities, re-select your Team from the dropdown. |
| Provisioning profile doesn't include the entitlement | Capability not enabled in App ID | developer.apple.com β Certificates, IDs & Profiles β your App ID β enable the capability. |
| Bundle identifier is already in use | Another app uses this bundle ID | In Xcode, select the target β General tab β change Bundle Identifier to something unique (e.g. com.yourname.yourapp2). |
| Cannot find type 'X' in scope | Missing import or typo | Check import statements. Check for typos. Verify the file is in the Sources directory. |
| Value of type 'X' has no member 'Y' | Calling a property that doesn't exist | Use Xcode autocomplete. Check you're calling the method on the correct type. |
| Archive failed, no signing certificate | No distribution certificate in keychain | Xcode β Settings β Accounts β Manage Certificates β + β Apple Distribution. |
| AppIcon set did not have any applicable content | Missing 1024Γ1024 PNG in AppIcon.appiconset | Add a 1024Γ1024 PNG (no alpha/transparency) to Assets.xcassets/AppIcon.appiconset. |
Cloudflare Workers: Serverless Backend
A Cloudflare Worker is a JavaScript function that runs at Cloudflare's global edge network, 300+ locations worldwide. Free tier: 100,000 requests per day. No server to manage, no cold starts longer than a few milliseconds, and deployment is a single terminal command.
Your iOS app runs on the user's device. Any API key embedded in the binary can be extracted. A backend Worker acts as a proxy, your app calls your Worker, your Worker calls Stripe, Anthropic, or any third-party service with the key stored safely as a Cloudflare secret.
Your first Worker
// Modern ES module syntax (recommended)
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/api/ping') {
return Response.json({ ok: true, ts: Date.now() });
}
return new Response('Not found', { status: 404 });
}
};
CORS: required for iOS calls
Your app's network request triggers a CORS preflight. Add these headers to every response:
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*', // Lock to your domain in production
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
}
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders() });
}
const data = await handleRequest(request, env);
return Response.json(data, { headers: corsHeaders() });
}
};
KV storage: simple persistent data
KV is a key-value store that's always available to your Worker. No connection setup, no SQL.
// In wrangler.toml β bind the namespace
// [[kv_namespaces]]
// binding = "KV"
// id = "your-namespace-id"
// In your Worker
async function rateLimit(env, userId) {
const key = `rl:${userId}:${new Date().toISOString().slice(0, 13)}`; // per-hour key
const count = parseInt(await env.KV.get(key) || '0');
if (count >= 50) return false;
await env.KV.put(key, String(count + 1), { expirationTtl: 3600 });
return true;
}
D1: SQLite at the edge
// Create a table (run once with wrangler d1 execute)
// CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, created_at INTEGER);
// In your Worker
async function getUser(env, id) {
const { results } = await env.DB.prepare(
'SELECT * FROM users WHERE id = ?'
).bind(id).all();
return results[0] ?? null;
}
async function createUser(env, id, email) {
await env.DB.prepare(
'INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)'
).bind(id, email, Date.now()).run();
}
Calling your Worker from iOS
struct WorkerClient {
static let base = "https://your-worker.yourname.workers.dev"
static func ping() async throws -> Bool {
let url = URL(string: "\(base)/api/ping")!
let (data, _) = try await URLSession.shared.data(from: url)
let json = try JSONDecoder().decode([String: Bool].self, from: data)
return json["ok"] == true
}
}
Deploy
Install Wrangler
npm install -g wrangler then wrangler login
Create project
wrangler init my-worker, generates the scaffold
Add secrets
wrangler secret put ANTHROPIC_API_KEY, never in code, always in secrets
Deploy globally
wrangler deploy, live at your workers.dev subdomain in seconds
Firebase: Real-Time Data, Auth & Storage
Firebase is Google's mobile backend platform. Spark plan (free) covers 50,000 reads and 20,000 writes per day, 5GB storage, and up to 50 simultaneous connections. Enough for most apps through early growth.
If your app offers Google Sign-In, Facebook Login, or any other third-party authentication, it MUST also offer Sign in with Apple. Reviewers enforce this strictly. Read Β§4.8
Setup (4 steps)
Create Firebase project
console.firebase.google.com β Add project β Add iOS app β enter your bundle ID
Add GoogleService-Info.plist
Download from Firebase console and drag into your Xcode Sources folder. This file contains your project config (not a secret, it's designed to be in your binary).
Add Firebase SDK
Xcode β File β Add Package Dependencies β paste the Firebase iOS SDK URL β select FirebaseAuth and FirebaseFirestore
Configure app entry point
Import FirebaseCore and call FirebaseApp.configure() in your App struct's init.
Sign in with Apple
import AuthenticationServices
import FirebaseAuth
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
request.nonce = generateNonce() // Required for Firebase
} onCompletion: { result in
switch result {
case .success(let auth):
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential,
let tokenData = cred.identityToken,
let token = String(data: tokenData, encoding: .utf8) else { return }
let firebaseCred = OAuthProvider.appleCredential(
withIDToken: token,
rawNonce: currentNonce,
fullName: cred.fullName
)
Auth.auth().signIn(with: firebaseCred) { result, error in
// result.user is your Firebase user
}
case .failure(let error):
print(error.localizedDescription)
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)
Firestore: reading and writing data
import FirebaseFirestore
let db = Firestore.firestore()
// Write a document (creates or replaces)
try await db.collection("posts").document(postId).setData([
"title": "My first post",
"body": "Hello world",
"authorId": Auth.auth().currentUser!.uid,
"createdAt": FieldValue.serverTimestamp(),
])
// Read once
let snapshot = try await db.collection("posts").document(postId).getDocument()
let title = snapshot["title"] as? String
// Real-time listener β SwiftUI view updates automatically
db.collection("posts")
.whereField("authorId", isEqualTo: Auth.auth().currentUser!.uid)
.order(by: "createdAt", descending: true)
.addSnapshotListener { snapshot, error in
posts = snapshot?.documents.compactMap { try? $0.data(as: Post.self) } ?? []
}
Security Rules
Go to Firestore β Rules. Replace the default with rules that protect your data:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can only read and write their own profile
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
// Posts: anyone can read, only author can write
match /posts/{postId} {
allow read: if true;
allow write: if request.auth != null
&& request.auth.uid == resource.data.authorId;
}
}
}
Firebase Storage: file uploads
import FirebaseStorage
let storage = Storage.storage()
let imageRef = storage.reference().child("users/\(uid)/avatar.jpg")
// Upload with progress
let uploadTask = imageRef.putData(imageData, metadata: nil) { metadata, error in
guard error == nil else { return }
imageRef.downloadURL { url, error in
// Save url.absoluteString to Firestore
}
}
uploadTask.observe(.progress) { snapshot in
let pct = Double(snapshot.progress!.completedUnitCount) /
Double(snapshot.progress!.totalUnitCount)
progress = pct
}
Cost checkpoints
- βSpark plan: 50,000 reads/day, 20,000 writes/day: free
- βEach
addSnapshotListenercounts as a read every time the document changes - βPaginate queries: don't fetch entire collections at once
- βSet budget alerts in Google Cloud Console before enabling Blaze plan
AI Features with Claude
Claude is Anthropic's AI model. Integrating it into your iOS app gives you capabilities that are genuinely hard to build any other way: intent-aware search, content generation, automatic categorization, natural language inputs, and personalized recommendations.
Never call the Anthropic API directly from your iOS app. The API key can be extracted from any shipped binary. Always route requests through your Cloudflare Worker from Module 12, storing the key as a Cloudflare secret.
Architecture
iOS App β POST https://your-worker.workers.dev/api/ai
β streamed response
Cloudflare Worker β POST https://api.anthropic.com/v1/messages
(ANTHROPIC_API_KEY stored as Worker secret)
β response forwarded to iOS app
The Cloudflare Worker AI proxy
// worker.js
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const { prompt, systemPrompt } = await request.json();
// Rate limit check (use KV from Module 12)
// ...
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system: systemPrompt ?? 'You are a helpful assistant.',
messages: [{ role: 'user', content: prompt }],
}),
});
const data = await response.json();
return Response.json(
{ text: data.content[0].text },
{ headers: { 'Access-Control-Allow-Origin': '*' } }
);
}
};
Calling the proxy from iOS
struct AIClient {
static let endpoint = URL(string: "https://your-worker.workers.dev/api/ai")!
static func ask(_ prompt: String, system: String = "") async throws -> String {
var req = URLRequest(url: endpoint)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode([
"prompt": prompt,
"systemPrompt": system,
])
let (data, _) = try await URLSession.shared.data(for: req)
let result = try JSONDecoder().decode([String: String].self, from: data)
return result["text"] ?? ""
}
}
// Usage
let summary = try await AIClient.ask(
"Summarize this in 2 sentences: \(articleText)",
system: "You are a concise summarizer. Never exceed 2 sentences."
)
System prompt design
The system prompt is the most important part. A bad system prompt produces inconsistent, off-brand responses. A good one makes Claude behave like a custom-tuned model.
- Be specific about role and output format. "You are a fitness coach. Always respond with a numbered list of exactly 3 exercises." works far better than "You are helpful."
- Include constraints. "Never recommend anything requiring gym equipment." prevents Claude from giving answers that don't fit your app.
- Specify length. "Respond in under 50 words" keeps AI responses from overwhelming your UI.
- Handle edge cases explicitly. "If the user asks about anything unrelated to fitness, reply: 'I can only help with workout planning.'"
Tool use: structured output
Instead of parsing prose, use tool use to get JSON back from Claude reliably:
// In your Worker β add tools to the request body
body: JSON.stringify({
model: 'claude-haiku-4-5',
max_tokens: 512,
tools: [{
name: 'categorize_expense',
description: 'Categorize a user expense into a category',
input_schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['food', 'transport', 'housing', 'entertainment', 'health', 'other']
},
confidence: { type: 'number' }
},
required: ['category', 'confidence']
}
}],
tool_choice: { type: 'tool', name: 'categorize_expense' },
messages: [{ role: 'user', content: `Categorize this expense: ${description}` }]
})
// Response will have content[0].type === 'tool_use' with input.category
Features you can build with Claude in a weekend
- βSmart search, user types intent ("something healthy near me under $15"), Claude returns structured filters
- βAuto-categorization, drop any text/receipt into your app, Claude tags it
- βPersonalized recommendations, feed Claude the user's history, get ranked suggestions
- βSupport chatbot. Claude answers questions about your app with a system prompt trained on your docs
- βNatural language to structured data, "remind me to call John tomorrow at 3pm" β parsed calendar event
- βContent generation, workout plans, meal suggestions, email drafts, any generative text feature
Pricing reference
| Model | Input / 1M tokens | Output / 1M tokens | Typical request cost |
|---|---|---|---|
| Claude Haiku 4.5 | $0.80 | $4.00 | ~$0.0008 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | ~$0.003 |
| Claude Opus 4.8 | $15.00 | $75.00 | ~$0.015 |
A "typical request" is ~500 input tokens + ~300 output tokens. Use Haiku for high-volume features (search, categorization). Use Sonnet or Opus for complex reasoning tasks.
Widgets & Push Notifications
Two features that dramatically improve retention, home screen widgets keep your app visible, push notifications bring users back. Both are optional for first submission, but powerful additions after launch.
Part 1: WidgetKit
WidgetKit lets you show a small, continuously updating view on the user's home screen or lock screen. Widgets use SwiftUI and are built as a separate target inside your Xcode project.
Widgets are not mini-apps. They can't respond to taps (except deep links), they can't run code continuously, and they don't share memory with your main app. They work through a timeline: you provide a set of entries with scheduled display times, and the system renders each one at the right moment.
Creating your Widget target
In Xcode: File > New > Target > Widget Extension. Give it a name like MyAppWidget. Make sure "Include Configuration Intent" is unchecked for a simple static widget.
The template creates three things:
Provider, your timeline provider. Generates an array ofEntryobjects for the system to displayEntry, a struct holding the data for one widget snapshotEntryView, a SwiftUI view that renders each entry
struct SimpleEntry: TimelineEntry {
let date: Date
let taskCount: Int
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), taskCount: 3)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
completion(SimpleEntry(date: Date(), taskCount: 3))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
// Refresh every hour
let entry = SimpleEntry(date: Date(), taskCount: loadTaskCount())
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
Sharing data with App Groups
Your widget runs in a separate process and cannot read your app's UserDefaults or files directly. You need an App Group, a shared container both targets can access.
- In Xcode, select your main app target > Signing & Capabilities > + Capability > App Groups
- Create a group:
group.com.yourcompany.yourapp - Do the same for your Widget Extension target
- Now use
UserDefaults(suiteName: "group.com.yourcompany.yourapp")in both your app and widget
// In your main app, when data changes:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")!
sharedDefaults.set(taskCount, forKey: "taskCount")
WidgetCenter.shared.reloadAllTimelines() // tell widgets to refresh
// In your widget's Provider:
func loadTaskCount() -> Int {
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")!
return sharedDefaults.integer(forKey: "taskCount")
}
Widget sizes
- Small (2Γ2): one key number or status. No scrolling, minimal text
- Medium (4Γ2): a list of 2β3 items, or a summary + action
- Large (4Γ4): full mini-dashboard with multiple data points
- Lock screen: circular, rectangular, or inline: added in iOS 16
Declare supported sizes in your widget configuration:
@main
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
EntryView(entry: entry)
}
.configurationDisplayName("My App")
.description("Shows your task count.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
Part 2: Push Notifications
Push notifications are sent from a server to a specific device via Apple Push Notification service (APNs). The flow: your backend sends a push to APNs → APNs delivers it to the device → iOS shows it to the user.
Step 1: Create an APNs key
- Go to developer.apple.com > Certificates, IDs & Profiles > Keys > + button
- Name it "APNs Key", enable Apple Push Notifications service (APNs)
- Download the
.p8key file, you only get one chance to download it - Note your Key ID (10-character string) and your Team ID (top-right on developer.apple.com)
- Add these as Vercel environment variables:
APNS_KEY_ID,APNS_TEAM_ID,APNS_KEY(the full content of the .p8 file)
Step 2: Register in your iOS app
import UserNotifications
// In your App init or AppDelegate:
func requestNotificationPermission() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
// Receive the device token (add to AppDelegate or use UIApplicationDelegateAdaptor):
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// Send tokenString to your backend to store against the user's account
Task { await saveDeviceToken(tokenString) }
}
Never ask at app launch. Apple's guidelines (and user behavior) strongly favor asking after the user has experienced value, for example, after they complete their first task, or when they try to enable a notifications feature. A premature permission request that gets denied cannot be easily recovered.
Step 3: Send a push from your Cloudflare Worker (Firebase FCM)
The easiest way to send pushes without managing APNs JWT tokens yourself is to use Firebase Cloud Messaging (FCM). You already have Firebase set up from Module 13. FCM handles the APNs authentication for you.
// Cloudflare Worker β send push via FCM HTTP v1 API
export default {
async fetch(request, env) {
const { token, title, body } = await request.json();
// Get FCM access token using your service account
// (store your Firebase service account JSON as an env variable)
const accessToken = await getFCMAccessToken(env.FIREBASE_SERVICE_ACCOUNT);
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${env.FIREBASE_PROJECT_ID}/messages:send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token: token, // the device token you saved
notification: { title, body },
apns: {
payload: { aps: { sound: 'default', badge: 1 } }
}
}
})
}
);
return new Response(JSON.stringify(await response.json()));
}
};
Rich notifications
Send an image with your notification by including a mutable-content: 1 flag and creating a Notification Service Extension target in Xcode. The extension intercepts the notification before display and can download and attach media.
- File > New > Target > Notification Service Extension
- Override
didReceive(_:withContentHandler:)to download and attach the image URL from the notification payload - Action buttons: declare
UNNotificationCategorywith actions in your app delegate, then specify the category in your push payload
The iOS Simulator cannot receive real push notifications. Test on a physical iPhone connected to Xcode, or use the Xcode Simulator push notification tool (Xcode 14+) which can simulate local pushes. For full end-to-end testing (server → device), use a real device with your production app or a TestFlight build.
App Store Optimization (ASO)
Most apps fail not because the code is bad but because nobody finds them. The App Store gets 650 million visitors per week. ASO determines whether any of them see your app. This module covers how to rank, how to convert impressions to downloads, and how to keep iterating after launch.
Impressions β Product Page Views β Downloads. You can't control impressions completely (that's ranking), but you fully control conversion rate: your icon, screenshots, title, and description determine what percentage of people who see your app actually tap and download. Even a 1% conversion improvement on 10,000 monthly impressions is 100 extra downloads.
App Name: your highest-ranking real estate
The app name carries significantly more algorithmic weight than any other metadata field. The first word matters most.
- 30 character limit including spaces
- Put your primary keyword as close to the start as possible
- Make the name memorable and searchable, not clever and obscure
- Don't keyword-stuff: "Best Task Manager - To Do List App Planner" reads as spam to both Apple and users
- Example structure:
[Primary Keyword] β [Brand Name]or[Brand Name]: [Primary Keyword]
App names, subtitles, and keywords may not include pricing, terms like "free," "new," "#1," competitor names, or irrelevant keywords. Violations cause rejection at metadata review before a human reviewer even sees your app.
Subtitle: second keyword line, not a tagline
30 characters. Indexed by Apple's search algorithm. Most developers waste this on a tagline like "The app that changes everything." Use keywords instead.
- Never repeat a word from your app name: Apple only counts each keyword once
- Think of it as a second keyword slot, not a marketing line
- Example: App name "Tempo: Workout Tracker" β subtitle "Gym Log & Fitness Planner"
Keyword field: 100 characters, comma-separated
Only visible in App Store Connect, never shown to users. Apple's search uses it directly.
- No spaces after commas: every character counts:
fitness,workout,gym,exercisenotfitness, workout, gym - Don't repeat words from your app name or subtitle: they're already indexed
- Don't include competitor app names: rejection risk
- Use singular or plural, not both: Apple handles stemming
- Research what people actually search: use the App Store search bar autocomplete, look at competitor keywords using tools like AppFollow or Sensor Tower (free tiers exist)
// Bad (wasted characters):
"task,tasks,task manager,task list,todo,to-do,to do list"
// β "task" repeated 4 times, wastes 30+ characters
// Good (100 chars, no repetition):
"productivity,planner,reminder,schedule,habit,routine,checklist,project,focus,agenda"
Screenshots: where most conversions are won or lost
Studies consistently show that 85% of users make a download decision from the first 1β2 screenshots without scrolling. The first screenshot is your most valuable marketing asset, not your description.
Screenshot strategy by position
- Screenshot 1: The single most compelling moment or result your app delivers. Value proposition in large text. This is what users see in search results (portrait orientation shows ~2.5 screenshots before scrolling).
- Screenshot 2: Core feature. Show the screen that users spend the most time in.
- Screenshot 3: Second most important feature or a social proof moment (ratings, "used by X people").
- Screenshots 4β10: Supporting features, differentiators, and edge case coverage. Most users won't see these, but people who are on the fence will scroll through them.
What converts vs. what kills conversion
| Works | Doesn't work |
|---|---|
| Large benefit-focused text overlay on each screenshot | Raw Xcode simulator screenshots with no context |
| Consistent brand color frame/background behind the device | White background (blends into the App Store) |
| Showing the outcome, not the UI ("Track your runs" over a results screen) | Showing a settings or onboarding screen first |
| Dark mode if your app looks better in dark mode | Whichever mode Apple randomly renders |
| Device frames that match the current iPhone design | iPhone X bezels in 2025 |
Required screenshot sizes (2025)
- iPhone 6.9" (required), 1320Γ2868px: Dynamic Island iPhone 16 Pro Max
- iPhone 6.5" (strongly recommended), 1242Γ2688px: iPhone 11 Pro Max / XS Max
- iPhone 5.5" (recommended), 1242Γ2208px: iPhone 8 Plus
- iPad 12.9" (required if iPad supported): 2048Γ2732px
If you only upload 6.9", Apple uses those for all iPhone sizes. Upload 6.9" at minimum. The metadata template includes all sizes pre-built in Figma.
App preview video
A 15β30 second video showing your app in action. Autoplay in the App Store with sound off.
A shaky screen recording, a loading spinner, or a generic stock music bed consistently performs worse than no video at all. Only add a video if you can produce something that feels polished. A clean video of your core feature loop with no sound, good transitions, and text callouts can lift conversion by 25β30%. A bad one will sink it.
- Must be captured from a real device or simulator (not screen-recorded from a Mac)
- 30 fps minimum, H.264 or HEVC, up to 500MB
- The poster frame (first frame, shown before autoplay) matters: choose it carefully in App Store Connect
Ratings: SKStoreReviewRequest
Your average rating is one of the most visible ranking and conversion factors on your product page. Ask at the right moment and most users say yes.
import StoreKit
// Trigger the native iOS rating prompt
// Apple limits this to 3 times per 365 days β use it wisely
func requestReviewIfAppropriate() {
// Only ask after the user has experienced value
// Good triggers: completed a task, finished a session,
// achieved a goal, returned for the 5th time
if sessionCount >= 3 && hasCompletedAction {
if let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive })
as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
}
}
// SwiftUI version:
import StoreKit
@Environment(\.requestReview) var requestReview
// Then: requestReview()
Never ask immediately at app launch or after something went wrong. The best trigger is right after a success moment: the user just completed their first task, finished their first workout, or reached a milestone. Apps that ask at the right time see 4β5Γ higher positive response rates than apps that ask on a timer.
Getting featured by Apple
Apple's editorial team curates apps for Today tab features, App of the Day, and themed collections. There's no algorithm, it's a human decision. Increase your chances by:
- Supporting the latest iOS features (widgets, Live Activities, Dynamic Island, SharePlay)
- Having a polished, visually distinctive UI that follows the Human Interface Guidelines
- Submitting a feature request at apple.com/feedback β App Store editorial, ideally 4β6 weeks before any planned launch or update
- Tying your launch or update to a culturally relevant moment (new year, back to school, major iOS release)
- Being an indie developer: Apple actively features independent developers in the "Indie App of the Week" slot
Localization as an ASO multiplier
Translating your metadata (not your app) into additional languages unlocks new country rankings with minimal work. You don't need to localize the entire app first.
// In App Store Connect, add a new localization:
// My Apps β [Your App] β App Information β + button β select language
// Translate: App Name, Subtitle, Keywords, Description, What's New
// Each localization gets its own keyword field β you get 100 more chars per language
// Top 5 markets by App Store revenue:
// United States, Japan, China, United Kingdom, Germany
// French, Spanish, Japanese, German, Chinese (Simplified) cover enormous reach
// Use the Claude API to translate your metadata:
// "Translate this App Store metadata to Japanese.
// Keep keywords within 100 characters comma-separated.
// App Name: [name], Subtitle: [subtitle], Keywords: [keywords]"
Product Page Optimization (A/B testing)
App Store Connect lets you A/B test your app icon, screenshots, and app preview video against a control. Up to 3 treatment variations. Apple shows each variation to a portion of your traffic and reports conversion rates.
- Go to App Store Connect β your app β Product Page Optimization
- Create a treatment: upload alternative screenshots or icon
- Set the traffic split (50/50 or custom)
- Run for at least 7β14 days to get statistically meaningful data
- Apply the winner: it instantly becomes your default product page
Test your first screenshot before anything else, it has the highest leverage on conversion. An icon test comes second. Never test both at the same time or you won't know which change drove the result.
Your First Month After Approval
Apple approved your app. It's live on the App Store. Most first-time developers have no idea what to do next. This is the exact action plan, day by day and week by week.
Approval doesn't mean immediate visibility. A new app with no reviews, no ratings, and no downloads history starts with zero organic ranking. The first 72 hours of downloads disproportionately affect your initial ranking, this is your best window to drive traffic from non-App Store sources (social, friends, email) into the App Store, so Apple sees momentum and ranks you higher.
Day 1: Verify everything is working
- βDownload your own app from the App Store (not Xcode) on a real device you've never tested with
- βComplete the full onboarding flow as a new user
- βIf you have IAP: test a real purchase in production (you'll be charged: worth it)
- βCheck your Firebase Crashlytics dashboard: are there any crashes in the first hour?
- βCheck Firebase Analytics: are sessions showing up? Is the funnel tracking?
- βVerify your support email is working: send a test message to your support address
- βShare your App Store link to your personal network. The first 50β100 downloads from people who know you prime the ranking algorithm.
Day 1β3: Drive your first downloads
App Store ranking in the first week is heavily influenced by download velocity. Every download in the first 72 hours matters more than a download a month from now.
- Text everyone you know. Not "check out my app": send the direct App Store link with one sentence about what it does. Personal messages convert, mass posts don't.
- Post to Reddit. Find 2β3 subreddits where your target user hangs out. Be transparent that you built it. r/apple, r/productivity, and niche subreddits that match your app's purpose. Read the subreddit rules first: many allow app launches.
- Post to X / Twitter. Tag @AppStore. Indie dev launch posts get engagement. Show a screenshot or short screen recording, not a marketing paragraph.
- ProductHunt. Schedule a launch for a Tuesday or Wednesday morning (US Eastern) for maximum visibility. Prepare your tagline and screenshots in advance.
- Your existing audience, newsletter, YouTube, Instagram, TikTok: whatever you have. Even 50 subscribers who trust you will drive downloads that matter.
Week 1: First reviews and first update
Responding to reviews
In App Store Connect β Ratings and Reviews, you can respond to every review. Respond to every 1β3 star review within 24 hours. A good response to a negative review converts future readers better than no response at all. Template:
// Response template for a negative review:
"Thanks for the feedback β [specific acknowledgment of their issue].
This is something we're actively working on. Update coming [timeframe].
If you'd like to help us fix this faster, email us at support@[yourapp].com."
// After you ship the fix:
// Go back and update your response to note it's been resolved.
// Many users update their rating when they see a responsive developer.
Your first update
Ship an update in the first week if you can. Even a small one. An active app with recent updates ranks better than a dormant one. Apple's algorithm considers:
- Time since last update (stale apps rank lower)
- Release cadence (consistent updates signal an active app)
- What's New text: write it for users, not engineers. "Fixed bugs" tells no story. "Added dark mode and fixed the crash on iPhone 14" is specific and human.
// Good "What's New" text:
"β Dark mode support (finally!)
β Faster loading on older devices
β Fixed the crash some of you saw when adding a second item
β You can now swipe to delete from the main list
Thanks to everyone who emailed feedback β keep it coming."
// The conversational tone, specific fixes, and gratitude increase
// the chance users update and leave a review.
Week 2β4: Monitor, iterate, optimize
App Store Connect Analytics, what to watch
| Metric | What it tells you | Good benchmark |
|---|---|---|
| Impressions | How often your app appeared in search/browse | Growing week over week |
| Product Page Views | People who tapped to see your full page | 10β20% of impressions |
| Conversion Rate | % of page views that downloaded | 30β60% (well-optimized) |
| Retention D1/D7/D30 | % of users still active after 1/7/30 days | D1: 25%+, D7: 10%+, D30: 5%+ |
| Proceeds | Revenue after Apple's cut | Track trend, not absolute |
| Crashes per Active Device | Crash rate | <1% is the target |
Reading your keyword rankings
App Store Connect shows you which search terms are driving impressions under Analytics β Acquisition β App Store Search. Use this to:
- Find keywords where you're ranking on page 1 but not position 1: small ASO changes can move you up
- Find unexpected keywords where you're already getting impressions: lean into them in your next metadata update
- Identify keywords where impressions are high but conversion is low: your screenshots may not match user intent for that keyword
Metadata updates (ASO iteration)
You can update your app name, subtitle, keywords, screenshots, and description at any time without a new build. Go to App Store Connect β your app β [version] β edit fields β Submit for Review. Metadata-only updates typically review in 24 hours.
- Change one thing at a time so you know what moved the needle
- Wait at least 1β2 weeks between keyword changes to see ranking impact
- Use Product Page Optimization (Module 16) to A/B test screenshot changes before committing
Month 1: Establish your release cadence
Apps that ship consistently outperform apps that ship in bursts. Aim for a predictable update rhythm: every 2β4 weeks for a consumer app, every 4β6 weeks if you have a smaller user base. Here's how to think about what to build:
User feedback triage
// Categorize every piece of feedback into one of three buckets:
// 1. BUGS β crashes, broken features, wrong behavior
// β Fix immediately. Ship in the next update.
// 2. FRICTION β "I couldn't figure out how to..."
// β UX improvements. Batch into the next update.
// 3. FEATURES β "I wish it could..."
// β Log in a backlog. Look for patterns across multiple requests
// before building. 10 users asking for the same thing
// is signal; 1 user asking is noise.
Retention over acquisition
A new download you immediately lose is worse than no download at all, it lowers your retention metrics, which hurts ranking. Before spending on paid acquisition or influencer placements, get your D7 retention to 10%+. If users aren't coming back in the first week, adding more new users just accelerates churn.
Crash reporting workflow
// Firebase Crashlytics sends you an email when a new crash type
// is detected. For every crash:
// 1. Open Crashlytics β find the crash
// 2. Look at the stack trace β it shows the exact line that crashed
// 3. Check "Affected users" β is this affecting 1 user or 1,000?
// 4. Fix it, add a comment in the code explaining why the crash
// happened, and ship the fix in the next release
// 5. In Crashlytics, mark the issue as "Closed" so you know it's fixed
Applying for App Store featuring
Apple's editorial team features apps in the Today tab, App of the Day, and themed collections. This can drive tens of thousands of downloads in a single day for a small app. It's not guaranteed, but it costs nothing to apply and the editorial team does read submissions.
- Go to developer.apple.com/contact/app-store/promote
- Submit at least 4β6 weeks before your desired featuring window
- Mention: your app's unique value, target audience, iOS features you support, any press coverage, any milestones (downloads, ratings)
- Tie your request to a cultural or seasonal moment: "Back to School" in July, "New Year" in December, "WWDC" releases if you support new iOS features
- If your app is being featured in press (TechCrunch, The Verge, 9to5Mac), mention this: Apple notices media momentum
Checklist: End of month 1
- βAt least one update shipped
- βAll 1β3 star reviews responded to
- βAnalytics review: retention D1/D7 benchmarked
- βKeyword rankings checked: at least one ASO change made
- βCrashlytics checked: no open crash affecting >1% of users
- βNext update scope defined: what are you building next?
- βApp Store featuring submission sent (if you support new iOS features)
- βRevenue baseline established: you know what a "normal" week looks like
App Store Metadata Template
Fill this out before Module 09. Download the full interactive version from your Downloads.
App Name 30 characters max
No keyword stuffing. Must match what's in your app UI and app icon.
Subtitle 30 characters max
Strongest benefit statement. Don't repeat the app name.
Keywords 100 characters, comma-separated
Don't repeat words in your name or subtitle. No competitor names, "best", "free", "#1".
Required screenshot sizes
- βiPhone 6.9", 1320Γ2868px: Required
- βiPhone 6.5", 1242Γ2688px: Strongly recommended
- βiPhone 5.5", 1242Γ2208px: Recommended
- βiPad 12.9", 2048Γ2732px: Required if iPad supported
Pre-submission checklist
- βApp tested on real iPhone via TestFlight
- βAll required screenshots uploaded
- βApp icon 1024Γ1024 PNG (no alpha)
- βSupport URL live and publicly accessible
- βPrivacy policy URL live and publicly accessible
- βApp Privacy section matches actual data collected
- βAge rating completed honestly
- βNo placeholder text anywhere in the app
- βAll links in the app are live
- βRestore Purchases button exists (if IAP)
- βSign in with Apple offered (if social login)
- βReview notes include test credentials (if login required)
Privacy Policy Template
Download the full editable version from your Downloads. Instructions: replace [Your App Name], [Your Company Name], [your@email.com], and [yoursite.com] throughout. Delete sections that don't apply. Host at a public URL before submitting.
Apple requires a publicly accessible privacy policy URL for every app. It cannot require a login to view. A simple HTML page hosted on your website is sufficient.
Must cover:
- What data your app collects (or a statement that it collects none)
- How that data is used
- Whether it's shared with third parties (list all SDKs that collect data)
- How users can request deletion of their data
- Contact information