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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Comfort with async/await — the reader fetches page image URLs over the network
- A real iOS device recommended: SwiftData performance and full-screen swipe gestures behave differently in Simulator
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
- Memory pressure in the reader. Loading all page images upfront causes crashes on older devices.
TabViewwithPageTabViewStylelazy-loads only the current page and its immediate neighbors — but only when each page is a direct child ofTabView. Wrapping pages in aGrouporForEachinside a container view defeats the lazy loading. - SwiftData mutation off the main actor. Appending chapters to
manga.chaptersfrom a backgroundTaskwithout@MainActorwill crash at runtime. Always useawait MainActor.run { ... }when writing back fetched data into SwiftData models. - Undeclared network access rejected by App Store review. If your app fetches chapter pages but the Privacy Manifest doesn't declare network data types, automated App Store review will flag it. Include your external domain in an App Transport Security declaration if you use HTTP instead of HTTPS.
- Linking to unlicensed content triggers App Store rejection. Apple reviewers test your app in their own region. If the manga API you integrate with hosts content unavailable in that region or lacks proper licensing, your app will be rejected. Use only properly licensed content sources or build a bring-your-own-server model.
- Missing
@unknown defaultin AsyncImage switch. Swift requires exhaustive switches onAsyncImagePhase. Omitting@unknown defaultgenerates a warning that becomes an error under strict concurrency — always include it even if the body is justEmptyView().
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.