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.

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

Prerequisites

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

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.