How to Build a TV Show Tracker App in SwiftUI
A TV Show Tracker app lets users mark episodes as watched, follow upcoming air dates, and visualize their progress season by season — built for binge-watchers who juggle a dozen ongoing series at once.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Free TMDB API key (themoviedb.org) for show metadata and poster images
- StoreKit 2 familiarity for subscription gating
Architecture overview
SwiftData handles all local persistence via two @Model types — Show and Episode — accessed through view-level @Query macros that keep the UI automatically in sync. A thin TMDBService layer wraps URLSession with async/await, mapping JSON responses into model objects. AsyncImage handles poster loading without third-party libraries. A StoreKit 2 @Observable entitlement store gates premium features (sync, unlimited shows) at the view level.
TVShowTracker/ ├── Models/ │ ├── Show.swift # @Model — persisted show record + progress │ └── Episode.swift # @Model — per-episode watched state ├── Views/ │ ├── ShowListView.swift # root list, search, AsyncImage posters │ ├── ShowDetailView.swift │ └── EpisodeListView.swift # season grouping + watched toggle ├── Services/ │ └── TMDBService.swift # URLSession + Codable search & episode fetch └── TVShowTrackerApp.swift # ModelContainer + StoreKit setup
Step-by-step
1. Data model
Define Show and Episode as SwiftData @Model classes; the cascade-delete rule ensures removing a show also wipes all its episode records.
import SwiftData
@Model final class Show {
@Attribute(.unique) var id: Int
var title: String
var posterPath: String?
var totalEpisodes: Int
var nextAirDate: Date?
@Relationship(deleteRule: .cascade) var episodes: [Episode] = []
init(id: Int, title: String, posterPath: String? = nil, totalEpisodes: Int = 0) {
self.id = id; self.title = title
self.posterPath = posterPath; self.totalEpisodes = totalEpisodes
}
var posterURL: URL? {
posterPath.flatMap { URL(string: "https://image.tmdb.org/t/p/w185\($0)") }
}
var watchedCount: Int { episodes.filter(\.watched).count }
var progress: Double {
totalEpisodes > 0 ? Double(watchedCount) / Double(totalEpisodes) : 0
}
}
@Model final class Episode {
@Attribute(.unique) var id: Int
var title: String; var season: Int; var number: Int
var airDate: Date?; var watched: Bool = false; var watchedAt: Date?
init(id: Int, title: String, season: Int, number: Int, airDate: Date? = nil) {
self.id = id; self.title = title; self.season = season
self.number = number; self.airDate = airDate
}
}
2. Core UI — show list
A NavigationStack with @Query drives the list; AsyncImage loads TMDB posters lazily and ProgressView visualises watched progress inline.
struct ShowListView: View {
@Query(sort: \Show.title) private var shows: [Show]
@State private var searchText = ""
@State private var showingSearch = false
private var filtered: [Show] {
searchText.isEmpty ? shows
: shows.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List(filtered) { show in
NavigationLink(destination: ShowDetailView(show: show)) {
HStack(spacing: 12) {
AsyncImage(url: show.posterURL) { img in
img.resizable().aspectRatio(2/3, contentMode: .fit)
} placeholder: { Color.secondary.opacity(0.15) }
.frame(width: 46, height: 69).clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 5) {
Text(show.title).font(.headline)
ProgressView(value: show.progress).tint(.accentColor)
Text("\(show.watchedCount)/\(show.totalEpisodes) episodes")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
.searchable(text: $searchText, prompt: "Search your shows")
.navigationTitle("My Shows")
.toolbar { ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { showingSearch = true }
}}
.sheet(isPresented: $showingSearch) { ShowSearchSheet() }
}
}
}
3. Episode tracking
Group episodes into seasons, let users tap the circle icon to toggle watched state, and persist the timestamp so you can display a "watched on" date later.
struct EpisodeListView: View {
@Bindable var show: Show
@Environment(\.modelContext) private var context
private var bySeason: [Int: [Episode]] {
Dictionary(grouping: show.episodes, by: \.season)
}
var body: some View {
List {
ForEach(bySeason.keys.sorted(), id: \.self) { season in
Section {
ForEach(bySeason[season]!.sorted { $0.number < $1.number }) { ep in
HStack {
Image(systemName: ep.watched ? "checkmark.circle.fill" : "circle")
.foregroundStyle(ep.watched ? .green : .secondary)
.onTapGesture { toggle(ep) }
VStack(alignment: .leading, spacing: 2) {
Text("E\(ep.number) · \(ep.title)").font(.subheadline)
if let d = ep.airDate {
Text(d, style: .date).font(.caption).foregroundStyle(.secondary)
}
}
}
}
} header: {
HStack {
Text("Season \(season)")
Spacer()
Button("Mark all") {
bySeason[season]?.forEach { $0.watched = true; $0.watchedAt = .now }
try? context.save()
}.font(.caption)
}
}
}
}
}
private func toggle(_ ep: Episode) {
ep.watched.toggle(); ep.watchedAt = ep.watched ? .now : nil; try? context.save()
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your app target (File → New → Resource) — App Store Connect rejects submissions using URLSession without it.
<?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>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key><true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key><array/>
</dict>
</plist>
Common pitfalls
- Missing Privacy Manifest causes immediate rejection. App Store Review automatically flags any binary that calls
URLSessionwithout aPrivacyInfo.xcprivacyin the app target. Add it before your first archive — it cannot be patched after upload. - TMDB rate limits silently break search. The free tier allows 40 requests per 10 seconds. Debounce your search field to fire no more than once per 400 ms, and cache results in SwiftData so repeated lookups skip the network entirely.
- SwiftData cascade delete not configured. Omitting
deleteRule: .cascadeon theepisodesrelationship leaves orphan rows in the persistent store. You won't notice until a schema migration is needed and the counts no longer add up. - Progress bar stuck at 0%.
totalEpisodesmust be populated from the API response beforeprogresscomputes correctly. Guard the zero case in the computed property and show a spinner until the episode list loads. - Subscription not visible during App Store Review. Reviewers test in the sandbox environment. Include a sandbox Apple ID in your review notes and ensure your
Products.storekitconfiguration file is added to the Debug scheme — missing it meansProduct.products(for:)returns an empty set in review.
Adding monetization: Subscription
Use StoreKit 2's Product.subscribe API to offer a monthly or annual plan that unlocks multi-device sync via CloudKit, unlimited tracked shows (free tier: 5), and push notifications for new episode air dates. Define your subscription group in App Store Connect, create a Products.storekit configuration file for local sandbox testing, and wrap Transaction.currentEntitlement(for: productID) inside an @Observable class so any view in the hierarchy can reactively read the entitlement state. Gate the premium sheet behind an .overlay paywall rather than hiding navigation items entirely — App Review consistently approves the former and occasionally flags the latter as confusing.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model layer, TMDBService networking boilerplate, and PrivacyInfo.xcprivacy from a single prompt — steps 1 and 4 become zero-touch. It also generates your Fastfile and Deliverfile, captures App Store screenshots directly from Simulator, and pre-populates the required App Store Connect metadata (privacy policy URL, support URL, age rating, subscription review notes) before handing off to the upload.
At intermediate complexity, wiring up SwiftData migrations, the StoreKit configuration file, fastlane match for code signing, and App Store review notes typically costs a full day of setup. Soarias compresses that overhead to under an hour, so your week stays focused on the episode-tracking UX rather than submission plumbing.
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 sideload onto a personal device with a free account, but App Store submission and TestFlight distribution require enrollment.
How do I submit this to the App Store?
Archive in Xcode via Product → Archive, validate the build in the Organizer, then upload to App Store Connect. Fill in metadata, screenshots (all required device sizes), subscription pricing, and Privacy Nutrition Labels, then click Submit for Review. Expect a 24–48 hour review window for a new app.
Can I ship without a TMDB API key?
Yes. You can start with a fully manual model where users type in show names themselves — no network calls, no API key required, and App Store approval is unaffected. Adding the TMDB key (free registration at themoviedb.org) enables auto-populated posters and episode lists, but it is entirely optional for your MVP.
Last reviewed: 2026-05-12 by the Soarias team.