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

How to Build a Movie Tracker App in SwiftUI

A Movie Tracker app lets users log films they've seen, assign star ratings, and browse a personal watchlist — built for film fans who want a private, offline-first log without subscription bloat. It suits indie developers looking for a polished, well-scoped first app on the App Store.

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

Prerequisites

Architecture overview

The app uses SwiftData as the local persistence layer with a single Movie model storing TMDB IDs, poster paths, ratings, and watched dates. URLSession handles TMDB search calls inside a Swift actor, while AsyncImage loads poster thumbnails lazily in list rows. All state flows through the SwiftData model context — no separate ObservableObject view models are needed at this scale.

MovieTracker/
├── MovieTrackerApp.swift       // modelContainer setup
├── Models/
│   └── Movie.swift             // @Model
├── Views/
│   ├── MovieListView.swift     // @Query list + filter picker
│   ├── MovieDetailView.swift   // @Bindable ratings + toggle
│   └── SearchMovieView.swift   // URLSession + AsyncImage results
├── Services/
│   └── TMDBService.swift       // actor, async search
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Define the SwiftData @Model class that persists each film's TMDB metadata, watched state, rating, and a computed poster URL.

import SwiftData
import Foundation

@Model
final class Movie {
    var tmdbID: Int
    var title: String
    var posterPath: String?
    var overview: String
    var releaseYear: String
    var isWatched: Bool
    var rating: Int          // 0 = unrated, 1–5 stars
    var watchedDate: Date?
    var addedDate: Date

    var posterURL: URL? {
        guard let path = posterPath else { return nil }
        return URL(string: "https://image.tmdb.org/t/p/w342\(path)")
    }

    init(tmdbID: Int, title: String, posterPath: String? = nil,
         overview: String = "", releaseYear: String = "") {
        self.tmdbID      = tmdbID
        self.title       = title
        self.posterPath  = posterPath
        self.overview    = overview
        self.releaseYear = releaseYear
        self.isWatched   = false
        self.rating      = 0
        self.addedDate   = .now
    }
}

2. Core UI — movie list

Build the main view with a @Query-driven list and a segmented picker to filter all / watched / unwatched movies.

enum MovieFilter: String, CaseIterable, Identifiable {
    case all, watched, unwatched
    var id: Self { self }
    var label: String { rawValue.capitalized }
}

struct MovieListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Movie.addedDate, order: .reverse) private var movies: [Movie]
    @State private var showSearch = false
    @State private var filter: MovieFilter = .all

    var filtered: [Movie] {
        switch filter {
        case .all:       return movies
        case .watched:   return movies.filter(\.isWatched)
        case .unwatched: return movies.filter { !$0.isWatched }
        }
    }

    var body: some View {
        NavigationStack {
            List(filtered) { movie in
                NavigationLink(destination: MovieDetailView(movie: movie)) {
                    MovieRowView(movie: movie)
                }
            }
            .navigationTitle("My Movies")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Add", systemImage: "plus") { showSearch = true }
                }
            }
            .safeAreaInset(edge: .top) {
                Picker("Filter", selection: $filter) {
                    ForEach(MovieFilter.allCases) { Text($0.label).tag($0) }
                }.pickerStyle(.segmented).padding(.horizontal).padding(.top, 4)
            }
            .sheet(isPresented: $showSearch) { SearchMovieView() }
        }
    }
}

3. Core feature — watched status and star ratings

Use @Bindable to write rating and watched changes directly back to the SwiftData model with no extra save call.

struct MovieDetailView: View {
    @Bindable var movie: Movie

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                AsyncImage(url: movie.posterURL) { img in
                    img.resizable().aspectRatio(2/3, contentMode: .fit)
                } placeholder: {
                    RoundedRectangle(cornerRadius: 12).fill(.quaternary)
                        .aspectRatio(2/3, contentMode: .fit)
                }
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .frame(maxHeight: 300)

                Toggle("Watched", isOn: $movie.isWatched)
                    .onChange(of: movie.isWatched) {
                        movie.watchedDate = movie.isWatched ? .now : nil
                        if !movie.isWatched { movie.rating = 0 }
                    }

                if movie.isWatched {
                    StarRatingView(rating: $movie.rating)
                }
                Text(movie.overview).font(.body).foregroundStyle(.secondary)
            }.padding()
        }
        .navigationTitle(movie.title)
        .navigationBarTitleDisplayMode(.large)
    }
}

struct StarRatingView: View {
    @Binding var rating: Int
    var body: some View {
        HStack(spacing: 6) {
            ForEach(1...5, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .foregroundStyle(star <= rating ? .yellow : .secondary)
                    .font(.title2)
                    .onTapGesture { rating = star }
            }
        }
    }
}

4. Privacy Manifest

Add PrivacyInfo.xcprivacy to your app target — App Store upload rejects apps that make network requests without declaring API usage types.

<?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: one-time purchase

Use StoreKit 2's Product.purchase() API to gate pro features — a home screen widget, export to CSV, or unlimited custom lists — behind a single non-consumable IAP. Define the product in App Store Connect with type "Non-Consumable", then call try await product.purchase() and listen to Transaction.updates to unlock the feature immediately. On each app launch, call Transaction.currentEntitlements to restore the purchase without prompting — this keeps the "Restore Purchases" button optional. No backend required for a simple one-time unlock.

Shipping this faster with Soarias

Soarias scaffolds the full project structure above from a single prompt — SwiftData container wiring, TMDBService actor boilerplate, the Privacy Manifest with correct API types, and a fastlane lane that captures App Store screenshots on a simulator automatically. It also writes the App Store Connect listing (description, keywords, screenshots for all required device sizes) and submits via the ASC API, bypassing the Xcode Organizer flow entirely.

For an intermediate project like this Movie Tracker, most developers spend two to three hours on SwiftData migration config, fastlane setup, and assembling ASC metadata before a single review is submitted. Soarias compresses that into one command, so you stay focused on the poster grid, rating UX, and TMDB attribution — the things reviewers and users actually notice.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. A $99/year Apple Developer Program membership is required to distribute on TestFlight or the App Store. You can build and run on a personal device for free using a free Apple ID, but you cannot submit for review or invite external testers without the paid membership.

How do I submit this to the App Store?

Archive your build in Xcode (Product → Archive), upload via Xcode Organizer or xcrun altool / fastlane, then complete the App Store Connect listing — screenshots for all required device sizes, a description, privacy nutrition labels, and an age rating — before clicking Submit for Review. First-time submissions typically take 24–48 hours for a decision.

Do I need permission from TMDB to ship a Movie Tracker on the App Store?

No explicit written permission is needed for the free API tier — commercial use is allowed under TMDB's terms as long as you display the required attribution notice and link to themoviedb.org in your app. Apps with high API traffic should review TMDB's commercial licensing options. Never ship without the attribution string; TMDB has revoked API keys for apps that omit it.

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

```