How to Build a Meal Planner App in SwiftUI
A meal planner app lets users schedule breakfast, lunch, and dinner across a rolling 7-day calendar by drawing from a personal recipe library. It's built for health-conscious individuals and busy families who want to cut daily decision fatigue and simplify grocery shopping.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Familiarity with SwiftData — this app uses
@Modeland@Querythroughout - StoreKit 2 basics for the subscription paywall
Architecture overview
Two SwiftData models do all the heavy lifting: Meal stores the recipe library and MealEntry binds a date, a meal type (breakfast/lunch/dinner), and an optional Meal reference. The main view fetches all entries with @Query and filters client-side by the visible week range. Week navigation is a single integer offset applied to the current week's Monday. A sheet view presents the picker and inserts a new MealEntry on selection — no separate view model required.
MealPlannerApp/ ├── Models/ │ ├── Meal.swift # @Model — recipe library │ └── MealEntry.swift # @Model — date + mealType + Meal ├── Views/ │ ├── WeeklyPlanView.swift # root week grid │ ├── MealTypeRow.swift # one row per meal type │ ├── MealPickerSheet.swift # assign meal to a slot │ └── MealLibraryView.swift # CRUD for saved meals └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define two @Model classes so SwiftData persists meals and scheduled slots, with a nullify delete rule to preserve entries when a recipe is removed.
import SwiftData
import Foundation
@Model
final class Meal {
var name: String
var ingredients: [String]
var prepTime: Int // minutes
var category: String // "breakfast" | "lunch" | "dinner"
var notes: String
init(name: String, category: String = "dinner", prepTime: Int = 30) {
self.name = name
self.ingredients = []
self.prepTime = prepTime
self.category = category
self.notes = ""
}
}
@Model
final class MealEntry {
var date: Date
var mealType: String
@Relationship(deleteRule: .nullify) var meal: Meal?
init(date: Date, mealType: String) {
self.date = date
self.mealType = mealType
}
}
2. Core UI — weekly grid
Compute the seven dates of the current week from a Monday anchor, then pass them to each meal-type row — forward/back buttons shift the weekOffset state to navigate weeks.
struct WeeklyPlanView: View {
@Query(sort: \MealEntry.date) private var entries: [MealEntry]
@State private var weekOffset = 0
private var weekDates: [Date] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let wd = cal.component(.weekday, from: today)
let monday = cal.date(
byAdding: .day,
value: -((wd + 5) % 7) + weekOffset * 7,
to: today
)!
return (0..<7).map { cal.date(byAdding: .day, value: $0, to: monday)! }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 2) {
WeekHeaderRow(dates: weekDates, offset: $weekOffset)
ForEach(["Breakfast", "Lunch", "Dinner"], id: \.self) { type in
MealTypeRow(dates: weekDates, mealType: type, entries: entries)
}
}.padding(.horizontal)
}
.navigationTitle("Meal Planner")
}
}
}
3. Weekly meal scheduling
The picker sheet is the heart of scheduling — it queries saved meals, lets the user tap one, and inserts a MealEntry linked to that date and meal type into the model context.
struct MealPickerSheet: View {
let date: Date
let mealType: String
@Query private var meals: [Meal]
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(meals) { meal in
Button {
let cal = Calendar.current
let entry = MealEntry(
date: cal.startOfDay(for: date),
mealType: mealType
)
entry.meal = meal
context.insert(entry)
dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(meal.name)
Text("\(meal.prepTime) min · \(meal.category)")
.font(.caption).foregroundStyle(.secondary)
}
}.tint(.primary)
}
.navigationTitle("\(mealType) — \(date.formatted(.dateTime.weekday().month().day()))")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your app target — App Store review will reject any submission missing this file or with blank API reason codes.
<?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/>
<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
- Duplicate MealEntry insertions. Tapping a slot that already has a meal assigned will insert a second
MealEntryfor the same date and meal type. Before inserting, checkentries.first { sameDay && sameType }and update the existing entry'smealreference rather than creating a new one. - Week start locale mismatch. The Monday-anchored
(wd + 5) % 7formula breaks for locales where Sunday is the first weekday. RespectCalendar.current.firstWeekdayand compute the offset dynamically. - Unbounded @Query performance. Fetching every
MealEntryever created and filtering client-side works fine for personal data but will slow down over time. Add a#Predicatescoped to ±4 weeks from today. - Missing Privacy Manifest causes rejection. App Store review flags apps that access
UserDefaultsor file timestamps without declared reasons inPrivacyInfo.xcprivacy. Add it before your very first TestFlight build — not just before App Store submission. - Paywall too early triggers App Store rejection. Apple reviewers reject apps where no meaningful functionality is visible before a purchase screen. Show the full weekly grid on first launch; gate premium features (grocery list export, nutrition data) behind the subscription.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to load your auto-renewable subscription configured in App Store Connect. Gate premium features — grocery list generation, nutrition tracking, or unlimited saved meals — behind a Transaction.currentEntitlement(for:) check on launch and foreground. Present a SubscriptionStoreView (iOS 17+) as a sheet when a free-tier user hits a locked feature. Always configure a 7-day free trial in App Store Connect; Apple reviewers expect users to access meaningful functionality before any payment prompt, and a trial reduces that friction while keeping your conversion funnel intact.
Shipping this faster with Soarias
Soarias scaffolds the full SwiftData model layer from a schema description, generates your PrivacyInfo.xcprivacy from a guided checklist, configures fastlane Match for code signing, and pre-populates your App Store Connect listing with screenshot templates and keyword suggestions — so you skip the ASC web UI entirely. For a Meal Planner specifically, it also writes a StoreKit configuration file with a sample monthly subscription product wired up for simulator testing from day one.
Intermediate apps like this typically burn 2–3 days on setup: Xcode project configuration, provisioning profiles, Privacy Manifest research, and fastlane lane files. Soarias brings that overhead under an hour, giving you the full week to focus on the scheduling grid, recipe library UI, and subscription paywall that actually differentiate your app in the App Store.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free Apple ID lets you sideload the app onto your own device through Xcode, but distributing on TestFlight or submitting to the App Store requires the $99/year Apple Developer Program membership.
How do I submit this to the App Store?
Archive the app in Xcode via Product → Archive, then upload through the Organizer or fastlane deliver. Complete your App Store Connect listing — screenshots for all required device sizes, privacy nutrition labels, and age rating — then click Submit for Review. First submissions typically take 24–48 hours to review.
Can I sync the meal plan across a user's devices with CloudKit?
Yes — SwiftData supports CloudKit sync with minimal changes. Pass .cloudKitDatabase(.automatic) to your ModelContainer configuration and enable the CloudKit capability in Xcode. One gotcha: CloudKit requires every @Model property to be optional or have a default value, so design your schema with that constraint from the start rather than retrofitting it later.
Last reviewed: 2026-05-12 by the Soarias team.