
Spotlight Indexing
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates Core Spotlight indexing infrastructure so app content is searchable via system Spotlight and Siri suggestions with deep-link integration.
About
Generates Core Spotlight indexing infrastructure that makes app content searchable via Spotlight and Siri suggestions with rich attributes and deep links. A developer uses it to make in-app content discoverable system-wide.
- Indexes items with rich attributes and deep links
- Handles search continuation and index lifecycle
Spotlight Indexing by the numbers
- 3 all-time installs (skills.sh)
- Ranked #883 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill spotlight-indexingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates Core Spotlight indexing infrastructure so app content is searchable via system Spotlight and Siri suggestions with deep-link integration.
Files
Spotlight Indexing Generator
Generate production Core Spotlight indexing infrastructure — makes app content searchable via Spotlight (and Siri suggestions). Indexes items with rich attributes, handles search continuation into your app, and manages index lifecycle.
When This Skill Activates
Use this skill when the user:
- Asks to "add spotlight search" or "spotlight indexing"
- Mentions "core spotlight" or "CSSearchableItem"
- Wants to make "searchable content" or "index content" for system search
- Asks about "system search" integration or "Siri suggestions"
- Wants content to appear in Spotlight results
- Mentions "NSUserActivity" for search or handoff
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Existing CoreSpotlight Detection
Search for existing Spotlight code:
Glob: **/*Spotlight*.swift, **/*Searchable*.swift, **/*CSSearchable*.swift
Grep: "CoreSpotlight" or "CSSearchableIndex" or "CSSearchableItem"If existing Spotlight code found:
- Ask if user wants to replace or augment it
- If augmenting, identify what's missing and generate only those pieces
3. Deep Linking Detection
Search for existing deep link or navigation setup:
Grep: "NavigationPath" or "onOpenURL" or "NSUserActivity" or "DeepLink"If deep linking exists:
- Integrate SpotlightSearchHandler with existing router
- If not, generate standalone handler with guidance on wiring it up
4. Entitlements Check
Verify CoreSpotlight doesn't require special entitlements (it doesn't — it's a standard framework), but check if the app uses App Groups for shared index across extensions.
Configuration Questions
Ask user via AskUserQuestion:
1. Content types to index?
- Articles / blog posts (titles, body text, authors)
- Products (name, description, price, images)
- Contacts / people (name, phone, email)
- Tasks / reminders (title, due date, priority)
- Custom (user describes their model)
2. Include thumbnails?
- Yes — index thumbnail images alongside text attributes
- No — text-only attributes (smaller index, faster)
3. Indexing strategy?
- Batch indexing (index all content at once, e.g., on first launch or sync)
- Incremental indexing (index items as they're created/updated/deleted)
- Both (batch for initial load, incremental for changes)
4. Include Siri suggestions / shortcuts?
- Yes — make content eligible for Siri suggestions and predictions
- No — Spotlight search only
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code. Read patterns.md for architecture guidance and best practices.
Step 2: Create Core Files
Generate these files: 1. SpotlightIndexable.swift — Protocol any model can conform to 2. SpotlightIndexManager.swift — Actor wrapping CSSearchableIndex with batching 3. SpotlightAttributeBuilder.swift — Fluent builder for CSSearchableItemAttributeSet
Step 3: Create Integration Files
4. SpotlightSearchHandler.swift — Handles NSUserActivity continuation from Spotlight taps
Step 4: Create Optional Files
Based on configuration:
SpotlightSyncModifier.swift— If incremental indexing selected (ViewModifier for auto index/deindex)- Add NSUserActivity + eligibleForSearch if Siri suggestions selected
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/SpotlightIndexing/ - If
App/exists ->App/SpotlightIndexing/ - Otherwise ->
SpotlightIndexing/
Output Format
After generation, provide:
Files Created
SpotlightIndexing/
├── SpotlightIndexable.swift # Protocol for indexable models
├── SpotlightIndexManager.swift # Actor-based index management
├── SpotlightAttributeBuilder.swift # Fluent attribute builder
├── SpotlightSearchHandler.swift # Handle Spotlight tap continuation
└── SpotlightSyncModifier.swift # Auto index/deindex ViewModifier (optional)Integration with Content Creation/Update
Make a model searchable:
// Conform your model to SpotlightIndexable
struct Article: SpotlightIndexable {
let id: UUID
let title: String
let body: String
let author: String
let tags: [String]
var spotlightID: String { id.uuidString }
var spotlightTitle: String { title }
var spotlightDescription: String { String(body.prefix(300)) }
var spotlightKeywords: [String] { tags + [author] }
var spotlightThumbnailData: Data? { nil }
var spotlightDomainIdentifier: String { "com.myapp.articles" }
}Index on create, remove on delete:
func createArticle(_ article: Article) async throws {
try await repository.save(article)
await SpotlightIndexManager.shared.index(items: [article])
}
func deleteArticle(_ article: Article) async throws {
try await repository.delete(article)
await SpotlightIndexManager.shared.remove(identifiers: [article.spotlightID])
}Batch reindex (e.g., on first launch):
func reindexAllContent() async {
let articles = await repository.fetchAll()
await SpotlightIndexManager.shared.reindexAll(items: articles, domain: "com.myapp.articles")
}Handle Spotlight tap (App or Scene delegate):
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity(CSSearchableItemActionType) { activity in
SpotlightSearchHandler.shared.handle(activity)
}
}
}
}Auto index/deindex with ViewModifier:
struct ArticleDetailView: View {
let article: Article
var body: some View {
ScrollView {
Text(article.body)
}
.spotlightIndexed(article) // Indexes on appear, deindexes on disappear
}
}Testing
@Test
func indexAndRetrieveItem() async throws {
let manager = SpotlightIndexManager(index: MockSearchableIndex())
let article = Article(id: UUID(), title: "Test", body: "Body", author: "Author", tags: ["swift"])
await manager.index(items: [article])
#expect(manager.indexedCount == 1)
}
@Test
func batchIndexChunksCorrectly() async throws {
let mockIndex = MockSearchableIndex()
let manager = SpotlightIndexManager(index: mockIndex, batchSize: 10)
let items = (0..<25).map { makeArticle(index: $0) }
await manager.index(items: items)
#expect(mockIndex.indexCallCount == 3) // 10 + 10 + 5
}
@Test
func handleSpotlightContinuation() async throws {
let handler = SpotlightSearchHandler()
let activity = NSUserActivity(activityType: CSSearchableItemActionType)
activity.userInfo = [CSSearchableItemActivityIdentifier: "article-123"]
let itemID = handler.extractItemID(from: activity)
#expect(itemID == "article-123")
}Common Patterns
Index Item on Create
Every time a model is created or updated, index it immediately:
await SpotlightIndexManager.shared.index(items: [newItem])Remove on Delete
When content is deleted, remove it from the index:
await SpotlightIndexManager.shared.remove(identifiers: [item.spotlightID])Batch Reindex
After app update or data migration, reindex all content:
await SpotlightIndexManager.shared.reindexAll(items: allItems, domain: "com.myapp.articles")Handle Search Continuation
When user taps a Spotlight result, the app receives an NSUserActivity. Extract the item ID and navigate:
.onContinueUserActivity(CSSearchableItemActionType) { activity in
if let id = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigationPath.append(Route.detail(id: id))
}
}Gotchas
Index Size Limits
- CSSearchableIndex has no hard documented limit, but Apple recommends keeping index under ~10,000 items for best performance
- For very large datasets, index only the most relevant/recent items
- Use
expirationDateon CSSearchableItem to auto-expire stale entries
Thumbnail Size for Performance
- Keep thumbnails small: 300x300 pixels max, JPEG compressed
- Large thumbnails slow down indexing and increase on-disk index size
- Use
Data(JPEG/PNG) not full PlatformImage objects
CSSearchableIndex.default() Thread Safety
CSSearchableIndex.default()returns a singleton but its methods are NOT actor-isolated- Wrap all calls in an actor (SpotlightIndexManager) to prevent data races
- Never call
indexSearchableItemsfrom multiple threads simultaneously
Handling App Launch from Spotlight Tap
- The app may be cold-launched — ensure navigation state is ready before handling the activity
- Use
.onContinueUserActivityin SwiftUI, notapplication(_:continue:)alone - The activity type is
CSSearchableItemActionType(a constant from CoreSpotlight)
Index Maintenance on App Update
- After a data model change, old indexed items may have stale attributes
- Call
removeAll(domain:)then re-index on first launch after update - Store indexed schema version in UserDefaults to detect when reindex is needed
Domain Identifiers
- Always set
domainIdentifieron items — it allows bulk removal by domain - Use reverse-DNS style:
"com.myapp.articles","com.myapp.products" - Without domains, you can only remove by individual identifier or remove everything
References
- templates.md — All production Swift templates for Spotlight indexing
- patterns.md — CSSearchableItemAttributeSet content types, batch strategies, Siri suggestions, testing
- Related:
generators/deep-linking— Deep link routing from Spotlight taps
Spotlight Indexing Patterns & Best Practices
CSSearchableItemAttributeSet Content Types
Choose the right UTType for your content to get the best Spotlight presentation:
| Content Type | UTType | When to Use | Spotlight Behavior |
|---|---|---|---|
| General content | .content | Default for most app content | Title + description |
| Text / articles | .text | Blog posts, notes, documents | Title + description + text preview |
| Images | .image | Photo library, gallery items | Shows thumbnail prominently |
| Audio | .audio | Podcasts, music, recordings | Shows playback metadata |
| Video | .movie | Video library items | Shows duration, thumbnail |
| Contact | .contact | People, profiles | Shows phone/email actions |
.emailMessage | Messages, communications | Shows sender/recipient | |
.pdf | Documents | Shows page count | |
| Presentation | .presentation | Slides, decks | Shows slide count |
Setting Content Type
import UniformTypeIdentifiers
// In SpotlightIndexable conformance
var spotlightContentType: UTType { .text }
// Or with the attribute builder
let attributes = SpotlightAttributeBuilder(contentType: .text)
.title("My Article")
.build()Content-Type-Specific Attributes
Different content types unlock additional attribute fields:
// For audio/music content
let attributes = CSSearchableItemAttributeSet(contentType: .audio)
attributes.title = "Episode 42: Swift Concurrency"
attributes.artist = "Swift Talk"
attributes.album = "Season 5"
attributes.duration = NSNumber(value: 2400) // seconds
attributes.audioSampleRate = NSNumber(value: 44100)
// For contact/people content
let attributes = CSSearchableItemAttributeSet(contentType: .contact)
attributes.title = "Jane Smith"
attributes.phoneNumbers = ["+1-555-0123"]
attributes.emailAddresses = ["jane@example.com"]
attributes.organizations = ["Acme Corp"]
// For location-based content
let attributes = CSSearchableItemAttributeSet(contentType: .content)
attributes.title = "Golden Gate Park"
attributes.latitude = NSNumber(value: 37.7694)
attributes.longitude = NSNumber(value: -122.4862)
attributes.namedLocation = "San Francisco, CA"Batch Indexing Strategies
Small Datasets (< 100 items)
Index all at once — no batching needed:
let items = articles.map { $0.toSearchableItem() }
try await CSSearchableIndex.default().indexSearchableItems(items)Medium Datasets (100 - 1,000 items)
Batch in chunks of 100 to avoid memory spikes:
actor SpotlightIndexManager {
private let batchSize = 100
func index<T: SpotlightIndexable>(items: [T]) async {
let searchableItems = items.map { $0.toSearchableItem() }
for startIndex in stride(from: 0, to: searchableItems.count, by: batchSize) {
let endIndex = min(startIndex + batchSize, searchableItems.count)
let batch = Array(searchableItems[startIndex..<endIndex])
do {
try await searchableIndex.indexSearchableItems(batch)
} catch {
// Log but continue with remaining batches
print("Batch index failed at offset \(startIndex): \(error)")
}
}
}
}Large Datasets (1,000+ items)
Use background processing with progress reporting:
func reindexAll<T: SpotlightIndexable>(
items: [T],
domain: String,
progress: @escaping (Double) -> Void
) async {
// 1. Remove all existing items in domain
try? await searchableIndex.deleteSearchableItems(withDomainIdentifiers: [domain])
// 2. Index in batches with progress
let total = items.count
var indexed = 0
for startIndex in stride(from: 0, to: total, by: batchSize) {
let endIndex = min(startIndex + batchSize, total)
let batch = items[startIndex..<endIndex].map { $0.toSearchableItem() }
try? await searchableIndex.indexSearchableItems(batch)
indexed += batch.count
progress(Double(indexed) / Double(total))
// Yield to avoid starving other work
await Task.yield()
}
}Indexing on Background Queue
For initial sync or large imports, run indexing in a low-priority task:
func performBackgroundReindex() {
Task(priority: .utility) {
let allArticles = await repository.fetchAll()
await SpotlightIndexManager.shared.reindexAll(
items: allArticles,
domain: "com.myapp.articles"
)
}
}Index Maintenance
When to Reindex
| Trigger | Action |
|---|---|
| Item created | Index single item |
| Item updated | Re-index single item (same ID overwrites) |
| Item deleted | Remove by identifier |
| App update with model changes | Full reindex of affected domain |
| Data migration | Full reindex of all domains |
| User logs out | Remove all items |
| User switches account | Remove all, reindex for new account |
Schema Version Tracking
Detect when a reindex is needed after app updates:
struct SpotlightSchemaVersion {
private static let key = "SpotlightIndexSchemaVersion"
private static let currentVersion = 2 // Increment when attributes change
static var needsReindex: Bool {
let stored = UserDefaults.standard.integer(forKey: key)
return stored < currentVersion
}
static func markReindexComplete() {
UserDefaults.standard.set(currentVersion, forKey: key)
}
}
// Call on app launch
func checkSpotlightIndex() async {
if SpotlightSchemaVersion.needsReindex {
await SpotlightIndexManager.shared.reindexAll(
items: await fetchAllContent(),
domain: "com.myapp.content"
)
SpotlightSchemaVersion.markReindexComplete()
}
}TTL via expirationDate
Set expiration dates on items that become stale:
extension CSSearchableItem {
/// Set a time-to-live on this searchable item.
func withExpiration(days: Int) -> CSSearchableItem {
self.expirationDate = Calendar.current.date(
byAdding: .day,
value: days,
to: Date()
)
return self
}
}
// Usage: Event expires after the event date
let item = event.toSearchableItem()
.withExpiration(days: daysUntilEvent + 1)Cleanup on App Launch
func cleanupSpotlightIndex() async {
// Remove items for content that no longer exists
let indexedIDs = await SpotlightIndexManager.shared.allIndexedIDs()
let existingIDs = Set(await repository.fetchAllIDs())
let orphanedIDs = indexedIDs.subtracting(existingIDs)
if !orphanedIDs.isEmpty {
await SpotlightIndexManager.shared.remove(identifiers: Array(orphanedIDs))
}
}Siri Suggestions Integration
NSUserActivity for Eligibility
Core Spotlight indexing makes content findable in Spotlight. NSUserActivity makes content eligible for Siri Suggestions — proactive recommendations based on user behavior patterns.
// Create an activity when the user views content
func makeUserActivity(for article: Article) -> NSUserActivity {
let activity = NSUserActivity(activityType: "com.myapp.viewArticle")
activity.title = article.title
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.keywords = Set(article.tags)
activity.persistentIdentifier = article.id.uuidString
// Link to the CSSearchableItem for unified results
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = article.title
attributes.contentDescription = String(article.body.prefix(200))
attributes.relatedUniqueIdentifier = article.id.uuidString
activity.contentAttributeSet = attributes
return activity
}SwiftUI Integration with .userActivity
struct ArticleDetailView: View {
let article: Article
var body: some View {
ScrollView {
Text(article.body)
}
.userActivity("com.myapp.viewArticle") { activity in
activity.title = article.title
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.keywords = Set(article.tags)
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = article.title
attributes.contentDescription = String(article.body.prefix(200))
attributes.relatedUniqueIdentifier = article.id.uuidString
activity.contentAttributeSet = attributes
}
}
}relatedUniqueIdentifier: The Key Connection
Setting relatedUniqueIdentifier on the NSUserActivity's contentAttributeSet links it to the corresponding CSSearchableItem. This prevents duplicate results in Spotlight — the system shows one result backed by both the indexed item and the activity.
// CSSearchableItem with ID "article-123"
let item = CSSearchableItem(
uniqueIdentifier: "article-123",
domainIdentifier: "com.myapp.articles",
attributeSet: attributes
)
// NSUserActivity links to the same item
attributes.relatedUniqueIdentifier = "article-123"
activity.contentAttributeSet = attributesSiri Shortcuts Integration (iOS 16+)
For deeper Siri integration, combine with App Intents:
import AppIntents
struct OpenArticleIntent: AppIntent {
static var title: LocalizedStringResource = "Open Article"
static var description = IntentDescription("Opens a specific article")
@Parameter(title: "Article")
var article: ArticleEntity
func perform() async throws -> some IntentResult {
// Navigate to article
return .result()
}
}
// Donate when user views an article
func donateIntent(for article: Article) {
let intent = OpenArticleIntent()
intent.article = ArticleEntity(article: article)
// System automatically creates a shortcut suggestion
}On-Device Search Ranking
How Apple Ranks Spotlight Results
Apple uses several signals to rank results from your app:
1. Engagement frequency — Items the user taps on more often rank higher 2. Recency — Recently indexed or viewed items rank higher 3. Title match quality — Exact title matches rank above keyword matches 4. Content type relevance — Matching the user's likely intent 5. NSUserActivity signals — Items with user activity + prediction eligibility get boosted
Improving Your Ranking
// DO: Use descriptive, searchable titles
attributes.title = "Chocolate Chip Cookie Recipe" // ✅ Specific, searchable
// DON'T: Use generic titles
attributes.title = "Recipe #42" // ❌ Not useful for search
// DO: Include rich keywords
attributes.keywords = ["chocolate", "cookie", "baking", "dessert", "recipe"]
// DO: Set content description for fallback matching
attributes.contentDescription = "Classic homemade chocolate chip cookies with brown butter..."
// DO: Use relatedUniqueIdentifier to unify CSSearchableItem + NSUserActivity
// This ensures engagement signals are combined, not splitEngagement Signals
The system tracks when users tap your Spotlight results. Items that are tapped more often naturally rise in ranking. You cannot directly manipulate this, but you can:
- Index only high-quality, relevant content
- Keep titles clear and descriptive
- Remove stale content that users would never tap
- Use thumbnails to make results visually appealing
Privacy Considerations
What Gets Indexed
Core Spotlight indexes are on-device only. Indexed content is:
- Stored locally on the user's device
- Not uploaded to Apple servers
- Not shared across devices (unless you re-index on each device)
- Automatically removed when the app is deleted
What to Index vs. Keep Private
// ✅ Safe to index — user-facing content
attributes.title = article.title
attributes.contentDescription = article.summary
attributes.keywords = article.tags
// ❌ Never index — sensitive data
// attributes.contentDescription = user.socialSecurityNumber
// attributes.keywords = [user.password, user.apiKey]
// ⚠️ Be careful — potentially sensitive
// Consider a user setting to opt-out of indexing
attributes.contentDescription = message.preview // Private messages?User Deletion Handling
When a user deletes content, always remove it from the index:
func deleteContent(_ item: ContentItem) async {
// 1. Delete from your data store
await dataStore.delete(item)
// 2. Remove from Spotlight index
await SpotlightIndexManager.shared.remove(identifiers: [item.spotlightID])
}Account Logout / Switch
func handleLogout() async {
// Remove ALL indexed content for this user
await SpotlightIndexManager.shared.removeAll()
}
func handleAccountSwitch(newAccount: Account) async {
// Remove old account's content
await SpotlightIndexManager.shared.removeAll()
// Re-index new account's content
let content = await fetchContent(for: newAccount)
await SpotlightIndexManager.shared.index(items: content)
}Opt-Out Setting
Provide users a way to disable Spotlight indexing:
struct SettingsView: View {
@AppStorage("spotlightIndexingEnabled") private var indexingEnabled = true
var body: some View {
Toggle("Show in Spotlight Search", isOn: $indexingEnabled)
.onChange(of: indexingEnabled) { _, newValue in
Task {
if !newValue {
await SpotlightIndexManager.shared.removeAll()
} else {
await reindexAllContent()
}
}
}
}
}Testing Spotlight Indexing
CSSearchQuery for Verification
Use CSSearchQuery to verify items are correctly indexed:
import CoreSpotlight
import Testing
@Test
func articleIsIndexedWithCorrectAttributes() async throws {
let article = Article(
id: UUID(),
title: "Test Article",
body: "This is the body",
author: "Author",
tags: ["swift", "ios"]
)
// Index the item
await SpotlightIndexManager.shared.index(item: article)
// Wait briefly for indexing to complete
try await Task.sleep(for: .milliseconds(500))
// Query for it
let results = try await searchSpotlight(query: "Test Article")
#expect(results.contains(where: { $0 == article.spotlightID }))
}
func searchSpotlight(query: String) async throws -> [String] {
try await withCheckedThrowingContinuation { continuation in
var foundIdentifiers: [String] = []
let query = CSSearchQuery(
queryString: query,
queryContext: .init()
)
query.foundItemsHandler = { items in
foundIdentifiers.append(contentsOf: items.map(\.uniqueIdentifier))
}
query.completionHandler = { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: foundIdentifiers)
}
}
query.start()
}
}Mock CSSearchableIndex for Unit Tests
/// Mock index that records operations for test verification.
final class MockSearchableIndex: CSSearchableIndex {
var indexedItems: [CSSearchableItem] = []
var removedIdentifiers: [String] = []
var removedDomains: [String] = []
var indexCallCount = 0
var removeCallCount = 0
var didRemoveAll = false
override func indexSearchableItems(_ items: [CSSearchableItem]) async throws {
indexedItems.append(contentsOf: items)
indexCallCount += 1
}
override func deleteSearchableItems(withIdentifiers identifiers: [String]) async throws {
removedIdentifiers.append(contentsOf: identifiers)
removeCallCount += 1
}
override func deleteSearchableItems(withDomainIdentifiers domainIdentifiers: [String]) async throws {
removedDomains.append(contentsOf: domainIdentifiers)
removeCallCount += 1
}
override func deleteAllSearchableItems() async throws {
indexedItems.removeAll()
didRemoveAll = true
}
}Testing SpotlightSearchHandler
@Test
func extractsItemIDFromSpotlightActivity() {
let handler = SpotlightSearchHandler()
let activity = NSUserActivity(activityType: CSSearchableItemActionType)
activity.userInfo = [CSSearchableItemActivityIdentifier: "article-123"]
let itemID = handler.extractItemID(from: activity)
#expect(itemID == "article-123")
}
@Test
func returnsNilForNonSpotlightActivity() {
let handler = SpotlightSearchHandler()
let activity = NSUserActivity(activityType: "com.myapp.other")
let itemID = handler.extractItemID(from: activity)
#expect(itemID == nil)
}
@Test
func extractsQueryFromContinuation() {
let handler = SpotlightSearchHandler()
let activity = NSUserActivity(activityType: CSQueryContinuationActionType)
activity.userInfo = [CSSearchQueryString: "chocolate cookies"]
let query = handler.extractQuery(from: activity)
#expect(query == "chocolate cookies")
}
@Test
func handleSetssPendingItemID() {
let handler = SpotlightSearchHandler()
let activity = NSUserActivity(activityType: CSSearchableItemActionType)
activity.userInfo = [CSSearchableItemActivityIdentifier: "product-456"]
let result = handler.handle(activity)
#expect(result == "product-456")
#expect(handler.pendingItemID == "product-456")
}Testing the SpotlightIndexable Protocol
struct TestItem: SpotlightIndexable {
let spotlightID: String
let spotlightTitle: String
let spotlightDescription: String
let spotlightKeywords: [String]
let spotlightDomainIdentifier: String
}
@Test
func toSearchableItemSetsAllAttributes() {
let item = TestItem(
spotlightID: "test-1",
spotlightTitle: "Test Title",
spotlightDescription: "Test Description",
spotlightKeywords: ["keyword1", "keyword2"],
spotlightDomainIdentifier: "com.test.items"
)
let searchableItem = item.toSearchableItem()
#expect(searchableItem.uniqueIdentifier == "test-1")
#expect(searchableItem.domainIdentifier == "com.test.items")
#expect(searchableItem.attributeSet.title == "Test Title")
#expect(searchableItem.attributeSet.contentDescription == "Test Description")
#expect(searchableItem.attributeSet.keywords == ["keyword1", "keyword2"])
}
@Test
func batchIndexSplitsIntoChunks() async {
let mockIndex = MockSearchableIndex(name: "test")
let manager = SpotlightIndexManager(index: mockIndex, batchSize: 10)
let items = (0..<25).map {
TestItem(
spotlightID: "item-\($0)",
spotlightTitle: "Item \($0)",
spotlightDescription: "Description \($0)",
spotlightKeywords: [],
spotlightDomainIdentifier: "com.test"
)
}
await manager.index(items: items)
#expect(mockIndex.indexCallCount == 3) // 10 + 10 + 5
#expect(mockIndex.indexedItems.count == 25)
}Spotlight Debug in Settings
On a real device, you can verify indexing in Settings: 1. Settings > Developer > Core Spotlight (requires Developer Mode) 2. View indexed items per app 3. See item counts and domain breakdowns 4. Manually trigger reindex
In the Simulator:
- Use
CSSearchQueryprogrammatically (shown above) - Check Console.app for CoreSpotlight log messages
- Filter by
subsystem:com.apple.CoreSpotlight
Anti-Patterns to Avoid
Don't Index Everything
// ❌ Indexing every database row
for row in database.allRows() {
await index(row) // 100,000 items = slow, wasteful
}
// ✅ Index only user-relevant content
let recentItems = database.items(limit: 5000, sortedBy: .lastAccessed)
await index(recentItems)Don't Index on the Main Thread
// ❌ Blocks UI during indexing
func viewDidAppear() {
let items = fetchItems().map { $0.toSearchableItem() }
try? CSSearchableIndex.default().indexSearchableItems(items) // Synchronous!
}
// ✅ Use async/await off the main thread
func viewDidAppear() {
Task {
await SpotlightIndexManager.shared.index(items: fetchItems())
}
}Don't Forget to Remove Deleted Content
// ❌ Content deleted but still appears in Spotlight
func delete(_ item: Item) {
database.delete(item)
// Forgot to remove from Spotlight!
}
// ✅ Always pair delete with index removal
func delete(_ item: Item) async {
database.delete(item)
await SpotlightIndexManager.shared.remove(identifiers: [item.spotlightID])
}Don't Use Large Thumbnails
// ❌ Full-size photo as thumbnail (10 MB)
attributes.thumbnailData = photo.fullSizeJPEGData
// ✅ Compressed, resized thumbnail (< 50 KB)
attributes.thumbnailData = photo.thumbnailData(maxSize: CGSize(width: 300, height: 300))Don't Ignore Errors Silently in Production
// ❌ Swallowing errors completely
try? await index.indexSearchableItems(items)
// ✅ Log errors for diagnostics
do {
try await index.indexSearchableItems(items)
} catch {
Logger.spotlight.error("Index failed: \(error.localizedDescription)")
// Consider retry logic for transient failures
}Spotlight Indexing Code Templates
Production-ready Swift templates for Core Spotlight indexing infrastructure. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
SpotlightIndexable.swift
import Foundation
import CoreSpotlight
/// Protocol that any model can conform to for Spotlight indexing.
///
/// Provides all the data needed to create a `CSSearchableItem`.
/// Conform your models to this protocol and pass them to `SpotlightIndexManager`.
///
/// Usage:
/// ```swift
/// struct Article: SpotlightIndexable {
/// let id: UUID
/// let title: String
/// let body: String
///
/// var spotlightID: String { id.uuidString }
/// var spotlightTitle: String { title }
/// var spotlightDescription: String { String(body.prefix(300)) }
/// var spotlightKeywords: [String] { ["article"] }
/// var spotlightThumbnailData: Data? { nil }
/// var spotlightDomainIdentifier: String { "com.myapp.articles" }
/// }
/// ```
protocol SpotlightIndexable {
/// Unique identifier for the searchable item.
/// Must be stable across app launches (e.g., UUID string).
var spotlightID: String { get }
/// Title displayed in Spotlight results.
var spotlightTitle: String { get }
/// Description displayed below the title in Spotlight results.
/// Keep under 300 characters for best display.
var spotlightDescription: String { get }
/// Keywords for matching search queries.
/// Include synonyms and related terms the user might search for.
var spotlightKeywords: [String] { get }
/// Optional thumbnail image data (JPEG or PNG).
/// Keep under 300x300 pixels for performance.
var spotlightThumbnailData: Data? { get }
/// Domain identifier for grouping related items.
/// Enables bulk removal by domain (e.g., "com.myapp.articles").
var spotlightDomainIdentifier: String { get }
/// Optional: Content type for the attribute set.
/// Defaults to general content. Override for specific types.
var spotlightContentType: UTType { get }
/// Optional: Expiration date after which the item is removed from the index.
/// Defaults to nil (no expiration).
var spotlightExpirationDate: Date? { get }
}
// MARK: - Defaults
extension SpotlightIndexable {
var spotlightContentType: UTType { .content }
var spotlightExpirationDate: Date? { nil }
var spotlightThumbnailData: Data? { nil }
}
// MARK: - CSSearchableItem Conversion
extension SpotlightIndexable {
/// Creates a `CSSearchableItem` from this model's Spotlight properties.
func toSearchableItem() -> CSSearchableItem {
let attributes = CSSearchableItemAttributeSet(contentType: spotlightContentType)
attributes.title = spotlightTitle
attributes.contentDescription = spotlightDescription
attributes.keywords = spotlightKeywords
attributes.thumbnailData = spotlightThumbnailData
attributes.domainIdentifier = spotlightDomainIdentifier
let item = CSSearchableItem(
uniqueIdentifier: spotlightID,
domainIdentifier: spotlightDomainIdentifier,
attributeSet: attributes
)
item.expirationDate = spotlightExpirationDate
return item
}
}SpotlightIndexManager.swift
import Foundation
import CoreSpotlight
/// Actor that manages all Core Spotlight indexing operations.
///
/// Wraps `CSSearchableIndex` with:
/// - Batch indexing for large datasets (configurable batch size)
/// - Safe concurrent access via actor isolation
/// - Domain-based bulk removal
/// - Full reindex support
///
/// Usage:
/// ```swift
/// // Index items
/// await SpotlightIndexManager.shared.index(items: articles)
///
/// // Remove specific items
/// await SpotlightIndexManager.shared.remove(identifiers: ["article-123"])
///
/// // Remove all items in a domain
/// await SpotlightIndexManager.shared.removeAll(domain: "com.myapp.articles")
///
/// // Full reindex
/// await SpotlightIndexManager.shared.reindexAll(items: articles, domain: "com.myapp.articles")
/// ```
actor SpotlightIndexManager {
static let shared = SpotlightIndexManager()
private let searchableIndex: CSSearchableIndex
private let batchSize: Int
init(
index: CSSearchableIndex = .default(),
batchSize: Int = 100
) {
self.searchableIndex = index
self.batchSize = batchSize
}
// MARK: - Index
/// Index an array of `SpotlightIndexable` items.
///
/// Automatically batches large datasets to avoid memory pressure.
/// Each batch is indexed in sequence to respect system resources.
func index<T: SpotlightIndexable>(items: [T]) async {
guard !items.isEmpty else { return }
let searchableItems = items.map { $0.toSearchableItem() }
if searchableItems.count <= batchSize {
await indexBatch(searchableItems)
} else {
// Chunk into batches
for startIndex in stride(from: 0, to: searchableItems.count, by: batchSize) {
let endIndex = min(startIndex + batchSize, searchableItems.count)
let batch = Array(searchableItems[startIndex..<endIndex])
await indexBatch(batch)
}
}
}
/// Index a single `SpotlightIndexable` item.
func index<T: SpotlightIndexable>(item: T) async {
await indexBatch([item.toSearchableItem()])
}
// MARK: - Remove
/// Remove items by their unique identifiers.
func remove(identifiers: [String]) async {
guard !identifiers.isEmpty else { return }
do {
try await searchableIndex.deleteSearchableItems(withIdentifiers: identifiers)
} catch {
logError("Failed to remove items: \(error.localizedDescription)")
}
}
/// Remove all items in a domain.
///
/// Use this for bulk cleanup, e.g., removing all articles.
func removeAll(domain: String) async {
do {
try await searchableIndex.deleteSearchableItems(withDomainIdentifiers: [domain])
} catch {
logError("Failed to remove domain '\(domain)': \(error.localizedDescription)")
}
}
/// Remove all items from the app's Spotlight index.
func removeAll() async {
do {
try await searchableIndex.deleteAllSearchableItems()
} catch {
logError("Failed to remove all items: \(error.localizedDescription)")
}
}
// MARK: - Reindex
/// Remove all items in a domain, then re-index with fresh data.
///
/// Use after data migration or app update when indexed attributes change.
func reindexAll<T: SpotlightIndexable>(items: [T], domain: String) async {
await removeAll(domain: domain)
await index(items: items)
}
// MARK: - Private
private func indexBatch(_ items: [CSSearchableItem]) async {
do {
try await searchableIndex.indexSearchableItems(items)
} catch {
logError("Failed to index batch of \(items.count) items: \(error.localizedDescription)")
}
}
private func logError(_ message: String) {
#if DEBUG
print("[SpotlightIndexManager] \(message)")
#endif
}
}SpotlightAttributeBuilder.swift
import Foundation
import CoreSpotlight
import UniformTypeIdentifiers
/// Fluent builder for `CSSearchableItemAttributeSet`.
///
/// Provides a chainable API for constructing rich attribute sets
/// when you need more control than `SpotlightIndexable` provides.
///
/// Usage:
/// ```swift
/// let attributes = SpotlightAttributeBuilder(contentType: .text)
/// .title("SwiftUI Navigation Guide")
/// .description("Learn about NavigationStack and NavigationSplitView")
/// .keywords(["swiftui", "navigation", "ios"])
/// .thumbnail(imageData)
/// .url(URL(string: "myapp://articles/123")!)
/// .rating(4.5)
/// .build()
///
/// let item = CSSearchableItem(
/// uniqueIdentifier: "article-123",
/// domainIdentifier: "com.myapp.articles",
/// attributeSet: attributes
/// )
/// ```
struct SpotlightAttributeBuilder {
private let attributeSet: CSSearchableItemAttributeSet
init(contentType: UTType = .content) {
self.attributeSet = CSSearchableItemAttributeSet(contentType: contentType)
}
// MARK: - Core Attributes
/// Set the title displayed in Spotlight results.
func title(_ value: String) -> SpotlightAttributeBuilder {
attributeSet.title = value
return self
}
/// Set the description displayed below the title.
func description(_ value: String) -> SpotlightAttributeBuilder {
attributeSet.contentDescription = value
return self
}
/// Set keywords for search matching.
func keywords(_ values: [String]) -> SpotlightAttributeBuilder {
attributeSet.keywords = values
return self
}
// MARK: - Visual Attributes
/// Set thumbnail image data (JPEG or PNG, max 300x300 recommended).
func thumbnail(_ data: Data?) -> SpotlightAttributeBuilder {
attributeSet.thumbnailData = data
return self
}
/// Set thumbnail from a URL (system fetches the image).
func thumbnailURL(_ url: URL) -> SpotlightAttributeBuilder {
attributeSet.thumbnailURL = url
return self
}
// MARK: - Content Type Attributes
/// Set a content URL for the item.
func url(_ value: URL) -> SpotlightAttributeBuilder {
attributeSet.url = value
return self
}
/// Set the content type identifier.
func contentType(_ type: UTType) -> SpotlightAttributeBuilder {
attributeSet.contentType = type.identifier
return self
}
// MARK: - Metadata Attributes
/// Set a star rating (0.0 to 5.0, displayed as stars in Spotlight).
func rating(_ value: Double) -> SpotlightAttributeBuilder {
attributeSet.rating = NSNumber(value: value)
return self
}
/// Set the content creation date.
func creationDate(_ date: Date) -> SpotlightAttributeBuilder {
attributeSet.contentCreationDate = date
return self
}
/// Set the content modification date.
func modificationDate(_ date: Date) -> SpotlightAttributeBuilder {
attributeSet.contentModificationDate = date
return self
}
/// Set the display name (alternative to title for file-like items).
func displayName(_ value: String) -> SpotlightAttributeBuilder {
attributeSet.displayName = value
return self
}
/// Set the domain identifier.
func domainIdentifier(_ value: String) -> SpotlightAttributeBuilder {
attributeSet.domainIdentifier = value
return self
}
// MARK: - People / Contact Attributes
/// Set author names.
func authors(_ values: [String]) -> SpotlightAttributeBuilder {
attributeSet.authorNames = values
return self
}
/// Set phone numbers (for contact-type items).
func phoneNumbers(_ values: [String]) -> SpotlightAttributeBuilder {
attributeSet.phoneNumbers = values
return self
}
/// Set email addresses (for contact-type items).
func emailAddresses(_ values: [String]) -> SpotlightAttributeBuilder {
attributeSet.emailAddresses = values
return self
}
// MARK: - Location Attributes
/// Set latitude for location-based items.
func latitude(_ value: Double) -> SpotlightAttributeBuilder {
attributeSet.latitude = NSNumber(value: value)
return self
}
/// Set longitude for location-based items.
func longitude(_ value: Double) -> SpotlightAttributeBuilder {
attributeSet.longitude = NSNumber(value: value)
return self
}
/// Set named location (e.g., "San Francisco, CA").
func namedLocation(_ value: String) -> SpotlightAttributeBuilder {
attributeSet.namedLocation = value
return self
}
// MARK: - Build
/// Build and return the configured `CSSearchableItemAttributeSet`.
func build() -> CSSearchableItemAttributeSet {
attributeSet
}
}SpotlightSearchHandler.swift
import Foundation
import CoreSpotlight
/// Handles app continuation from Spotlight search result taps.
///
/// When a user taps a Spotlight result, the system delivers an
/// `NSUserActivity` with `activityType == CSSearchableItemActionType`.
/// This handler extracts the item identifier and routes to the
/// appropriate view.
///
/// ## SwiftUI Integration
///
/// ```swift
/// @main
/// struct MyApp: App {
/// @State private var navigationPath = NavigationPath()
///
/// var body: some Scene {
/// WindowGroup {
/// NavigationStack(path: $navigationPath) {
/// ContentView()
/// .navigationDestination(for: SpotlightDestination.self) { dest in
/// DetailView(id: dest.itemID)
/// }
/// }
/// .onContinueUserActivity(CSSearchableItemActionType) { activity in
/// if let destination = SpotlightSearchHandler.shared.destination(from: activity) {
/// navigationPath.append(destination)
/// }
/// }
/// }
/// }
/// }
/// ```
///
/// ## UIKit Integration (SceneDelegate)
///
/// ```swift
/// func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
/// if let itemID = SpotlightSearchHandler.shared.extractItemID(from: userActivity) {
/// navigate(to: itemID)
/// }
/// }
/// ```
@Observable
final class SpotlightSearchHandler {
static let shared = SpotlightSearchHandler()
/// The most recently received item ID from a Spotlight tap.
/// Observe this to react to Spotlight navigation.
private(set) var pendingItemID: String?
// MARK: - Extract
/// Extract the searchable item identifier from a Spotlight user activity.
///
/// Returns `nil` if the activity is not a Spotlight search continuation
/// or if the identifier is missing.
func extractItemID(from activity: NSUserActivity) -> String? {
guard activity.activityType == CSSearchableItemActionType else { return nil }
return activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
}
/// Extract a query string if the user launched the app from Spotlight
/// with a search query (iOS 16+).
func extractQuery(from activity: NSUserActivity) -> String? {
guard activity.activityType == CSQueryContinuationActionType else { return nil }
return activity.userInfo?[CSSearchQueryString] as? String
}
// MARK: - Handle
/// Handle a Spotlight continuation activity.
///
/// Extracts the item ID and stores it as `pendingItemID` for observation.
/// Returns the extracted item ID, or nil if not a valid Spotlight activity.
@discardableResult
func handle(_ activity: NSUserActivity) -> String? {
if let itemID = extractItemID(from: activity) {
pendingItemID = itemID
return itemID
}
return nil
}
/// Clear the pending item after navigation is complete.
func clearPending() {
pendingItemID = nil
}
}
// MARK: - Navigation Destination
/// A Hashable destination for use with `NavigationStack` and `.navigationDestination`.
struct SpotlightDestination: Hashable {
let itemID: String
let domain: String?
init(itemID: String, domain: String? = nil) {
self.itemID = itemID
self.domain = domain
}
}
extension SpotlightSearchHandler {
/// Create a `SpotlightDestination` from a Spotlight user activity.
///
/// Use with `NavigationStack`:
/// ```swift
/// .navigationDestination(for: SpotlightDestination.self) { dest in
/// resolveView(for: dest)
/// }
/// ```
func destination(from activity: NSUserActivity) -> SpotlightDestination? {
guard let itemID = extractItemID(from: activity) else { return nil }
return SpotlightDestination(itemID: itemID)
}
}SpotlightSyncModifier.swift
import SwiftUI
import CoreSpotlight
/// ViewModifier that automatically indexes content when a view appears
/// and optionally deindexes when it disappears.
///
/// Usage:
/// ```swift
/// // Index when view appears
/// ArticleDetailView(article: article)
/// .spotlightIndexed(article)
///
/// // Index on appear, deindex on disappear
/// ArticleDetailView(article: article)
/// .spotlightIndexed(article, removeOnDisappear: true)
/// ```
struct SpotlightSyncModifier<T: SpotlightIndexable>: ViewModifier {
let item: T
let removeOnDisappear: Bool
func body(content: Content) -> some View {
content
.task {
await SpotlightIndexManager.shared.index(item: item)
}
.onDisappear {
if removeOnDisappear {
Task {
await SpotlightIndexManager.shared.remove(identifiers: [item.spotlightID])
}
}
}
}
}
extension View {
/// Index this content in Spotlight when the view appears.
///
/// - Parameters:
/// - item: The `SpotlightIndexable` item to index.
/// - removeOnDisappear: If `true`, removes the item from the index when the view disappears.
/// Defaults to `false` (items remain indexed after viewing).
func spotlightIndexed<T: SpotlightIndexable>(
_ item: T,
removeOnDisappear: Bool = false
) -> some View {
modifier(SpotlightSyncModifier(item: item, removeOnDisappear: removeOnDisappear))
}
}
// MARK: - Siri Suggestions Activity Modifier
/// ViewModifier that creates an NSUserActivity for Siri suggestions.
///
/// Makes content eligible for Siri suggestions and Spotlight search
/// via the user activity system (complementary to CSSearchableItem indexing).
///
/// Usage:
/// ```swift
/// ArticleView(article: article)
/// .spotlightActivity(
/// id: article.id.uuidString,
/// title: article.title,
/// description: article.body.prefix(200),
/// keywords: article.tags,
/// eligibleForPrediction: true
/// )
/// ```
struct SpotlightActivityModifier: ViewModifier {
let id: String
let activityType: String
let title: String
let description: String?
let keywords: Set<String>
let eligibleForPrediction: Bool
func body(content: Content) -> some View {
content
.userActivity(activityType) { activity in
activity.title = title
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = eligibleForPrediction
activity.keywords = keywords
activity.contentAttributeSet = {
let attributes = CSSearchableItemAttributeSet(contentType: .content)
attributes.title = title
attributes.contentDescription = description
attributes.keywords = Array(keywords)
attributes.relatedUniqueIdentifier = id
return attributes
}()
}
}
}
extension View {
/// Advertise this content as a Siri suggestion and Spotlight result
/// via NSUserActivity.
///
/// - Parameters:
/// - id: Unique identifier matching the CSSearchableItem's uniqueIdentifier.
/// - activityType: The activity type (use reverse-DNS, e.g., "com.myapp.viewArticle").
/// - title: Display title for the suggestion.
/// - description: Optional description text.
/// - keywords: Search keywords.
/// - eligibleForPrediction: Whether Siri can proactively suggest this content.
func spotlightActivity(
id: String,
activityType: String = "com.myapp.viewItem",
title: String,
description: String? = nil,
keywords: Set<String> = [],
eligibleForPrediction: Bool = true
) -> some View {
modifier(SpotlightActivityModifier(
id: id,
activityType: activityType,
title: title,
description: description,
keywords: keywords,
eligibleForPrediction: eligibleForPrediction
))
}
}