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.
Prerequisites
- Mac with Xcode 16 or later installed
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Comfortable with Swift 5.10 and basic SwiftUI layouts (VStack, List, NavigationStack)
- Familiarity with SwiftData or Core Data concepts (we use SwiftData throughout)
- If loading remote recipe images: basic understanding of async/await and URLSession
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
- Storing Ingredient as a separate @Model when a Codable struct is enough. If you define a child
@Modelfor ingredients, you need to manage the cascade delete relationship manually. ACodablestruct stored as an array in the parent is simpler and avoids the common mistake of leaking orphaned rows when a recipe is deleted. - AsyncImage layout jumping during load. Without a fixed frame before the
AsyncImageclosure, rows resize when the image arrives and produce jarring scroll reflows. Always set.frame(width:height:)on theAsyncImageitself, not just on the image phase. - Mutating SwiftData models on a background thread. Always perform inserts and deletes on the main actor's
ModelContext. Background fetches viaModelActorrequire a separate context — mixing contexts causes crashes that are difficult to reproduce. - App Store rejection for missing ad SDK privacy manifest. Google Mobile Ads 11+ ships its own
PrivacyInfo.xcprivacyinside the XCFramework, but you still need your app-level manifest. Reviewers check both. Use Xcode's "Privacy Report" tool (Product → Generate Privacy Report) before submission to catch gaps. - Test ad unit IDs left in production builds. Shipping with Google's test ad unit IDs triggers a policy violation. Gate the ad unit ID behind a build configuration flag — use
#if DEBUGor a separate xcconfig value forAD_UNIT_ID.
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.