How to Build a Manga Reader App in SwiftUI

A Manga Reader app lets users build a personal library, browse chapters, and read pages with smooth swipe navigation — all stored offline on device using SwiftData. It's built for fans who want a clean, distraction-free reading experience they fully control.

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

Prerequisites

Architecture overview

The app uses SwiftData for offline library persistence — Manga and Chapter are @Model classes linked by a cascade-delete relationship so removing a manga automatically removes all its chapters. The library layer is driven by a @Query in MangaLibraryView, which means any SwiftData insert or delete automatically re-renders the grid with no manual notification code. An @Observable view model handles search and read-filter state without touching the persistence layer at all. For cover art and chapter pages, AsyncImage handles network fetching with built-in URLSession caching. The reader uses TabView with PageTabViewStyle to deliver native horizontal swipes, keeping only a few pages in memory at a time.

MangaReaderApp/
├── MangaReaderApp.swift       # App entry, ModelContainer setup
├── Models/
│   ├── Manga.swift            # @Model: title, coverURL, chapters[]
│   └── Chapter.swift          # @Model: pageURLs[], isRead, number
├── Views/
│   ├── MangaLibraryView.swift # @Query grid, search bar, nav stack
│   ├── MangaCoverCell.swift   # AsyncImage cover tile
│   ├── MangaDetailView.swift  # Chapter list → reader navigation
│   ├── MangaReaderView.swift  # PageTabViewStyle full-screen reader
│   └── AddMangaView.swift     # Add manga form
├── ViewModels/
│   └── LibraryViewModel.swift # @Observable filter + search
├── Services/
│   └── MangaService.swift     # async/await API integration
└── PrivacyInfo.xcprivacy      # Required for App Store (step 7)

Step-by-step

1. Project setup

In Xcode 16, create a new iOS App project, choose SwiftUI for the interface, and select SwiftData for storage. Wire the ModelContainer at the app entry point so the entire view hierarchy shares one database context. Passing both Manga.self and Chapter.self here ensures both schemas are created in the same SQLite store.

// MangaReaderApp.swift
import SwiftUI
import SwiftData

@main
struct MangaReaderApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Manga.self, Chapter.self])
    }
}

// ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        MangaLibraryView()
    }
}

#Preview {
    ContentView()
        .modelContainer(for: [Manga.self, Chapter.self], inMemory: true)
}

2. Data model

Define two @Model classes: Manga holds library metadata and owns a cascade-delete relationship to its Chapter array. Storing pageURLs as [String] directly on Chapter avoids a third model class — SwiftData persists arrays of value types natively in iOS 17+.

// Models/Manga.swift
import SwiftData
import Foundation

@Model
final class Manga {
    var id: UUID
    var title: String
    var author: String
    var coverURL: String
    var synopsis: String
    var lastReadChapter: Int
    var dateAdded: Date
    @Relationship(deleteRule: .cascade) var chapters: [Chapter]

    init(
        title: String,
        author: String,
        coverURL: String,
        synopsis: String = ""
    ) {
        self.id = UUID()
        self.title = title
        self.author = author
        self.coverURL = coverURL
        self.synopsis = synopsis
        self.lastReadChapter = 0
        self.dateAdded = .now
        self.chapters = []
    }
}

// Models/Chapter.swift
import SwiftData

@Model
final class Chapter {
    var id: UUID
    var number: Int
    var title: String
    var pageURLs: [String]
    var isRead: Bool

    init(number: Int, title: String, pageURLs: [String] = []) {
        self.id = UUID()
        self.number = number
        self.title = title
        self.pageURLs = pageURLs
        self.isRead = false
    }
}

3. Library view

The library is a two-column adaptive grid driven by a @Query that automatically re-renders whenever SwiftData changes. Each cover tile handles AsyncImage's loading, success, and failure phases explicitly — this keeps the grid responsive even on slow connections. The .searchable modifier wires into the view model's search text with no extra plumbing.

// Views/MangaLibraryView.swift
import SwiftUI
import SwiftData

struct MangaLibraryView: View {
    @Query(sort: \Manga.dateAdded, order: .reverse) private var mangas: [Manga]
    @Environment(\.modelContext) private var context
    @State private var viewModel = LibraryViewModel()
    @State private var showAddSheet = false

    private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(viewModel.filtered(mangas)) { manga in
                        NavigationLink(value: manga) {
                            MangaCoverCell(manga: manga)
                        }
                        .buttonStyle(.plain)
                    }
                }
                .padding()
            }
            .searchable(text: $viewModel.searchText, prompt: "Search library")
            .navigationTitle("My Library")
            .navigationDestination(for: Manga.self) { manga in
                MangaDetailView(manga: manga)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") {
                        showAddSheet = true
                    }
                }
            }
            .sheet(isPresented: $showAddSheet) {
                AddMangaView()
            }
        }
    }
}

