How to Build Spotlight Search in SwiftUI
Create a CSSearchableItemAttributeSet, wrap it in a
CSSearchableItem, then call
CSSearchableIndex.default().indexSearchableItems.
Handle the tap-through in your SwiftUI App with
.onContinueUserActivity.
import CoreSpotlight
import UniformTypeIdentifiers
func indexNote(id: String, title: String, body: String) {
let attrs = CSSearchableItemAttributeSet(contentType: UTType.text)
attrs.title = title
attrs.contentDescription = body
let item = CSSearchableItem(
uniqueIdentifier: id,
domainIdentifier: "com.example.app.notes",
attributeSet: attrs
)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error { print("Spotlight index error: \(error)") }
}
}
Full implementation
The example below builds a minimal notes app that indexes every note into Spotlight on creation or update,
removes a note from the index when it is deleted, and navigates directly to the matching note when the user
taps a Spotlight result. The SpotlightIndexer actor
keeps all CoreSpotlight calls off the main thread, which is required for large datasets.
import SwiftUI
import CoreSpotlight
import UniformTypeIdentifiers
// MARK: – Model
struct Note: Identifiable {
let id: String
var title: String
var body: String
}
// MARK: – Spotlight indexer (actor for thread safety)
actor SpotlightIndexer {
static let shared = SpotlightIndexer()
private let index = CSSearchableIndex.default()
private let domain = "com.example.app.notes"
func index(_ notes: [Note]) async throws {
let items = notes.map { note -> CSSearchableItem in
let attrs = CSSearchableItemAttributeSet(contentType: UTType.text)
attrs.title = note.title
attrs.contentDescription = note.body
// Optional: add a thumbnail
// attrs.thumbnailData = UIImage(named: "note-icon")?.pngData()
return CSSearchableItem(
uniqueIdentifier: note.id,
domainIdentifier: domain,
attributeSet: attrs
)
}
try await index.indexSearchableItems(items)
}
func remove(ids: [String]) async throws {
try await index.deleteSearchableItems(withIdentifiers: ids)
}
func removeAll() async throws {
try await index.deleteSearchableItems(withDomainIdentifiers: [domain])
}
}
// MARK: – Store
@MainActor
@Observable
final class NoteStore {
var notes: [Note] = []
var selectedNoteID: String?
func add(title: String, body: String) {
let note = Note(id: UUID().uuidString, title: title, body: body)
notes.append(note)
Task { try await SpotlightIndexer.shared.index([note]) }
}
func delete(at offsets: IndexSet) {
let ids = offsets.map { notes[$0].id }
notes.remove(atOffsets: offsets)
Task { try await SpotlightIndexer.shared.remove(ids: ids) }
}
func handleSpotlight(userActivity: NSUserActivity) {
guard
userActivity.activityType == CSSearchableItemActionType,
let id = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String
else { return }
selectedNoteID = id
}
}
// MARK: – Views
struct NoteListView: View {
@Environment(NoteStore.self) private var store
@State private var showAdd = false
var body: some View {
@Bindable var store = store
NavigationStack {
List {
ForEach(store.notes) { note in
NavigationLink(value: note.id) {
VStack(alignment: .leading, spacing: 2) {
Text(note.title).font(.headline)
Text(note.body)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
.onDelete(perform: store.delete)
}
.navigationTitle("Notes")
.navigationDestination(for: String.self) { id in
if let note = store.notes.first(where: { $0.id == id }) {
NoteDetailView(note: note)
}
}
.toolbar {
Button("Add", systemImage: "plus") { showAdd = true }
}
.sheet(isPresented: $showAdd) { AddNoteView() }
}
}
}
struct NoteDetailView: View {
let note: Note
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(note.title).font(.title2.bold())
Text(note.body).foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
}
}
struct AddNoteView: View {
@Environment(NoteStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var body = ""
var body: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
TextField("Body", text: $body, axis: .vertical)
.lineLimit(4...)
}
.navigationTitle("New Note")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
store.add(title: title, body: body)
dismiss()
}
.disabled(title.isEmpty)
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
// MARK: – App entry point
@main
struct SpotlightDemoApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
NoteListView()
.environment(store)
.onContinueUserActivity(CSSearchableItemActionType) { activity in
store.handleSpotlight(userActivity: activity)
}
}
}
}
// MARK: – Preview
#Preview {
let store = NoteStore()
store.add(title: "Buy groceries", body: "Milk, eggs, sourdough bread")
store.add(title: "Read WWDC notes", body: "Focus on SwiftUI data flow changes")
return NoteListView().environment(store)
}
How it works
-
CSSearchableItemAttributeSet — created with
contentType: UTType.text, it carries the metadata iOS Search uses to display and rank your result:title,contentDescription, and an optionalthumbnailData. Pick the UTType that best matches your content (e.g.UTType.imagefor photos). - CSSearchableItem uniqueIdentifier — the string you pass here is echoed back to you when the user taps the result. Using a stable model ID (UUID) means you can look it up in your store without extra bookkeeping.
-
SpotlightIndexer actor — CoreSpotlight calls are synchronous-looking but do I/O;
wrapping them in an
actorand usingasync/awaitkeeps them off the main thread and prevents data races when indexing batches of items. -
deleteSearchableItems — called in
delete(at:)to remove stale results the moment the user deletes a note. Failing to do this leaves ghost entries in Spotlight that crash or show nothing when tapped. -
.onContinueUserActivity(CSSearchableItemActionType) — SwiftUI's scene-level modifier
receives the
NSUserActivityiOS delivers when the user picks your Spotlight result. ExtractingCSSearchableItemActivityIdentifierfromuserInfogives you the identifier you stored at index time.
Variants
Batch re-index on launch
If your data lives in SwiftData or Core Data, re-index the entire corpus on first launch (or after a migration) so Spotlight stays in sync even if the app was reinstalled.
extension SpotlightIndexer {
/// Call once after model migration or fresh install.
func reindexAll(notes: [Note]) async throws {
// Wipe stale entries first
try await removeAll()
// Re-index in chunks to avoid memory pressure
let chunkSize = 100
for chunk in stride(from: 0, to: notes.count, by: chunkSize) {
let slice = Array(notes[chunk ..< min(chunk + chunkSize, notes.count)])
try await index(slice)
}
}
}
Expiring index entries
Set CSSearchableItem.expirationDate to automatically remove
time-sensitive content (e.g. calendar events, limited-time offers) without needing a deletion call:
item.expirationDate = Calendar.current.date(byAdding: .day, value: 7, to: .now).
iOS prunes expired items silently, so this is the safest approach for ephemeral content.
Common pitfalls
-
iOS version floor:
CSSearchableIndexasync/await methods require iOS 16+, but the delegate-based batch indexing API (CSSearchableIndexDelegate) for on-demand indexing of large datasets is only fully reliable from iOS 16.4+. The code above targets iOS 17+, so you're safe — but don't backport without checking. -
Missing .onContinueUserActivity placement: The modifier must be on a
WindowGroupscene view (or its root), not a child view deep in the hierarchy. Placing it on a modal or aNavigationStackchild means it silently never fires. -
Never-deleted index entries: If you reinstall without calling
deleteSearchableItems(withDomainIdentifiers:), old Spotlight results persist. A tap on a stale result will deliver an identifier your current store doesn't know about. Always guard with an optional lookup and show a "not found" state rather than force-unwrapping. -
Accessibility: Spotlight results surface
CSSearchableItemAttributeSet.titleandcontentDescriptionto VoiceOver on the Search screen. Keep titles concise and descriptions meaningful — avoid repeating the title in the description.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement spotlight search in SwiftUI for iOS 17+. Use CoreSpotlight (CSSearchableItem, CSSearchableItemAttributeSet, CSSearchableIndex). Index items on create/update, remove on delete, handle .onContinueUserActivity(CSSearchableItemActionType) in the App scene. Make it accessible (VoiceOver labels via attrs.title and attrs.contentDescription). Add a #Preview with realistic sample data.
In Soarias, drop this prompt into the Build phase after your data model screens are scaffolded — Spotlight indexing slots in cleanly as a side-effect of your existing create/delete actions without restructuring the view layer.
Related
FAQ
Does this work on iOS 16?
The async/await overloads of
CSSearchableIndex require iOS 16+, so the code compiles
back to iOS 16. If you need iOS 15 support, use the older completion-handler API:
indexSearchableItems(_:completionHandler:). For iOS 17+
projects (the target here), the async API is always the right choice.
How many items can I index without hitting rate limits?
Apple documents no hard cap, but recommends keeping the index under a few thousand items for snappy
performance. For larger datasets, implement
CSSearchableIndexDelegate with
searchableIndex(_:reindexAllSearchableItemsWithAcknowledgementHandler:)
— iOS calls your delegate when it needs a fresh copy (e.g. after a device restore) instead of storing
everything redundantly. This is the recommended path for apps with thousands of records.
What's the UIKit equivalent?
UIKit has no Spotlight-specific API — CoreSpotlight is framework-level and works identically in UIKit
apps. The only UIKit difference is the continuation handler: instead of
.onContinueUserActivity, implement
application(_:continue:restorationHandler:) in your
UIApplicationDelegate and check for
CSSearchableItemActionType there.
Last reviewed: 2026-05-11 by the Soarias team.