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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Free TMDB API key from themoviedb.org — needed for movie search and poster images
- TMDB requires visible attribution ("This product uses the TMDB API but is not endorsed or certified by TMDB") in your app; missing it risks API key revocation and App Store rejection
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
- Hardcoded TMDB API key in source. Reviewers and binary scanners can extract string literals from your app bundle. Store the key in an xcconfig file excluded from git, or proxy TMDB requests through your own server.
- AsyncImage has no disk cache. Poster images re-fetch on every scroll-off. For a list of 200+ films this becomes noticeable. Configure
URLCache.sharedcapacity or reach for the Nuke package before shipping. - SwiftData migration crash on update. Adding a non-optional property without a default to an existing
@Modelcauses a crash on devices with existing stores. Always supply a default value or mark new properties@Attribute(.optional). - Missing TMDB attribution causes App Store rejection. TMDB's API terms require a visible credit string in the UI. Reviewers have caught and rejected apps that omit it, and TMDB can revoke API access mid-review if they're notified.
- Star rating resets to 0 on relaunch. If you accidentally bind the rating to a
@Statelocal copy rather than the@Bindablemodel property, it never persists. Verify by force-quitting and relaunching during development.
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.