How I'd structure a scripture app that works on a plane: a read-only text database, fast search, user data that follows you between devices, read-aloud and a verse-of-the-day widget
The first app I ever published under my App Store developer name was a Bible reader. It went live on 20 April 2020, a day ahead of the next few apps I shipped that month, and today it's called Bible Study AI – Read Offline. It has grown a lot since then: reading plans, devotionals, a prayer journal, widgets and an AI study assistant. But the promise in the name has never changed. It works offline.
"Offline" sounds like a feature you tick off. It isn't. It's an architectural decision, and it shapes everything: where the text lives, how search works, where highlights and notes are saved, how sync behaves when the network comes back, and what you do on a device that has never been online since the app was installed.
This article walks through the architecture I'd recommend for this kind of app: a large, read-only text corpus plus a small amount of precious user data. Scripture is the example, but the same shape fits dictionaries, manuals, legal codes, recipe books and course material. All the code is Swift, and I've checked API names and availability against Apple's developer documentation. Where I'm describing the approach I'd use rather than the app's exact internals, I say so.
1. The problem: two kinds of data with opposite needs
A Bible app holds two very different kinds of data.
The text itself. All 66 books, in several translations; the app's listing mentions KJV, ASV, Webster and World English. That's tens of thousands of verses per translation. It's large, it never changes on the device, and it must be searchable in milliseconds. It belongs to you, the developer, not the user.
What the user creates. Highlights in different colours, notes on verses and chapters, bookmarks, reading-plan progress, prayer journal entries. It's small, it changes all the time, and it's the only data the user would be upset to lose. It belongs to them, and they expect it on their iPad as well as their iPhone.
The mistake I see most often is putting both in one store. Then every app update that fixes a typo in the text has to migrate a database full of user notes, and every sync pushes megabytes of scripture to iCloud for no reason.
So split them:
App bundle (read-only) User store (read-write, synced) ┌──────────────────────────┐ ┌──────────────────────────────┐ │ bible.sqlite │ │ Core Data + CloudKit │ │ translations, books, │ verse │ Highlight, Note, Bookmark, │ │ chapters, verses │◀─ id ────▶│ PlanProgress, Prayer │ │ verses_fts (FTS5) │ │ │ └──────────────────────────┘ └──────────────────────────────┘ The only thing that links them is a stable verse identifier. Get that right first.
2. A verse ID that will never change
A verse needs an identifier that's identical across translations, app versions and devices. I use a simple integer: book * 1_000_000 + chapter * 1_000 + verse. John 3:16 is book 43, so its ID is 43_003_016.
struct VerseRef: Hashable, Codable, Comparable { let book: Int // 1...66, canonical order let chapter: Int let verse: Int var id: Int { book * 1_000_000 + chapter * 1_000 + verse } init(book: Int, chapter: Int, verse: Int) { self.book = book; self.chapter = chapter; self.verse = verse } init(id: Int) { book = id / 1_000_000 chapter = (id / 1_000) % 1_000 verse = id % 1_000 } static func < (lhs: VerseRef, rhs: VerseRef) -> Bool { lhs.id < rhs.id } } Why an integer and not a string like "JHN.3.16"? It sorts correctly, ranges are cheap (BETWEEN 43003001 AND 43003999 is "all of John 3"), and it's a compact key in both SQLite and Core Data. A highlight saved against 43003016 shows up in KJV and in World English alike, because the ID describes the position, not the wording.
One caveat from experience: versification isn't identical in every tradition. A few translations number some verses differently. If you add a translation that disagrees, map it onto your canonical scheme when you build the database, never at runtime.
3. Shipping the text as a bundled, read-only SQLite database
I build the text database offline with a script, add bible.sqlite to the app target and open it read-only straight from the bundle. No copying to Documents, no migrations, no risk of corruption.
The schema is tiny:
CREATE TABLE verses ( translation TEXT NOT NULL, -- 'KJV', 'ASV', 'WEB', ... id INTEGER NOT NULL, -- VerseRef.id text TEXT NOT NULL, PRIMARY KEY (translation, id) ) WITHOUT ROWID; -- Full-text index (built by the script, shipped in the file) CREATE VIRTUAL TABLE verses_fts USING fts5( text, translation UNINDEXED, id UNINDEXED, tokenize = 'unicode61 remove_diacritics 2' ); Opening it from Swift with the system SQLite library:
import Foundation import SQLite3 enum TextStoreError: Error { case openFailed(String), queryFailed(String) } final class BibleTextStore { private var db: OpaquePointer? init() throws { guard let url = Bundle.main.url(forResource: "bible", withExtension: "sqlite") else { throw TextStoreError.openFailed("bible.sqlite missing from bundle") } // immutable=1 tells SQLite the file can never change, so it skips locking. let uri = "file:\(url.path)?immutable=1" let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_URI guard sqlite3_open_v2(uri, &db, flags, nil) == SQLITE_OK else { throw TextStoreError.openFailed(String(cString: sqlite3_errmsg(db))) } } deinit { sqlite3_close(db) } func chapter(_ book: Int, _ chapter: Int, translation: String) throws -> [(VerseRef, String)] { let sql = "SELECT id, text FROM verses WHERE translation = ? AND id BETWEEN ? AND ? ORDER BY id" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw TextStoreError.queryFailed(String(cString: sqlite3_errmsg(db))) } defer { sqlite3_finalize(stmt) } let start = VerseRef(book: book, chapter: chapter, verse: 0).id sqlite3_bind_text(stmt, 1, translation, -1, SQLITE_TRANSIENT) sqlite3_bind_int64(stmt, 2, Int64(start)) sqlite3_bind_int64(stmt, 3, Int64(start + 999)) var rows: [(VerseRef, String)] = [] while sqlite3_step(stmt) == SQLITE_ROW { let id = Int(sqlite3_column_int64(stmt, 0)) let text = String(cString: sqlite3_column_text(stmt, 1)) rows.append((VerseRef(id: id), text)) } return rows } } // The C macro SQLITE_TRANSIENT doesn't import into Swift; this is the usual stand-in. private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) Why raw SQLite and not Core Data for the text? Core Data is great for object graphs you edit. A read-only corpus with a full-text index is a different job, and SQLite's FTS5 does that job directly. You can wrap this in GRDB or another library if you prefer; the shape doesn't change.
4. Search that feels instant
The listing promises "Search across all books, chapters, and verses" and "Advanced search with history". With an FTS5 index shipped in the file, the query is one statement:
extension BibleTextStore { struct Hit { let ref: VerseRef; let snippet: String } func search(_ query: String, translation: String, limit: Int = 100) throws -> [Hit] { // Turn "love one another" into "love* one* another*": prefix match on every word. let terms = query .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) .map { "\($0)*" } .joined(separator: " ") guard !terms.isEmpty else { return [] } let sql = """ SELECT id, snippet(verses_fts, 0, '[', ']', '…', 12) FROM verses_fts WHERE verses_fts MATCH ? AND translation = ? ORDER BY rank LIMIT ? """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw TextStoreError.queryFailed(String(cString: sqlite3_errmsg(db))) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, terms, -1, SQLITE_TRANSIENT) sqlite3_bind_text(stmt, 2, translation, -1, SQLITE_TRANSIENT) sqlite3_bind_int(stmt, 3, Int32(limit)) var hits: [Hit] = [] while sqlite3_step(stmt) == SQLITE_ROW { hits.append(Hit(ref: VerseRef(id: Int(sqlite3_column_int64(stmt, 0))), snippet: String(cString: sqlite3_column_text(stmt, 1)))) } return hits } } A few things that matter more than the SQL:
- Sanitise the query. FTS5 has its own query syntax; a stray quote or hyphen from the user can cause a syntax error. Splitting on non-alphanumerics and rebuilding the string, as above, avoids that.
- Debounce in the UI. Search on every keystroke, but cancel the previous task. A
.task(id: query)modifier in SwiftUI with a shortTask.sleepat the top gives you that for free. - Run it off the main thread. It's fast, but "fast" on an older iPhone in Low Power Mode isn't the same as on your desk.
- Search history is user data , so it goes in the user store (next section), not the read-only file.
5. User data: Core Data with CloudKit mirroring
The listing says "iCloud sync across all your devices… Access your highlights, notes, and bookmarks anywhere". The app supports iOS 16.0 and later, which rules out SwiftData (iOS 17+). The natural fit is NSPersistentCloudKitContainer (iOS 13+), which keeps a normal local Core Data store and mirrors it to the user's private CloudKit database in the background.
That's exactly the offline-first behaviour you want: every write goes to the local store first and works with no network; sync catches up later.
import CoreData final class UserStore { static let shared = UserStore() let container: NSPersistentCloudKitContainer private init() { container = NSPersistentCloudKitContainer(name: "UserData") // UserData.xcdatamodeld guard let description = container.persistentStoreDescriptions.first else { fatalError("No store description") } description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.bible") // Needed so the app can react to changes that arrive from other devices. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) container.loadPersistentStores { _, error in if let error { print("Store failed to load: \(error)") } } container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy } } The Highlight entity is deliberately boring: verseID (Integer 64), translation (String, optional), colorName (String), createdAt (Date) and a UUID id. A few CloudKit rules shape that model, and they catch everyone once:
- Every attribute must be optional or have a default value. CloudKit can deliver records in any order and partially.
- No unique constraints. CloudKit mirroring doesn't support them. If the user highlights the same verse on two offline devices, you'll get two rows; de-duplicate in code (keep the newest per
verseID). - Relationships must be optional and need inverses.
- Enable the capabilities: iCloud with CloudKit and your container, plus Background Modes → Remote notifications, so pushes from CloudKit can wake the app to sync.
- Deploy the schema to production in the CloudKit Console before you ship. A development schema works on your device and silently fails for users. During development,
initializeCloudKitSchema(options:)can create the schema from your model for you.
Saving a highlight is then plain Core Data:
extension UserStore { func toggleHighlight(_ ref: VerseRef, color: String) throws { let context = container.viewContext let request = NSFetchRequest<NSManagedObject>(entityName: "Highlight") request.predicate = NSPredicate(format: "verseID == %lld", Int64(ref.id)) let existing = try context.fetch(request) if existing.isEmpty { let h = NSEntityDescription.insertNewObject(forEntityName: "Highlight", into: context) h.setValue(UUID(), forKey: "id") h.setValue(Int64(ref.id), forKey: "verseID") h.setValue(color, forKey: "colorName") h.setValue(Date(), forKey: "createdAt") } else { existing.forEach(context.delete) // also cleans up offline duplicates } try context.save() } } On the reading screen, fetch highlights for the chapter's ID range and merge them with the verse rows from SQLite in a view model. The two stores never need to know about each other.
6. Listen mode with AVSpeechSynthesizer
The listing's "Listen & Read" section mentions text-to-speech and auto-scroll. On iOS that's AVSpeechSynthesizer (iOS 7+), and its delegate tells you which range of text is about to be spoken, which is all you need to highlight and scroll along.
import AVFoundation @MainActor final class ReadAloud: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { @Published var currentVerse: VerseRef? private let synth = AVSpeechSynthesizer() private var refs: [ObjectIdentifier: VerseRef] = [:] override init() { super.init() synth.delegate = self } func speak(chapter verses: [(VerseRef, String)], language: String = "en-US") { synth.stopSpeaking(at: .immediate) refs = [:] for (ref, text) in verses { let u = AVSpeechUtterance(string: text) u.voice = AVSpeechSynthesisVoice(language: language) u.rate = AVSpeechUtteranceDefaultSpeechRate u.postUtteranceDelay = 0.15 // a breath between verses refs[ObjectIdentifier(u)] = ref synth.speak(u) // utterances are queued in order } } func pause() { synth.pauseSpeaking(at: .word) } func resume() { synth.continueSpeaking() } nonisolated func speechSynthesizer(_ s: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { let key = ObjectIdentifier(utterance) Task { @MainActor in self.currentVerse = self.refs[key] } } } The view observes currentVerse and calls ScrollViewReader.scrollTo(_:anchor:) with the verse ID. For word-level highlighting, implement speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:) as well.
Two practical notes. If you want speech to continue with the screen locked ("perfect for listening while commuting"), set the audio session category to .playback and add the audio background mode, and only while speech is actually running. And the voices available vary by device and by what the user has downloaded in Settings, so let them pick, and fall back gracefully when AVSpeechSynthesisVoice(language:) returns nil.
7. Verse of the Day on the Home Screen with WidgetKit
The listing has "Home screen widgets: quick access to verse of the day". A widget runs in its own extension process, so it can't reach into the app's memory. Two things make this simple with the architecture above:
- The text database is read-only and in the bundle , so the widget extension can ship its own copy (or you can share it through an App Group container). No sync needed.
- The verse of the day is a deterministic function of the date. Every device shows the same verse without a server.
import WidgetKit import SwiftUI struct VerseEntry: TimelineEntry { let date: Date let reference: String let text: String } struct VerseProvider: TimelineProvider { func placeholder(in context: Context) -> VerseEntry { VerseEntry(date: .now, reference: "John 3:16", text: "For God so loved the world…") } func getSnapshot(in context: Context, completion: @escaping (VerseEntry) -> Void) { completion(entry(for: .now)) } func getTimeline(in context: Context, completion: @escaping (Timeline<VerseEntry>) -> Void) { let now = Date() let tomorrow = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400)) completion(Timeline(entries: [entry(for: now)], policy: .after(tomorrow))) } private func entry(for date: Date) -> VerseEntry { // Pick from a curated list by day-of-year: same verse for everyone, every device. let day = Calendar.current.ordinality(of: .day, in: .year, for: date) ?? 1 let pick = DailyVerses.listSource:https://dev.to/aryansbtloe/building-an-offline-first-bible-reader-on-ios-bundled-sqlite-full-text-search-and-icloud-synced-1023Related posts

The latest AI news we announced in October 2026

