```html How to Build a Recipe App in SwiftUI (2026)

How to Build a Recipe App in SwiftUI

A recipe app lets users browse, save, and cook from a personal cookbook — and with built-in shopping list integration, it automatically collects ingredients from any recipe in one tap. It's a great fit for home cooks who want an app tuned to their own recipe collection rather than a generic online database.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app follows a straightforward SwiftData-backed architecture. Recipe, Ingredient, and ShoppingListItem are all @Model classes stored in a single ModelContainer. Views observe queries with @Query and mutate state through @Environment(\.modelContext). There is no separate view model layer — SwiftData's observation handles reactivity directly. Remote recipe images are loaded lazily with AsyncImage, keeping the model layer free of networking concerns. Ad banners are rendered as a thin UIKit wrapper injected at the bottom of the main tab view.

RecipeApp/
├── RecipeAppApp.swift          # @main, ModelContainer setup
├── Models/
│   ├── Recipe.swift            # @Model: title, imageURL, steps, ingredients
│   ├── Ingredient.swift        # @Model: name, quantity, unit
│   └── ShoppingListItem.swift  # @Model: name, quantity, isChecked
├── Views/
│   ├── RecipeListView.swift    # @Query, search, List + AsyncImage
│   ├── RecipeDetailView.swift  # steps, "Add to list" button
│   ├── ShoppingListView.swift  # checklist, swipe-to-delete
│   └── AddRecipeView.swift     # form for new recipes
├── Components/
│   └── BannerAdView.swift      # UIViewRepresentable for GADBannerView
└── PrivacyInfo.xcprivacy

Step-by-step

1. Project setup

Create a new SwiftUI project in Xcode 16 with SwiftData enabled. Name it RecipeApp, set the minimum deployment target to iOS 17, and confirm the checkbox for SwiftData is ticked in the project wizard — this scaffolds the ModelContainer for you.

// RecipeAppApp.swift
import SwiftUI
import SwiftData

@main
struct RecipeAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Recipe.self, ShoppingListItem.self])
    }
}

2. Define data models with SwiftData

Three @Model classes form the backbone: Recipe owns an array of Ingredient value types (no separate table needed for simple ingredient data), and ShoppingListItem is an independent model that represents what the user has queued up to buy. Using a cascade delete rule prevents orphaned rows.

// Models/Recipe.swift
import SwiftData
import Foundation

struct Ingredient: Codable {
    var name: String
    var quantity: String
    var unit: String
}

@Model
final class Recipe {
    var title: String
    var summary: String
    var imageURL: String
    var steps: [String]
    var ingredients: [Ingredient]
    var isFavorite: Bool
    var createdAt: Date

    init(title: String, summary: String = "",
         imageURL: String = "", steps: [String] = [],
         ingredients: [Ingredient] = []) {
        self.title = title
        self.summary = summary
        self.imageURL = imageURL
        self.steps = steps
        self.ingredients = ingredients
        self.isFavorite = false
        self.createdAt = .now
    }
}

// Models/ShoppingListItem.swift
@Model
final class ShoppingListItem {
    var name: String
    var quantity: String
    var unit: String
    var isChecked: Bool

    init(name: String, quantity: String, unit: String) {
        self.name = name
        self.quantity = quantity
        self.unit = unit
        self.isChecked = false
    }
}

3. Build the recipe list view

RecipeListView uses @Query to fetch all recipes, a searchText binding to filter them in memory, and AsyncImage for lazy image loading. Avoid applying heavy modifiers inside the AsyncImage phase closure — do it outside to prevent layout thrash during loads.

// Views/RecipeListView.swift
import SwiftUI
import SwiftData

struct RecipeListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Recipe.createdAt, order: .reverse) private var recipes: [Recipe]
    @State private var searchText = ""
    @State private var showingAdd = false

    var filtered: [Recipe] {
        guard !searchText.isEmpty else { return recipes }
        return recipes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filtered) { recipe in
                NavigationLink(value: recipe) {
                    HStack(spacing: 12) {
                        AsyncImage(url: URL(string: recipe.imageURL)) { phase in
                            switch phase {
                            case .success(let image):
                                image.resizable().scaledToFill()
                            case .failure:
                                Image(systemName: "fork.knife").foregroundStyle(.secondary)
                            default:
                                ProgressView()
                            }
                        }
                        .frame(width: 56, height: 56)
                        .clipShape(RoundedRectangle(cornerRadius: 10))

                        VStack(alignment: .leading, spacing: 2) {
                            Text(recipe.title).font(.headline)
                            Text(recipe.summary)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                                .lineLimit(1)
                        }
                    }
                    .padding(.vertical, 4)
                }
            }
            .navigationTitle("Recipes")
            .searchable(text: $searchText, prompt: "Search recipes")
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") { showingAdd = true }
                }
            }
            .sheet(isPresented: $showingAdd) {
                AddRecipeView()
            }
        }
    }
}

#Preview {
    RecipeListView()
        .modelContainer(for: Recipe.self, inMemory: true)
}

4. Core feature: recipe detail and shopping list integration

The detail screen shows steps and ingredients, and exposes an "Add to Shopping List" button that inserts each ingredient as a ShoppingListItem into the same ModelContext. Duplicate detection keeps the list clean — skip ingredients that already exist by name.

// Views/RecipeDetailView.swift
import SwiftUI
import SwiftData

struct RecipeDetailView: View {
    let recipe: Recipe
    @Environment(\.modelContext) private var modelContext
    @Query private var existingItems: [ShoppingListItem]
    @State private var addedToList = false

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                AsyncImage(url: URL(string: recipe.imageURL)) { phase in
                    if let image = phase.image {
                        image.resizable().scaledToFill()
                    } else {
                        Rectangle().fill(.quaternary)
                    }
                }
                .frame(maxWidth: .infinity)
                .frame(height: 240)
                .clipped()

                VStack(alignment: .leading, spacing: 16) {
                    // Ingredients
                    Text("Ingredients").font(.title2).bold()
                    ForEach(recipe.ingredients, id: \.name) { ing in
                        HStack {
                            Text("• \(ing.quantity) \(ing.unit) \(ing.name)")
                                .font(.body)
                        }
                    }

                    Button {
                        addIngredientsToShoppingList()
                    } label: {
                        Label(
                            addedToList ? "Added to list!" : "Add all to shopping list",
                            systemImage: addedToList ? "checkmark.circle.fill" : "cart.badge.plus"
                        )
                        .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(addedToList)

                    Divider()

                    // Steps
                    Text("Method").font(.title2).bold()
                    ForEach(Array(recipe.steps.enumerated()), id: \.offset) { idx, step in
                        HStack(alignment: .top, spacing: 10) {
                            Text("\(idx + 1)")
                                .font(.caption.bold())
                                .foregroundStyle(.white)
                                .frame(width: 22, height: 22)
                                .background(Color.accentColor, in: Circle())
                            Text(step).font(.body)
                        }
                    }
                }
                .padding(.horizontal)
                .padding(.bottom, 32)
            }
        }
        .navigationTitle(recipe.title)
        .navigationBarTitleDisplayMode(.large)
    }

    private func addIngredientsToShoppingList() {
        let existingNames = Set(existingItems.map { $0.name.lowercased() })
        for ing in recipe.ingredients where !existingNames.contains(ing.name.lowercased()) {
            let item = ShoppingListItem(name: ing.name, quantity: ing.quantity, unit: ing.unit)
            modelContext.insert(item)
        }
        withAnimation { addedToList = true }
    }
}

#Preview {
    let recipe = Recipe(
        title: "Pasta Primavera",
        summary: "Light and fresh spring pasta",
        steps: ["Boil water", "Cook pasta", "Sauté vegetables", "Combine and serve"],
        ingredients: [
            Ingredient(name: "Penne", quantity: "200", unit: "g"),
            Ingredient(name: "Zucchini", quantity: "1", unit: "medium")
        ]
    )
    NavigationStack { RecipeDetailView(recipe: recipe) }
        .modelContainer(for: [Recipe.self, ShoppingListItem.self], inMemory: true)
}

5. Shopping list view with persistence

The shopping list screen queries all ShoppingListItem records sorted alphabetically. Swipe-to-delete removes items via modelContext.delete, and tapping an item toggles the isChecked flag with a smooth strikethrough animation — SwiftData propagates changes automatically.

// Views/ShoppingListView.swift
import SwiftUI
import SwiftData

struct ShoppingListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \ShoppingListItem.name) private var items: [ShoppingListItem]

    var body: some View {
        NavigationStack {
            Group {
                if items.isEmpty {
                    ContentUnavailableView(
                        "No items yet",
                        systemImage: "cart",
                        description: Text("Tap 'Add to shopping list' on any recipe.")
                    )
                } else {
                    List {
                        ForEach(items) { item in
                            Button {
                                item.isChecked.toggle()
                            } label: {
                                HStack {
                                    Image(systemName: item.isChecked
                                          ? "checkmark.circle.fill" : "circle")
                                        .foregroundStyle(item.isChecked ? .green : .secondary)
                                    VStack(alignment: .leading) {
                                        Text(item.name)
                                            .strikethrough(item.isChecked)
                                            .foregroundStyle(item.isChecked ? .secondary : .primary)
                                        Text("\(item.quantity) \(item.unit)")
                                            .font(.caption)
                                            .foregroundStyle(.secondary)
                                    }
                                }
                            }
                            .buttonStyle(.plain)
                        }
                        .onDelete(perform: deleteItems)
                    }
                }
            }
            .navigationTitle("Shopping List")
            .toolbar {
                if !items.isEmpty {
                    ToolbarItem(placement: .destructiveAction) {
                        Button("Clear checked") { clearChecked() }
                    }
                }
            }
        }
    }

    private func deleteItems(at offsets: IndexSet) {
        for index in offsets { modelContext.delete(items[index]) }
    }

    private func clearChecked() {
        items.filter(\.isChecked).forEach { modelContext.delete($0) }
    }
}

#Preview {
    ShoppingListView()
        .modelContainer(for: ShoppingListItem.self, inMemory: true)
}

6. Ad monetization with Google Mobile Ads

Wrap GADBannerView in a UIViewRepresentable so it slots into any SwiftUI hierarchy. Place the banner at the bottom of your tab view's container so it doesn't intrude on list or detail content, and always use test ad unit IDs during development.

// Components/BannerAdView.swift
// Requires: pod 'Google-Mobile-Ads-SDK' (or SPM equivalent)
import SwiftUI
import GoogleMobileAds

struct BannerAdView: UIViewRepresentable {
    // Replace with your real ad unit ID before submitting
    let adUnitID: String = "ca-app-pub-3940256099942544/2934735716" // test ID

    func makeUIView(context: Context) -> GADBannerView {
        let banner = GADBannerView(adSize: GADAdSizeBanner)
        banner.adUnitID = adUnitID
        banner.rootViewController = context.coordinator.rootVC
        banner.load(GADRequest())
        return banner
    }

    func updateUIView(_ uiView: GADBannerView, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator() }

    class Coordinator {
        var rootVC: UIViewController? {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .first?.windows.first?.rootViewController
        }
    }
}

// Usage in ContentView:
// VStack(spacing: 0) {
//     TabView { ... }
//     BannerAdView().frame(height: 50)
// }

7. Add the Privacy Manifest (required for App Store)

Apple requires a PrivacyInfo.xcprivacy file in the app bundle if you access any "required reason" APIs or use third-party SDKs that do. The Google Mobile Ads SDK collects device data for ad targeting — declare this explicitly. Missing or incomplete manifests are a top reason for App Store rejection.

<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeDeviceID</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Ad-supported

Use the Google Mobile Ads SDK (available via Swift Package Manager at https://github.com/googleads/swift-package-manager-google-mobile-ads) to serve banner ads. Initialise MobileAds.shared.start() in your @main App's init. Place a 320×50 adaptive banner anchored to the bottom of the screen — this maximises impressions without covering content. For a better CPM, add interstitial ads on natural pause points (e.g. after the user saves a new recipe), but cap frequency to one per session to avoid App Store guideline 4.5.4 violations on excessive advertising. You can layer a "Remove Ads" StoreKit subscription on top later without restructuring the codebase — just gate banner rendering behind a @AppStorage("adsRemoved") flag that your StoreKit transaction handler sets.

Shipping this faster with Soarias

Soarias handles the scaffolding work that eats the first day of any intermediate project: it generates the SwiftData model files, wires the ModelContainer into the App entry point, sets up a Fastlane Matchfile for code signing, and pre-fills your PrivacyInfo.xcprivacy based on the SDKs it detects in the project. For a recipe app using the Google Mobile Ads SDK, Soarias also injects the correct ad-related privacy entries automatically — you don't need to cross-reference Apple's required reasons documentation by hand.

For an intermediate-complexity app like this one, most developers spend two to three hours on signing, provisioning, and App Store Connect metadata before a single line of feature code ships. Soarias compresses that to under fifteen minutes. You spend the saved time on the parts that differentiate your app: the search experience, the ingredient parser, the onboarding flow.

Related guides

FAQ

Does this work on iOS 16?

The tutorial targets iOS 17 because SwiftData, the #Preview macro, and the @Observable macro are all iOS 17-only. If you need iOS 16 support you'd need to replace SwiftData with Core Data and @ObservableObject — a significant rewrite. The App Store's iOS 16 install base is now below 10%, so iOS 17 as your minimum is a reasonable commercial decision.

Do I need a paid Apple Developer account to test?

You can run the app on your own physical device with a free Apple ID via Xcode's automatic signing — no paid account required. However, TestFlight distribution, App Store submission, and push notifications all require the $99/year Apple Developer Program membership. For this app, a real device is strongly recommended over the simulator anyway to test the full Google Mobile Ads lifecycle.

How do I add this to the App Store?

Archive your app in Xcode (Product → Archive), then use the Organizer window to validate and upload to App Store Connect. From there, complete the app's metadata (name, description, screenshots, age rating, privacy nutrition labels), attach a build, and submit for review. First submissions typically take two to five business days for review. Soarias automates the upload, screenshot generation, and metadata prefill steps.

How do I handle duplicate ingredients when the user adds the same recipe twice to the shopping list?

The addIngredientsToShoppingList() function in Step 4 already checks for existing item names before inserting. For more robust handling — especially if ingredient names vary by capitalisation or trailing spaces — normalise names with .trimmingCharacters(in: .whitespaces).lowercased() before the set lookup. A future enhancement could also merge quantities (e.g. add 200g + 200g = 400g) by parsing the quantity string with a number formatter, though that requires handling mixed units carefully.

Last reviewed: 2026-05-12 by the Soarias team.

```