How to Build an EPUB Reader App in SwiftUI
An EPUB reader app lets users import and read local ebook files with a clean, distraction-free interface. It's built for readers who want an offline-first library without cloud lock-in or subscription fees.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Familiarity with
UIViewRepresentable— the WKWebView wrapper lives here - epub.js bundled as a local HTML file in your app target (open-source, MIT licensed)
Architecture overview
SwiftData stores book metadata — title, author, relative file path, cover image data, and the current reading position as an EPUB Canonical Fragment Identifier (CFI) string — and persists everything across launches automatically. When a user picks a file, the document importer copies it into the app's Documents directory and a background EPUBParser extracts the cover and spine. The reader view wraps WKWebView in a UIViewRepresentable; epub.js runs inside a locally bundled HTML page and handles EPUB decompression, pagination, and CFI navigation entirely in the web layer. PDFKit handles any .pdf files imported alongside EPUBs.
EPUBReader/ ├── App/ │ └── EPUBReaderApp.swift ├── Models/ │ └── Book.swift # @Model: metadata + CFI position ├── Views/ │ ├── LibraryView.swift # adaptive grid + fileImporter │ ├── BookTileView.swift # cover art + progress ring │ └── ReaderView.swift # WKWebView wrapper ├── Services/ │ └── EPUBParser.swift # unzip + cover extraction (async) └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model that records each book's file path, CFI reading position, and cover image so the library renders from disk without re-parsing the EPUB on every launch.
import SwiftData
import Foundation
@Model
final class Book {
var id: UUID = UUID()
var title: String
var author: String
var filePath: String // relative to app Documents dir
var coverImageData: Data?
var currentCFI: String = "" // EPUB Canonical Fragment Identifier
var totalLocations: Int = 0
var currentLocation: Int = 0
var dateAdded: Date = Date.now
var lastOpened: Date?
var progress: Double {
guard totalLocations > 0 else { return 0 }
return Double(currentLocation) / Double(totalLocations)
}
var resolvedFileURL: URL? {
let docs = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent(filePath)
}
init(title: String, author: String, filePath: String) {
self.title = title
self.author = author
self.filePath = filePath
}
}
2. Library UI
Build an adaptive cover grid with a fileImporter sheet that lets users pull EPUBs and PDFs from Files.app; copying each file into Documents makes the path stable across iOS app updates.
struct LibraryView: View {
@Query(sort: \Book.dateAdded, order: .reverse) private var books: [Book]
@Environment(\.modelContext) private var context
@State private var isImporting = false
@State private var selectedBook: Book?
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 16)]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(books) { book in
BookTileView(book: book)
.onTapGesture { selectedBook = book }
}
}
.padding()
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add", systemImage: "plus") { isImporting = true }
}
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.epub, .pdf],
allowsMultipleSelection: true
) { result in
Task { await handleImport(result) }
}
.fullScreenCover(item: $selectedBook) { ReaderView(book: $0) }
}
}
}
3. EPUB rendering with WebKit
Wrap WKWebView in a UIViewRepresentable that loads the locally bundled epub.js reader HTML, then calls into JavaScript to open the book file and restore the saved CFI position.
import WebKit
struct EPUBWebView: UIViewRepresentable {
let book: Book
@Binding var currentCFI: String
func makeUIView(context: Context) -> WKWebView {
let prefs = WKWebpagePreferences()
prefs.allowsContentJavaScript = true
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences = prefs
let wv = WKWebView(frame: .zero, configuration: config)
wv.navigationDelegate = context.coordinator
loadReader(into: wv)
return wv
}
func updateUIView(_ wv: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
private func loadReader(into wv: WKWebView) {
guard let readerURL = Bundle.main.url(forResource: "reader",
withExtension: "html"),
let bookURL = book.resolvedFileURL else { return }
wv.loadFileURL(readerURL,
allowingReadAccessTo: bookURL.deletingLastPathComponent())
}
class Coordinator: NSObject, WKNavigationDelegate {
let parent: EPUBWebView
init(_ p: EPUBWebView) { parent = p }
func webView(_ wv: WKWebView, didFinish _: WKNavigation!) {
let path = parent.book.filePath
let cfi = parent.currentCFI
wv.evaluateJavaScript("openBook('\(path)', '\(cfi)')")
}
}
}
4. Privacy Manifest
Add PrivacyInfo.xcprivacy to your app target — App Store Connect rejects EPUB readers that access UserDefaults (epub.js uses localStorage, which maps to it) or file timestamps without declared reason codes.
<?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><string>CA92.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Common pitfalls
- Loading the EPUB file directly in WKWebView: EPUB files are ZIP archives. WKWebView cannot open them raw — use epub.js to decompress and parse the content inside the web layer, or unzip to a temp directory first with
ZipFoundation. - Forgetting security-scoped resource access: When a user picks a file via
fileImporter, callurl.startAccessingSecurityScopedResource()before copying it to Documents and release it immediately after. Skipping this causes silent read failures on every subsequent app launch. - Cover extraction blocking the main thread: Parsing the EPUB spine XML synchronously freezes the UI for files over ~5 MB. Run
EPUBParserinside a detachedTaskand update the@Modelon the@MainActor. - App Store review: DRM-protected content: Reviewers will send a detailed request for information if your app description implies it can open Adobe DRM-protected EPUBs. State "DRM-free EPUB files only" in your App Store description and metadata to prevent a multi-week back-and-forth.
- Missing Privacy Manifest for epub.js localStorage: epub.js persists rendering state via
localStorage, which the iOS runtime maps toNSUserDefaultsAPIs. Without theCA92.1reason code in your manifest, the binary upload to ASC will fail validation.
Adding monetization: One-time purchase
Implement a non-consumable IAP using StoreKit 2. Define a product like com.yourapp.fullaccess in App Store Connect, then gate premium features — custom reading themes, font controls, unlimited library size — behind a Transaction.currentEntitlement(for:) check at app launch. Because this is a permanent unlock, users expect it to restore automatically on new devices; wire a "Restore Purchase" button that calls AppStore.sync() in your settings screen. Attach the IAP to your first submission in App Store Connect before submitting for review — the reviewer will attempt to purchase it, and missing IAP metadata causes immediate rejection.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model and UIViewRepresentable wrapper from your app description, auto-generates PrivacyInfo.xcprivacy with the correct UserDefaults and file-timestamp reason codes for epub.js, sets up fastlane lanes for both TestFlight and production builds, and drives the full App Store Connect submission — including attaching the non-consumable IAP before review, which is the step that most first-time submitters miss and costs a full rejection cycle.
For an intermediate app like this EPUB reader, project scaffolding, entitlement configuration, Privacy Manifest, and fastlane setup typically consume two to three days. Soarias compresses that to under an hour, so your week is spent building the actual reading experience — CFI position restoration, custom themes, bookmarks, and font sizing — rather than untangling Xcode build settings and App Store Connect forms.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free Apple ID lets you sideload to your own device via Xcode, but the $99/year Apple Developer Program membership is required to distribute via TestFlight or publish on the App Store.
How do I submit this to the App Store?
Archive your build in Xcode (Product → Archive), then upload via Organizer or fastlane deliver. In App Store Connect, complete the metadata, upload screenshots, attach your non-consumable IAP, and submit for review. First-time reader-app submissions typically take two to four days to clear review.
Can my app open DRM-protected EPUB files?
Technically you can import any EPUB file, but decrypting Adobe DRM-protected content requires licensing Adobe's RMSDK, which is a commercial SDK with a non-trivial integration cost. Nearly all indie EPUB readers state "DRM-free files only" in their App Store description to keep the review process clean and avoid legal ambiguity.
Last reviewed: 2026-05-12 by the Soarias team.