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

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.

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

Prerequisites

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

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.

```