struct MangaCoverCell: View {
    let manga: Manga

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            AsyncImage(url: URL(string: manga.coverURL)) { phase in
                switch phase {
                case .empty:
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color.secondary.opacity(0.15))
                        .overlay { ProgressView() }
                case .success(let image):
                    image.resizable().scaledToFill()
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                case .failure:
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color.secondary.opacity(0.15))
                        .overlay {
                            Image(systemName: "book.closed")
                                .foregroundStyle(.secondary)
                        }
                @unknown default:
                    EmptyView()
                }
            }
            .frame(height: 210)
            .clipShape(RoundedRectangle(cornerRadius: 10))
            .shadow(radius: 2, y: 1)

            Text(manga.title)
                .font(.caption.bold())
                .lineLimit(2)
                .foregroundStyle(.primary)

            Text("\(manga.chapters.count) ch · ch.\(manga.lastReadChapter) read")
                .font(.caption2)
                .foregroundStyle(.secondary)
        }
    }
}

#Preview {
    MangaLibraryView()
        .modelContainer(for: [Manga.self, Chapter.self], inMemory: true)
}

4. Reader view (core feature)

The heart of the app is a full-screen reader using TabView with .tabViewStyle(.page(indexDisplayMode: .never)) for native horizontal page swipes. Controls overlay fades in and out on tap to keep focus on the artwork. When the reader is dismissed, the chapter is marked as read. Keep each page as a direct TabView child — wrapping in any container view defeats the lazy loading.

// Views/MangaReaderView.swift
import SwiftUI
import SwiftData

struct MangaReaderView: View {
    @Bindable var chapter: Chapter
    @Environment(\.dismiss) private var dismiss
    @State private var currentPage = 0
    @State private var isControlsVisible = true

    var body: some View {
        ZStack(alignment: .top) {
            Color.black.ignoresSafeArea()

            TabView(selection: $currentPage) {
                ForEach(chapter.pageURLs.indices, id: \.self) { index in
                    AsyncImage(url: URL(string: chapter.pageURLs[index])) { phase in
                        switch phase {
                        case .empty:
                            ProgressView()
                                .tint(.white)
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        case .success(let image):
                            image
                                .resizable()
                                .scaledToFit()
                        case .failure:
                            Image(systemName: "exclamationmark.triangle.fill")
                                .foregroundStyle(.white.opacity(0.5))
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        @unknown default:
                            EmptyView()
                        }
                    }
                    .tag(index)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea()
            .onTapGesture {
                withAnimation(.easeInOut(duration: 0.2)) {
                    isControlsVisible.toggle()
                }
            }

            if isControlsVisible {
                HStack {
                    Button {
                        chapter.isRead = true
                        dismiss()
                    } label: {
                        Image(systemName: "chevron.left")
                            .padding(10)
                            .background(.ultraThinMaterial, in: Circle())
                    }
                    Spacer()
                    Text("\(currentPage + 1) / \(chapter.pageURLs.count)")
                        .font(.caption.bold())
                        .padding(.horizontal, 12)
                        .padding(.vertical, 6)
                        .background(.ultraThinMaterial, in: Capsule())
                }
                .foregroundStyle(.white)
                .padding()
                .transition(.opacity)
            }
        }
        .navigationBarHidden(true)
        .statusBarHidden(!isControlsVisible)
        .onDisappear {
            chapter.isRead = true
        }
    }
}

#Preview {
    let chapter = Chapter(
        number: 1,
        title: "The Beginning",
        pageURLs: [
            "https://picsum.photos/seed/page1/400/600",
            "https://picsum.photos/seed/page2/400/600",
            "https://picsum.photos/seed/page3/400/600"
        ]
    )
    return MangaReaderView(chapter: chapter)
}

5. Persistence and state

An @Observable view model owns search text and read-filter state, keeping that logic out of both the view and the model layer. Because @Observable tracks only the properties a given view reads, SwiftUI avoids unnecessary redraws as the library grows — unlike @ObservableObject, which notifies on every @Published change regardless of which property changed.

// ViewModels/LibraryViewModel.swift
import Foundation
import Observation

@Observable
final class LibraryViewModel {
    var searchText = ""
    var selectedFilter: ReadFilter = .all

    enum ReadFilter: String, CaseIterable, Identifiable {
        case all       = "All"
        case reading   = "Reading"
        case completed = "Completed"
        var id: String { rawValue }
    }

    func filtered(_ mangas: [Manga]) -> [Manga] {
        mangas.filter { manga in
            let matchesSearch = searchText.isEmpty
                || manga.title.localizedCaseInsensitiveContains(searchText)
                || manga.author.localizedCaseInsensitiveContains(searchText)

            let matchesFilter: Bool
            switch selectedFilter {
            case .all:
                matchesFilter = true
            case .reading:
                matchesFilter = manga.lastReadChapter > 0
                    && manga.lastReadChapter < manga.chapters.count
            case .completed:
                matchesFilter = !manga.chapters.isEmpty
                    && manga.lastReadChapter >= manga.chapters.count
            }
            return matchesSearch && matchesFilter
        }
    }
}

6. Networking — fetching chapter pages

Integrate with a public manga API to pull chapter page URLs on demand. MangaDex provides a free, documented CDN proxy endpoint. Use async/await with URLSession and write the fetched URLs back into SwiftData so the reader works offline after the first load. Always mutate SwiftData models on the main actor.

// Services/MangaService.swift
import Foundation

struct MangaService {
    static let shared = MangaService()
    private init() {}

