Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 067 / 100

App Store review guidelines essentials, explained simply

Avoid common rejections.

Expona Admin11 min read

Getting past the Apple review gates

We've all seen the rejection email: "Your app has been rejected for not following App Store Review Guidelines." It stings, especially when you've been iterating for weeks. The good news is that most rejections follow predictable patterns, and understanding App Store review guidelines essentials, explained simply, gives you a solid foundation to ship confidently.

This isn't about reading Apple's 80-page rulebook cover to cover. Instead, we'll walk through the four most common rejection triggers we've encountered in production apps, plus practical strategies to avoid each one. By the end, you'll know exactly what Apple's reviewers look for—and how to build your app from day one to pass on the first try.

Complete functionality, no placeholders

Apple rejects apps that feel unfinished. This doesn't mean your feature set needs to be massive—it means everything you ship must be fully functional and polished. Placeholders, disabled buttons, "Coming Soon" screens, and stubbed-out navigation will trigger a rejection almost every time.

We learned this the hard way with a travel booking app. The first submission included a "View Past Trips" section that showed a loading spinner indefinitely because the backend wasn't ready. The reviewer tapped it, saw the spinner, waited, and rejected it. The fix was straightforward: we removed that tab entirely until the feature was ready, and resubmitted. It passed in 24 hours.

The rule is simple: if it's visible in your UI, it must work end-to-end. That includes network calls, error handling, edge cases, and proper fallbacks. Here's what complete functionality looks like in practice:

swift
// ❌ Incomplete: shows placeholder only
struct ProfileView: View {
    var body: some View {
        VStack {
            Text("Profile coming soon")
            Button("View History") {
                // Not implemented
            }
            .disabled(true)
        }
    }
}

// ✅ Complete: fully functional or hidden
struct ProfileView: View {
    @StateObject private var viewModel = ProfileViewModel()
    
    var body: some View {
        VStack {
            if let user = viewModel.user {
                HStack {
                    Image(systemName: "person.crop.circle.fill")
                    VStack(alignment: .leading) {
                        Text(user.name)
                        Text(user.email)
                            .font(.caption)
                            .foregroundColor(.gray)
                    }
                }
                
                if !viewModel.trips.isEmpty {
                    NavigationLink(destination: HistoryView(trips: viewModel.trips)) {
                        Label("View History", systemImage: "clock")
                    }
                }
            } else if viewModel.isLoading {
                ProgressView()
            } else if let error = viewModel.error {
                VStack {
                    Image(systemName: "exclamationmark.triangle")
                    Text(error.localizedDescription)
                    Button("Retry") {
                        viewModel.loadProfile()
                    }
                }
            }
        }
        .onAppear {
            viewModel.loadProfile()
        }
    }
}

Notice the key differences: we load data asynchronously, show appropriate loading and error states, and only display interactive elements when the data is genuinely available. No "Coming Soon" screens. No disabled buttons with no context. No placeholders.

This also extends to metadata. Test every link in your app description, make sure all screenshots match the actual app, and verify that any URLs you reference actually work.

Clear privacy policy and data usage

Privacy is one of the strictest review categories. Apple requires that you clearly disclose what data you collect, how you use it, and where users can find your privacy policy. Vague or missing privacy disclosures are an automatic rejection.

Start with the basics: Apple Privacy Manifest. Since iOS 17, you must declare any APIs your app or dependencies use that Apple monitors for privacy. This includes things like local network access, clipboard reading, and user defaults. Many rejections happen because developers include a dependency that reads the clipboard but forget to declare it.

In your Xcode project, create a PrivacyInfo.xcprivacy file (or use Xcode's UI under Build Settings). Here's a sample structure:

swift
// PrivacyInfo.xcprivacy (XML)
// In Xcode: File > New > Resource > App Privacy Report

// Key sections:
// NSPrivacyTracking: whether your app tracks users across other apps/websites
// NSPrivacyTrackingDomains: third-party domains your app connects to for tracking
// NSPrivacyCollectedDataTypes: data you collect (health, contacts, location, etc.)
//   - For each: NSPrivacyCollectedDataType, NSPrivacyCollectedDataTypeLinked, NSPrivacyCollectedDataTypeTracking, NSPrivacyCollectedDataTypePurpose
// NSPrivacyAccessedAPITypes: API categories used (FileTimestampAPIs, SystemBootTime, UserDefaults, etc.)

Beyond the manifest, your app must link to a privacy policy from the app's settings or first launch. This policy should explain:

  • What personal data you collect (emails, location, health data, etc.)
  • How you use it (analytics, personalization, third-party sharing)
  • How long you retain it
  • How users can request deletion
  • Your data security measures

We've seen rejections because the privacy policy was generic boilerplate or contradicted what the app actually does. If you use Firebase Analytics, say so explicitly. If you use Adjust or AppsFlyer for attribution, disclose it. If you don't share data with third parties, say that too.

In your app, make it easy to reach the privacy policy:

swift
struct SettingsView: View {
    var body: some View {
        List {
            Section("Legal") {
                Link("Privacy Policy", destination: URL(string: "https://yoursite.com/privacy")!)
                Link("Terms of Service", destination: URL(string: "https://yoursite.com/terms")!)
            }
        }
    }
}

One more critical point: if your app uses Sign in with Apple (which Apple prefers), you must also support account deletion. This is non-negotiable—reviewers specifically check for this.

Use in-app purchase for digital goods

This one catches teams off guard regularly. If your app sells anything digital—subscriptions, coins, premium features, unlock codes—you must use Apple's in-app purchase system. You cannot redirect users to a web view to pay with Stripe or PayPal for digital goods. Apple takes a 30% cut of subscription revenue (15% after year one), but it's mandatory.

Physical goods are an exception. If you're selling t-shirts or coffee beans and just collecting addresses, you can use your own payment processor. But if the transaction happens inside the app for digital access or content, it goes through StoreKit.

Set up your products in App Store Connect:

  1. Go to App Store Connect > Your App > In-App Purchases
  2. Create products: auto-renewable subscriptions (for recurring access), non-consumables (one-time purchase), or consumables (can be repurchased)
  3. Test with Sandbox accounts before submitting

Here's a minimal but production-ready example using StoreKit 2 (iOS 15+):

swift
import StoreKit

@MainActor
class SubscriptionManager: NSObject, ObservableObject {
    @Published var hasActiveSubscription = false
    @Published var isLoading = false
    @Published var error: String?
    
    private var updateListenerTask: Task?
    
    override init() {
        super.init()
        updateListenerTask = listenForTransactions()
    }
    
    private func listenForTransactions() -> Task {
        return Task.detached { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await self?.handleVerifiedTransaction(transaction)
                    await transaction.finish()
                }
            }
        }
    }
    
    func requestSubscription(productID: String) async {
        isLoading = true
        error = nil
        
        do {
            let product = try await Product.products(for: [productID]).first
            guard let product = product else { throw NSError(domain: "Product not found", code: -1) }
            
            let result = try await product.purchase()
            
            switch result {
            case .success(let verification):
                if case .verified(let transaction) = verification {
                    hasActiveSubscription = true
                    await transaction.finish()
                }
            case .userCancelled:
                error = "Purchase cancelled"
            case .pending:
                error = "Purchase pending approval"
            @unknown default:
                error = "Unknown purchase result"
            }
        } catch {
            self.error = error.localizedDescription
        }
        
        isLoading = false
    }
    
    private func handleVerifiedTransaction(_ transaction: Transaction) {
        if transaction.revocationDate == nil {
            hasActiveSubscription = true
        } else {
            hasActiveSubscription = false
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
}

// Usage
struct PremiumView: View {
    @StateObject private var subscriptionManager = SubscriptionManager()
    
    var body: some View {
        VStack {
            Text("Premium Access")
            
            if subscriptionManager.hasActiveSubscription {
                Label("You have an active subscription", systemImage: "checkmark.circle.fill")
                    .foregroundColor(.green)
            } else {
                Button(action: {
                    Task {
                        await subscriptionManager.requestSubscription(productID: "com.yourapp.premium.monthly")
                    }
                }) {
                    Text("Subscribe")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
                
                if let error = subscriptionManager.error {
                    Text(error)
                        .foregroundColor(.red)
                        .font(.caption)
                }
            }
            
            if subscriptionManager.isLoading {
                ProgressView()
            }
        }
    }
}

Critical points: always validate receipts on your backend, handle subscription renewal, and make it easy for users to manage or cancel their subscription (link to Settings > Subscriptions).

Sign in with Apple when using social login

If your app offers social login (Facebook, Google, GitHub), Apple requires that you also offer "Sign in with Apple" as an alternative. This is a competitive fairness rule—you can't force users into a single third-party ecosystem.

The rule applies if you have any social login. If your app has no login at all, you're exempt. But the moment you add Google Sign-In, you must add Sign in with Apple.

Implement it like this:

swift
import AuthenticationServices

struct LoginView: View {
    @State private var isLoading = false
    @State private var error: String?
    
    var body: some View {
        VStack(spacing: 16) {
            // Your branding
            Image("AppLogo")
                .resizable()
                .scaledToFit()
                .frame(height: 100)
            
            Text("Sign In")
                .font(.title)
            
            // Sign in with Apple
            SignInWithAppleButton(
                .signIn,
                onRequest: { request in
                    request.requestedScopes = [.fullName, .email]
                },
                onCompletion: { result in
                    handleAppleSignIn(result)
                }
            )
            .signInWithAppleButtonStyle(.black)
            .frame(height: 50)
            
            Divider()
            
            // Other social logins
            Button(action: { handleGoogleSignIn() }) {
                HStack {
                    Image(systemName: "g.circle.fill")
                    Text("Continue with Google")
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)
            }
            
            if let error = error {
                Text(error)
                    .foregroundColor(.red)
                    .font(.caption)
            }
            
            if isLoading {
                ProgressView()
            }
            
            Spacer()
        }
        .padding()
    }
    
    private func handleAppleSignIn(_ result: Result) {
        isLoading = true
        
        switch result {
        case .success(let authorization):
            if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
                let userID = credential.user
                let email = credential.email
                let fullName = credential.fullName
                
                // Send to your backend for verification
                authenticateWithBackend(
                    provider: "apple",
                    userID: userID,
                    email: email,
                    name: fullName
                )
            }
        case .failure(let error):
            self.error = "Sign in with Apple failed: \(error.localizedDescription)"
            isLoading = false
        }
    }
    
    private func handleGoogleSignIn() {
        // Use Google Sign-In SDK
        // But remember: you can only use this if Sign in with Apple is also available
    }
    
    private func authenticateWithBackend(provider: String, userID: String, email: String?, name: PersonNameComponents?) {
        // Call your backend API
        isLoading = false
    }
}

Make Sign in with Apple visually prominent—at minimum, it should have equal or better placement than other social login buttons. Use Apple's official SignInWithAppleButton view and approved styling.

Common mistakes

  • Disabling buttons without explanation. If a feature isn't ready, hide it entirely. Don't show a greyed-out button with no context.
  • Hidden privacy policies. Reviewers look for this in the app's Settings. Bury it in a menu and you'll get rejected.
  • Selling digital goods outside StoreKit. Redirecting to a web checkout for in-app purchases is a guaranteed rejection.
  • Incomplete Privacy Manifest. Dependencies matter. If you use a library that accesses the clipboard, you must declare it—even if your app code doesn't directly call it.
  • Offering Google Sign-In without Sign in with Apple. Apple explicitly checks for this. Reviewers will test both flows.
  • Testing only happy paths. Submit with live test data. Try logging in with incomplete info, cancelling purchases, hitting error states. Reviewers will.

Key takeaways

  • Every UI element must be fully functional. No placeholders, no disabled buttons, no "Coming Soon" screens. If it's visible, it must work.
  • Privacy policies are mandatory and must be linked within the app. Disclose all data collection, third-party sharing, and retention practices in your Privacy Manifest and in-app policy.
  • Digital goods require in-app purchase via StoreKit. There are no exceptions and no workarounds.
  • Social login requires Sign in with Apple as an alternative. Offer both with equal prominence, or offer neither.
  • Test your app as a reviewer would: try edge cases, network failures, permission denials, and cancellations. Submit confidence that every visible path is solid.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles