# Share Extensions in Practice – Part 3: App Groups & Core Data for Shared Storage

Table of Contents

This is part 3 of a 4-part series on building robust iOS Share Extensions:


While the Keychain handles sensitive data like authentication tokens, what about everything else? Content shared from other apps, metadata, offline queues, user preferences – all of this needs a robust shared storage solution.

This is where App Groups come in. They provide a shared container accessible to both your main app and Share Extension. Combined with Core Data, you get a powerful foundation for offline persistence and seamless data synchronization.

Here’s the strategy: The Share Extension quickly saves shared content locally for instant user feedback. The main app handles the heavy lifting – syncing to servers, processing metadata, and providing full management UI. Both work with the same data store, but with clear responsibilities.

Setting up App GroupsLink to heading

App Groups create a shared file system container that both your main app and Share Extension can access. This is the foundation for all shared data between the two processes.

Configure App Group CapabilitiesLink to heading

First, enable App Groups in both your main app and Share Extension targets. This creates a shared container that both processes can access:

In Xcode:

  1. Select your main app target → Signing & Capabilities
  2. Click + CapabilityApp Groups
  3. Click + and add: group.com.yourcompany.yourapp.shared
  4. Repeat for your Share Extension target with the identical group identifier

Important: Both targets must use exactly the same App Group identifier. Even a single character difference will break sharing.

Access the Shared ContainerLink to heading

With App Groups configured, you can access the shared file system:

import Foundation
class SharedContainer {
static let groupIdentifier = "group.com.yourcompany.yourapp.shared"
static var sharedContainerURL: URL? {
return FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupIdentifier
)
}
static var documentsURL: URL? {
return sharedContainerURL?.appendingPathComponent("Documents")
}
static var coreDataURL: URL? {
return sharedContainerURL?.appendingPathComponent("CoreDataStore.sqlite")
}
}

This gives both targets access to the same file system location where you can store Core Data databases, temporary files, or any other shared resources.

Core Data Stack for App GroupsLink to heading

Once you have App Groups configured, the next step is setting up Core Data to use the shared container. This involves placing your persistent store in the shared location and handling any existing data migration.

Migrating Existing Databases to App GroupsLink to heading

If you’re adding App Groups to an existing app with Core Data, you’ll need to migrate the existing database to the shared container. This is a common scenario when adding Share Extensions to established apps.

The challenge: Your existing Core Data store lives in the app’s private Documents directory, but the Share Extension can’t access it. You need to move it to the shared container while preserving all user data.

Here’s a simplified migration approach that you can integrate into your Core Data setup:

// Add to your SharedCoreDataManager
private func migrateStoreToAppGroupIfNeeded(targetURL: URL) {
let fileManager = FileManager.default
// Check if store already exists in app group
if fileManager.fileExists(atPath: targetURL.path) {
return
}
// Look for existing database in Documents directory
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return
}
let oldStoreURL = documentsURL.appendingPathComponent("SharedDataModel.sqlite")
if fileManager.fileExists(atPath: oldStoreURL.path) {
do {
// Create app group directory
let appGroupDirectory = targetURL.deletingLastPathComponent()
try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
// Copy database files
try fileManager.copyItem(at: oldStoreURL, to: targetURL)
// Copy WAL and SHM files if they exist
let walURL = oldStoreURL.appendingPathExtension("wal")
let shmURL = oldStoreURL.appendingPathExtension("shm")
if fileManager.fileExists(atPath: walURL.path) {
try fileManager.copyItem(at: walURL, to: targetURL.appendingPathExtension("wal"))
}
if fileManager.fileExists(atPath: shmURL.path) {
try fileManager.copyItem(at: shmURL, to: targetURL.appendingPathExtension("shm"))
}
print("Database migrated to App Group successfully")
} catch {
print("Migration failed: \(error)")
}
}
}

Key points:

  • Migration happens automatically on first launch
  • Copies all SQLite files (.sqlite, .wal, .shm)
  • Graceful error handling that doesn’t crash the app

Shared Core Data ManagerLink to heading

Create a Core Data manager that both targets can use. The key is placing the persistent store in the shared container:

import CoreData
class SharedCoreDataManager {
static let shared = SharedCoreDataManager()
private let groupIdentifier = "group.com.yourcompany.yourapp.shared"
private init() {}
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "SharedDataModel")
// Use App Group container for shared access
if let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)?
.appendingPathComponent("SharedDataModel.sqlite") {
// Migrate existing database if needed
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [storeDescription]
}
container.loadPersistentStores { _, error in
if let error = error {
print("Core Data failed to load: \(error)")
}
}
// Merge policy for conflicts (important when both app and extension access simultaneously)
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
func save() {
try? context.save()
}
}

Core Data Model Target MembershipLink to heading

⚠️ Critical step often forgotten: Your .xcdatamodeld file must be included in both targets. This is the most common cause of Share Extension crashes when using Core Data.

In Xcode:

  1. Select your DataModel.xcdatamodeld file
  2. In the File Inspector, check Target Membership for both:
    • Your main app target
    • Your Share Extension target

Without this step: Your Share Extension will crash at runtime when trying to access Core Data entities, with confusing error messages about unknown entity types.

Entity Design Best PracticesLink to heading

When designing entities for App Group sharing:

Essential attributes for shared entities:

  • UUID primary key: Prevents ID conflicts between targets
  • Timestamps: For conflict resolution (createdAt, updatedAt)
  • Sync status: Track what needs server upload (pending, synced, failed)
  • Simple data types: Strings, Dates, Booleans work best across targets

Example BookmarkEntity with these attributes:

  • id: UUID (primary key)
  • url: String
  • title: String
  • createdAt: Date
  • syncStatus: String

Using Core Data in the Share ExtensionLink to heading

With the shared Core Data stack in place, your Share Extension can now save data directly to the same store your main app uses. The key is keeping operations simple and fast for a responsive user experience.

Local Storage StrategyLink to heading

Share Extensions can make API calls directly, but local storage provides a robust fallback and better user experience in many scenarios.

When to use local storage:

  • Server unreachable - Network issues, server downtime, maintenance
  • Instant feedback - Users get immediate confirmation without waiting for network
  • Offline functionality - Works in airplane mode or poor connectivity
  • Queue mechanism - Store items locally, sync later when convenient

Simple Wrapper ApproachLink to heading

Use a simple wrapper class that handles the Core Data operations:

class OfflineBookmarkManager {
static let shared = OfflineBookmarkManager()
private init() {}
func saveBookmark(url: String, title: String) -> Bool {
let context = SharedCoreDataManager.shared.context
do {
let entity = BookmarkEntity(context: context)
entity.id = UUID()
entity.url = url
entity.title = title
entity.syncStatus = "pending"
try context.save()
return true
} catch {
print("Failed to save bookmark: \(error)")
return false
}
}
}
// In your Share Extension
class ShareViewController: SLComposeServiceViewController {
override func didSelectPost() {
guard let url = extractSharedURL() else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Save locally immediately
let success = OfflineBookmarkManager.shared.saveBookmark(
url: url.absoluteString,
title: contentText ?? ""
)
// Complete extension regardless of save result
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}

This wrapper provides a fallback when API calls fail or when you want to queue items for later processing. You can also use local storage as the primary approach - many apps store shared content locally and sync later, or don’t sync at all depending on the use case.

Performance note: For saving many items at once (like bulk imports), consider using NSBatchInsertRequest instead of individual entity creation for better performance.

Synchronization in the Main AppLink to heading

While the Share Extension handles immediate data capture, the main app takes responsibility for processing, organizing, and syncing that data to external services. This separation keeps the extension lightweight while providing full functionality in the main app.

Sync Strategy with User ControlLink to heading

If your use case requires server sync, consider giving users control over when it happens. This provides better battery life and allows users to sync on their own terms.

Find the right integration point - Main menu, tab view, or sidebar are good entry points where users naturally expect sync functionality. This works consistently across all device types.

@Observable
class OfflineBookmarksViewModel {
var pendingCount: Int = 0
var isSyncing: Bool = false
private let getPendingCountUseCase = GetPendingItemsCountUseCase()
private let syncPendingItemsUseCase = SyncPendingItemsUseCase()
init() {
refreshPendingCount()
}
func refreshPendingCount() {
pendingCount = getPendingCountUseCase.execute()
}
// Simplified sync flow - adapt to your specific needs
func syncPendingItems() async {
guard pendingCount > 0 else { return }
isSyncing = true
await syncPendingItemsUseCase.execute()
refreshPendingCount()
isSyncing = false
}
}
// In your main view (TabView, NavigationView, etc.)
struct MainView: View {
@State private var syncViewModel = OfflineBookmarksViewModel()
var body: some View {
TabView {
// Your content tabs
if syncViewModel.pendingCount > 0 {
SyncView()
.tabItem {
Label("Sync", systemImage: "arrow.triangle.2.circlepath")
}
.badge(syncViewModel.pendingCount)
}
}
}
}

Benefits of user-controlled sync:

  • Battery efficiency - No background network requests
  • User awareness - Clear indication when data needs syncing
  • Network control - Users can choose when to use data/Wi-Fi
  • Transparency - Users see exactly what’s being synced

Data Flow OverviewLink to heading

Now that we’ve covered all the implementation details, here’s how everything works together:

App Group Container   

Save locally  

Also save to  

Read data  

Display & Sync  

API Sync  

Direct API  

User Shares Content   

Share Extension   

Other Extensions   

Core Data Store   

Success Feedback   

Main App   

External Server   

The same App Group pattern works for any type of extension - photo editing extensions, action extensions, or custom keyboard extensions can all share data through the same Core Data store.

ConclusionLink to heading

App Groups and Core Data provide a solid foundation for sharing data between your main app and Share Extension. The shared container enables seamless data flow, while Core Data ensures reliable persistence and conflict resolution.

Key takeaways:

  • Use App Groups for shared file system access
  • Place Core Data store in the shared container and migrate existing data if needed
  • Keep Share Extension saves fast and simple
  • Let the main app handle complex sync operations

The beauty of this approach is clear separation of concerns: the Share Extension handles immediate user needs while the main app manages the complex synchronization logic. Users get instant feedback, and you get robust data management.

Now that we’ve covered shared storage and synchronization, the next article will explore how to modernize your Share Extension’s user interface with SwiftUI. This is particularly interesting for creating more interactive and visually appealing extensions while maintaining the robust data architecture we’ve built.

Next up: SwiftUI integration for modern Share Extension UI with hosting controllers (coming soon).


This “Share Extensions in Practice” series shows you step by step how to develop stable, secure, and user-friendly Share Extensions for iOS.

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts