```html How to Build an EPUB Reader App in SwiftUI (2026)

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.

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

Prerequisites

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

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.

```