How to Build Spotlight Indexing in SwiftUI
Create a CSSearchableItem with an attribute set, then call
CSSearchableIndex.default().indexSearchableItems — your content appears in Spotlight within seconds.
Handle deep-links back into your app using onContinueUserActivity.
import CoreSpotlight
func indexItem(id: String, title: String, description: String) {
let attrs = CSSearchableItemAttributeSet(contentType: .text)
attrs.title = title
attrs.contentDescription = description
let item = CSSearchableItem(
uniqueIdentifier: id,
domainIdentifier: "com.example.myapp.notes",
attributeSet: attrs
)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error { print("Spotlight error:", error) }
}
}
Full implementation
The example below builds a small notes app. A SpotlightIndexer actor
handles all indexing work off the main thread. The ContentView calls
the indexer whenever a note is created or updated, and uses onContinueUserActivity
to navigate directly to the note when the user taps its Spotlight result. Deletion is handled by
deleteSearchableItems(withIdentifiers:) so stale entries never linger.
import SwiftUI
import CoreSpotlight
// MARK: – Model
struct Note: Identifiable, Hashable {
let id: String
var title: String
var body: String
var tags: [String]
}
// MARK: – Spotlight actor (off-main-thread safe)
actor SpotlightIndexer {
static let shared = SpotlightIndexer()
private let domainID = "com.example.myapp.notes"
func index(_ notes: [Note]) async {
let items = notes.map { note -> CSSearchableItem in
let attrs = CSSearchableItemAttributeSet(contentType: .text)
attrs.title = note.title
attrs.contentDescription = note.body
attrs.keywords = note.tags
// Thumbnail (optional – uncomment if you have an image)
// attrs.thumbnailData = UIImage(named: "note-icon")?.pngData()
return CSSearchableItem(
uniqueIdentifier: note.id,
domainIdentifier: domainID,
attributeSet: attrs
)
}
do {
try await CSSearchableIndex.default().indexSearchableItems(items)
} catch {
print("Spotlight index error:", error)
}
}
func remove(_ ids: [String]) async {
do {
try await CSSearchableIndex.default()
.deleteSearchableItems(withIdentifiers: ids)
} catch {
print("Spotlight delete error:", error)
}
}
func removeAll() async {
do {
try await CSSearchableIndex.default()
.deleteAllSearchableItems()
} catch {
print("Spotlight removeAll error:", error)
}
}
}
// MARK: – View Model
@Observable
final class NotesViewModel {
var notes: [Note] = [
Note(id: "1", title: "Meeting agenda", body: "Discuss Q3 roadmap with the team.", tags: ["work", "meeting"]),
Note(id: "2", title: "Grocery list", body: "Apples, oat milk, sourdough.", tags: ["personal"]),
Note(id: "3", title: "SwiftUI tips", body: "Use @Observable instead of ObservableObject.", tags: ["dev"]),
]
var selectedNoteID: String?
func indexAll() {
Task { await SpotlightIndexer.shared.index(notes) }
}
func delete(note: Note) {
notes.removeAll { $0.id == note.id }
Task { await SpotlightIndexer.shared.remove([note.id]) }
}
}
// MARK: – Views
struct ContentView: View {
@State private var vm = NotesViewModel()
var body: some View {
NavigationStack {
List(vm.notes) { note in
NavigationLink(value: note) {
VStack(alignment: .leading, spacing: 2) {
Text(note.title).font(.headline)
Text(note.body)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.swipeActions {
Button(role: .destructive) {
vm.delete(note: note)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.navigationTitle("Notes")
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note)
}
// Deep-link: user tapped a Spotlight result
.onContinueUserActivity(CSSearchableItemActionType) { activity in
guard let id = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
else { return }
vm.selectedNoteID = id
}
.task {
// Index on first launch (and after re-install)
vm.indexAll()
}
}
}
}
struct NoteDetailView: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(note.title).font(.title).bold()
Text(note.body).foregroundStyle(.secondary)
HStack {
ForEach(note.tags, id: \.self) { tag in
Text(tag)
.font(.caption)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.clipShape(Capsule())
}
}
Spacer()
}
.padding()
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: – Preview
#Preview {
ContentView()
}
How it works
-
CSSearchableItemAttributeSet — Initialized with
contentType: .text, this object carries every piece of metadata Spotlight displays:title,contentDescription, andkeywords(used for tag-based matching). You can also attach athumbnailDataimage. -
SpotlightIndexer actor — Wrapping indexing calls in a Swift
actorkeeps them off the main thread automatically, preventing UI hitches when indexing hundreds of items. The asyncindexSearchableItemsoverload (available iOS 16+) removes callback nesting. -
.task modifier —
.task { vm.indexAll() }runs once when the view appears, re-indexing everything after a re-install. Because the index is persistent on-device, subsequent launches only need to index changed or new items. -
onContinueUserActivity — Listening for
CSSearchableItemActionTypegives you the tapped item'suniqueIdentifierviaCSSearchableItemActivityIdentifierin the activity'suserInfo. Use that ID to push the matching detail view. - deleteSearchableItems — Called immediately in the swipe-to-delete path so orphaned Spotlight results never point to deleted content. Always pair an index write with a matching delete to avoid ghost entries.
Variants
Add a thumbnail image to the Spotlight result
import CoreSpotlight
import UIKit
func indexNoteWithThumbnail(_ note: Note, image: UIImage) {
let attrs = CSSearchableItemAttributeSet(contentType: .text)
attrs.title = note.title
attrs.contentDescription = note.body
attrs.keywords = note.tags
// Resize to 120 pt to keep index lightweight
let size = CGSize(width: 120, height: 120)
let thumb = UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
attrs.thumbnailData = thumb.pngData()
let item = CSSearchableItem(
uniqueIdentifier: note.id,
domainIdentifier: "com.example.myapp.notes",
attributeSet: attrs
)
CSSearchableIndex.default().indexSearchableItems([item]) { _ in }
}
Set an expiration date
By default, CSSearchableItem entries never expire.
For ephemeral content (e.g. event reminders, sale banners), set the
expirationDate property on the item:
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 7).
Spotlight automatically removes the entry after one week without you needing to call deleteSearchableItems.
Common pitfalls
-
iOS version for async overloads: The
async/awaitversions ofindexSearchableItemsanddeleteSearchableItemsrequire iOS 16+. They compile fine under iOS 17+, but if you ever lower your deployment target, switch back to the callback variants. -
Forgetting to handle the activity in the root view:
onContinueUserActivitymust be on your rootNavigationStack, not a child view. If the modifier is placed on an inner view that isn't yet in the hierarchy when the app cold-launches from Spotlight, the activity is silently dropped. -
Over-indexing hammers battery: Avoid calling
indexAll()on every app foreground. Use a lightweight change-tracking flag (e.g. a UserDefaults timestamp) and only re-index items modified since the last index run. The on-device Spotlight database has no hard item limit, but large volumes slow down the first-run index pass significantly.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement spotlight indexing in SwiftUI for iOS 17+. Use CoreSpotlight/CSSearchableItem. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
In Soarias' Build phase, drop this prompt into the active screen session alongside your data model file so Claude Code indexes the correct entity types with the right domain identifiers from the start.
Related
FAQ
Does this work on iOS 16?
Yes — CSSearchableItem and CSSearchableIndex
have been available since iOS 9. The async/await overloads used in this guide require iOS 16+.
For iOS 15 targets, replace them with the completion-handler variants:
indexSearchableItems(_:completionHandler:).
The onContinueUserActivity SwiftUI modifier requires iOS 14+.
How do I test Spotlight indexing in the simulator?
Run your app once so it indexes items, then press ⌘ + Shift + H
twice to reach the home screen, swipe down for Spotlight, and search by title or keyword. Indexing in the simulator can lag
10–30 seconds — use xcrun simctl spawn booted mdutil -s / to verify
the Spotlight daemon is running if results don't appear. On a physical device, results typically appear within a few seconds.
What is the UIKit equivalent?
In UIKit you handle the deep-link in
application(_:continue:restorationHandler:) in your
AppDelegate. Check that
userActivity.activityType == CSSearchableItemActionType,
then read CSSearchableItemActivityIdentifier from
userActivity.userInfo — exactly the same dictionary key as in SwiftUI's
onContinueUserActivity closure.
The indexing side (CSSearchableIndex) is identical between UIKit and SwiftUI.
Last reviewed: 2026-05-11 by the Soarias team.