```html SwiftUI: How to Build Spotlight Search (iOS 17+, 2026)

How to Build Spotlight Search in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: CoreSpotlight Updated: May 11, 2026
TL;DR

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

  1. CSSearchableItemAttributeSet — created with contentType: UTType.text, it carries the metadata iOS Search uses to display and rank your result: title, contentDescription, and an optional thumbnailData. Pick the UTType that best matches your content (e.g. UTType.image for photos).
  2. 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.
  3. SpotlightIndexer actor — CoreSpotlight calls are synchronous-looking but do I/O; wrapping them in an actor and using async/await keeps them off the main thread and prevents data races when indexing batches of items.
  4. 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.
  5. .onContinueUserActivity(CSSearchableItemActionType) — SwiftUI's scene-level modifier receives the NSUserActivity iOS delivers when the user picks your Spotlight result. Extracting CSSearchableItemActivityIdentifier from userInfo gives 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

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.

```