How to Build an Anime Tracker App in SwiftUI
An Anime Tracker lets users maintain a personal watchlist, log episode-by-episode progress, and categorize series as watching, completed, or dropped. It's built for fans who want a fast, offline-first alternative to web-based list services.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Familiarity with async/await — Jikan API calls use structured concurrency
- No third-party API key required; Jikan (api.jikan.moe) is free and open
Architecture overview
Anime entries live in a SwiftData store for fully offline access, populated by searching the Jikan API via a lightweight JikanService actor. The view layer is three screens: a filterable watchlist driven by @Query, a search sheet with AsyncImage covers, and a detail view with the episode stepper. SwiftData automatically propagates changes back to the list — no manual refresh needed.
AnimeTracker/ ├── Models/ │ └── AnimeEntry.swift # @Model + WatchStatus enum ├── Views/ │ ├── WatchlistView.swift # Segmented filter + list │ ├── AnimeDetailView.swift # Episode stepper + progress bar │ └── SearchView.swift # Jikan search + AsyncImage ├── Services/ │ └── JikanService.swift # URLSession @Observable actor └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define AnimeEntry as a SwiftData @Model so every watchlist mutation persists automatically without Core Data boilerplate.
import SwiftData
import Foundation
enum WatchStatus: String, Codable, CaseIterable {
case planToWatch = "Plan to Watch"
case watching = "Watching"
case completed = "Completed"
case dropped = "Dropped"
}
@Model
final class AnimeEntry {
var malID: Int
var title: String
var coverURL: String
var totalEpisodes: Int // 0 = ongoing / unknown
var watchedEpisodes: Int
var status: WatchStatus
var addedAt: Date
init(malID: Int, title: String, coverURL: String, totalEpisodes: Int) {
self.malID = malID
self.title = title
self.coverURL = coverURL
self.totalEpisodes = max(totalEpisodes, 0)
self.watchedEpisodes = 0
self.status = .planToWatch
self.addedAt = .now
}
}
2. Search with URLSession + AsyncImage
An @Observable service debounces keystrokes before hitting the Jikan API, cancelling in-flight tasks on each new character to stay inside the 3 req/sec rate limit.
@Observable
final class JikanService {
private(set) var results: [JikanAnime] = []
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel()
guard !query.isEmpty else { results = []; return }
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
var comps = URLComponents(string: "https://api.jikan.moe/v4/anime")!
comps.queryItems = [.init(name: "q", value: query),
.init(name: "limit", value: "12")]
guard let url = comps.url,
let (data, _) = try? await URLSession.shared.data(from: url),
let decoded = try? JSONDecoder().decode(JikanResponse.self, from: data)
else { return }
await MainActor.run { self.results = decoded.data }
}
}
}
struct JikanAnime: Decodable {
let mal_id: Int; let title: String
let images: Images; let episodes: Int?
struct Images: Decodable {
struct JPG: Decodable { let image_url: String }
let jpg: JPG
}
}
struct JikanResponse: Decodable { let data: [JikanAnime] }
3. Watchlist progress view
The detail view is the core feature: a Stepper increments watched episodes, a ProgressView reflects completion percentage, and watch status updates automatically when the final episode is logged.
struct AnimeDetailView: View {
@Bindable var entry: AnimeEntry
private var progress: Double {
guard entry.totalEpisodes > 0 else { return 0 }
return Double(entry.watchedEpisodes) / Double(entry.totalEpisodes)
}
private var episodeLabel: String {
let total = entry.totalEpisodes > 0 ? "\(entry.totalEpisodes)" : "?"
return "Ep \(entry.watchedEpisodes) / \(total)"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: URL(string: entry.coverURL)) { img in
img.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: { Color.secondary.opacity(0.15) }
.frame(maxWidth: .infinity, maxHeight: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
ProgressView(value: progress)
.tint(.accentColor).padding(.horizontal)
Stepper(
episodeLabel,
value: $entry.watchedEpisodes,
in: 0...max(entry.totalEpisodes, entry.watchedEpisodes)
)
.onChange(of: entry.watchedEpisodes) { _, new in
if new == entry.totalEpisodes, entry.totalEpisodes > 0 {
entry.status = .completed
} else if new > 0 { entry.status = .watching }
}
.padding(.horizontal)
Picker("Status", selection: $entry.status) {
ForEach(WatchStatus.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.menu).padding(.horizontal)
}.padding(.bottom, 32)
}
.navigationTitle(entry.title).navigationBarTitleDisplayMode(.inline)
}
}
4. Privacy Manifest
Add PrivacyInfo.xcprivacy to your app target — App Store Review has rejected apps missing this file since May 2024.
<?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
- Jikan rate limit (3 req/sec): Rapid typing without debounce returns 429 errors immediately. The 500 ms
Task.sleepin the snippet is the safe minimum; don't drop below 400 ms. - AsyncImage has no disk cache: Every cold launch re-downloads every cover image. Add a shared
URLCachewith a reasonable disk capacity at app startup to avoid visible flicker on large lists. - Ongoing series return
episodes: null: Jikan omits episode counts for airing shows. Always guard againsttotalEpisodes == 0before computing a progress ratio or you'll divide by zero. - SwiftData schema migration: Renaming or adding a field to
AnimeEntryafter shipping v1 requires aSchemaMigrationPlan. Omit it and existing users' stores crash on update. - App Store review — copyrighted artwork: Including character art or screenshots from specific titles in your App Store screenshots triggers rejection under guideline 5.2.1. Use your own UI mockups or generic placeholder art.
Adding monetization: One-time purchase
Use StoreKit 2 to offer a single non-consumable "Unlock Full Watchlist" product. Free users can add up to 10 entries; a one-time payment unlocks unlimited tracking. On each app launch, call Transaction.currentEntitlements to verify the purchase and update a @AppStorage("isPro") flag — StoreKit 2 validates receipts on-device, so there's no server to maintain. Handle the three Product.PurchaseResult cases (.success, .userCancelled, .pending) and present a restore button for users who reinstall.
Shipping this faster with Soarias
Soarias scaffolds the AnimeEntry SwiftData model and JikanService actor from a plain-English description, generates a correctly populated PrivacyInfo.xcprivacy, and configures fastlane lanes that capture simulator screenshots across all required iPhone sizes. App Store Connect setup — bundle ID registration, app category, age rating, and privacy nutrition labels — is handled without you leaving the terminal.
For an intermediate project like this one, most developers spend the better part of a day on ASC paperwork, fastlane wiring, and screenshot prep. Soarias compresses that to under an hour, so your full week stays focused on the watchlist UX, search quality, and the episode-progress interactions that actually differentiate your app.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The $99/year Apple Developer Program is required to distribute on TestFlight or the App Store. You can build and run on your own device for free using a personal team, but sharing the app with anyone else requires a paid enrollment.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then upload the build via Xcode Organizer or fastlane deliver. In App Store Connect, complete the required metadata, privacy nutrition labels, and screenshots, then click Submit for Review. First-time submissions typically take 24–48 hours.
Can I sync my watchlist with MyAnimeList or AniList?
Jikan is a read-only proxy for MyAnimeList — ideal for fetching metadata and search results, but it cannot write to a user's MAL account. For two-way sync you need the official MAL OAuth 2.0 API or AniList's GraphQL API. Both require a full OAuth login flow and additional privacy disclosures in your App Store listing, which meaningfully increases the project scope.
Last reviewed: 2026-05-12 by the Soarias team.