    // Returns CDN page image URLs for a given MangaDex chapter ID.
    func fetchPageURLs(chapterId: String) async throws -> [String] {
        let url = URL(string: "https://api.mangadex.org/at-home/server/\(chapterId)")!
        var request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
        request.setValue("MangaReaderApp/1.0", forHTTPHeaderField: "User-Agent")

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        let decoded = try JSONDecoder().decode(AtHomeResponse.self, from: data)
        let base = decoded.baseUrl
        let hash = decoded.chapter.hash
        // Use .data for full quality; .dataSaver for low-bandwidth mode.
        return decoded.chapter.data.map { "\(base)/data/\(hash)/\($0)" }
    }
}

// MARK: - Decodable response models
private struct AtHomeResponse: Decodable {
    let baseUrl: String
    let chapter: ChapterPages

    struct ChapterPages: Decodable {
        let hash: String
        let data: [String]
        let dataSaver: [String]
    }
}

// MARK: - Usage in a view
// Call inside a .task modifier so it cancels automatically on disappear:
//
// .task {
//     do {
//         let urls = try await MangaService.shared.fetchPageURLs(chapterId: externalId)
//         await MainActor.run { chapter.pageURLs = urls }
//     } catch {
//         // show error state
//     }
// }

7. Privacy Manifest

Since Xcode 15.3, Apple requires a PrivacyInfo.xcprivacy file for any app using certain system APIs. A Manga Reader touches UserDefaults (via SwiftData internals) and makes network requests — both require declared reasons. Add the file to your app target in Xcode via File › New File › App Privacy. Missing this file causes automated rejection before a human reviewer ever sees your app.

<!-- PrivacyInfo.xcprivacy — add to your main app target -->
<?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>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <!-- CA92.1: Access own app's defaults -->
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
    <key>NSPrivacyCollectedDataTypes</key>
    <array/>
    <key>NSPrivacyTracking</key>
    <false/>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2 to offer a monthly or annual "Premium" tier that unlocks unlimited library size, offline chapter downloads, and an ad-free experience. Define your subscription products in App Store Connect under In-App Purchases, then load them at launch with Product.products(for:). Gate premium features behind a check on Transaction.currentEntitlement(for:) — never trust a locally stored boolean alone, as it won't reflect cancellations or billing failures. Set up a Task listening to Transaction.updates in your app entry point so renewals, family-sharing changes, and refunds update your app's state in real time. StoreKit 2 handles server-side receipt validation automatically, so no custom backend is needed for basic subscription management.

Shipping this faster with Soarias

Soarias automates the setup steps that consume most of an intermediate project's calendar time: it scaffolds the Xcode project with the correct SwiftData entitlements already wired, generates your PrivacyInfo.xcprivacy by detecting your SwiftData and URLSession usage, configures fastlane with match for code signing, and submits your archive to App Store Connect — all from a single guided flow running locally on your Mac.

For an intermediate app at this complexity level, most developers spend a full day on project configuration, provisioning profiles, signing, screenshot creation, and App Store Connect metadata before a single line of feature code ships. Soarias compresses that to under 30 minutes. The Privacy Manifest auto-generation alone eliminates the most common first-submission rejection reason for networking apps, letting you focus on what actually matters: the reading experience.

Related guides

FAQ

Does this work on iOS 16?

Not without significant changes. The code uses the @Observable macro and SwiftData, both of which require iOS 17+. To support iOS 16 you'd need to replace @Observable with @ObservableObject/@Published and swap SwiftData for Core Data — a meaningful rewrite. Given iOS 17 adoption exceeded 90% by early 2025, targeting iOS 17+ is the pragmatic choice for new apps in 2026.

Do I need a paid Apple Developer account to test?

No — you can build and run on a personal device or the Simulator with a free Apple ID. The paid Apple Developer Program ($99/year) is required only when you want to distribute via TestFlight or submit to the App Store. That said, a real device is strongly recommended for testing this app: full-screen swipe gestures and image rendering behave quite differently on device versus Simulator.

How do I add this to the App Store?

Create an App Store Connect record at appstoreconnect.apple.com, fill in all metadata, and upload at least one screenshot at 6.9" Super Retina XDR resolution (required since 2025). In Xcode, select Product › Archive, then click Distribute App in the Organizer window. Soarias handles all of this — screenshots, metadata, and submission — from one guided flow.

How do I prevent memory crashes on very long chapters?

For chapters with 100+ pages, TabView's built-in lazy loading handles most devices fine, but you can tighten memory further by implementing a custom paging solution: ScrollView + LazyHStack with manual URLSession image prefetching limited to the next 3–5 pages ahead of the current position. Also consider offering a "data saver" mode that fetches dataSaver page URLs from the API instead of full-resolution images.

Last reviewed: 2026-05-12 by the Soarias team.