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

How to Build Spotlight Indexing in SwiftUI

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

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

  1. CSSearchableItemAttributeSet — Initialized with contentType: .text, this object carries every piece of metadata Spotlight displays: title, contentDescription, and keywords (used for tag-based matching). You can also attach a thumbnailData image.
  2. SpotlightIndexer actor — Wrapping indexing calls in a Swift actor keeps them off the main thread automatically, preventing UI hitches when indexing hundreds of items. The async indexSearchableItems overload (available iOS 16+) removes callback nesting.
  3. .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.
  4. onContinueUserActivity — Listening for CSSearchableItemActionType gives you the tapped item's uniqueIdentifier via CSSearchableItemActivityIdentifier in the activity's userInfo. Use that ID to push the matching detail view.
  5. 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

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.

```