```html How to Build an Anime Tracker App in SwiftUI (2026)

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.

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

Prerequisites

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

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.

```