●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Implementing Share Extensions with Rork Max — Adding Your App to iOS's Share Sheet
A production-ready guide to building iOS share extensions on top of a Rork Max project. Covers App Groups, URL schemes, minimal extension UI, and the gotchas that ship-blocking apps every quarter.
If you've ever built a notes app on iOS, you've probably wished you could let users share a Safari article straight into your app from the system share sheet. I burned two full days early on assuming this could be done in React Native alone. The honest answer: share extensions live in their own native target, and JavaScript bundles cannot be wired into them.
The good news is that pairing Rork Max's native SwiftUI generation with a small share-extension target collapses most of that complexity. In this guide we'll walk through adding a share extension on top of a Rork Max base project, sharing data through an App Group, and deep-linking back into the main app — all with code that actually ships and survives App Review. If you're building a Pocket-style read-later app, a notes app, or a social client that pulls in content from other apps, this is the blueprint.
Why share extensions can't live inside React Native alone
Let's level-set on the platform constraints before any code. Share extensions are part of Apple's "App Extension" architecture: they run in a different process from your main app, with their own signed binary. The little panel that appears in the share sheet is effectively a tiny separate app.
App extensions must be authored in Swift or Objective-C. React Native and Expo manage your main app's bundle, but they don't extend into separate extension targets. Open-source bridges like react-native-share-extension exist, but they're frequently unmaintained and force you into UIKit + a JS runtime that's slow to spin up — a poor fit for the few seconds the OS gives an extension to live.
In real products I prefer main app = Rork (React Native) or Rork Max (SwiftUI), share extension = native SwiftUI. With Rork Max, both halves can sit in a single Xcode project, sharing models and styles where it makes sense.
Choosing the right extension type
iOS ships several extension types, and the naming is genuinely confusing the first time. If your goal is "show my app inside the system share sheet," there is exactly one correct answer.
Share Extension: receive content and persist or send it from inside your app
Action Extension: transform content and hand the result back to the host app
Document Provider: expose your app's files inside the Files app
Read-later, notes, social clients, and "save for later" tools all want a Share Extension.
Step 1: Generate the base project with Rork Max
Start with the main app. I keep the Rork Max prompt deliberately small at this stage so the diff after adding the extension is easy to read.
Generate an iOS-only read-later app with Rork Max:
- SwiftUI native target
- Minimum iOS 17
- Show a list of saved URLs and titles
- On launch, read pending items from the App Group
"group.net.rorklab.readlater.shared" UserDefaults
- Handle the URL scheme "readlater://" with optional ?url=&title=
query parameters appended to the list
- Persist with SwiftData
Open the generated project in Xcode, replace the bundle ID, signing team, and capabilities with your own, and confirm the app builds and runs on a real device before doing anything else. Wiring up an extension on top of a still-broken main app makes signing failures genuinely impossible to triage.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been stuck because share extensions can't be built in React Native alone, you'll get a working Rork Max + native SwiftUI implementation today.
✦You'll master sharing data between an extension and your main app via App Groups, deep-linking with URL schemes, and designing minimal-yet-useful extension UI.
✦You'll be able to ship reading-list, note-taking, or social client apps that pull content from other apps into your own — production-ready from day one.
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
In Xcode, choose File → New → Target → Share Extension. Name the target ShareExt, language Swift. The template hands you a UIKit-based ShareViewController.swift. The first thing I do is rewrite it to embed a SwiftUI view, so I can share styling and small components with the main app.
The SwiftUI side is straightforward. Keep it minimal — extracting URL and title is the only real job at first.
struct SharePayload { let url: URL let title: String}struct ShareView: View { let onSave: (SharePayload) -> Void let onCancel: () -> Void @State private var payload: SharePayload? @State private var loadError: String? var body: some View { NavigationStack { VStack(spacing: 16) { if let p = payload { Text(p.title).font(.headline).lineLimit(2) Text(p.url.absoluteString) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } else if let err = loadError { Text(err).foregroundStyle(.red) } else { ProgressView("Loading…") } } .padding() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onCancel() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { if let p = payload { onSave(p) } } .disabled(payload == nil) } } } }}
One subtle point: SwiftUI views can't reach extensionContext directly. The view controller has to extract the NSExtensionItem and hand the parsed payload into the SwiftUI tree (via initializer, @Environment, or a small observable object). Step 3 shows the parsing logic.
Step 3: Pull URL + title from NSItemProvider
When the user taps Share, iOS hands attached content to the extension as NSItemProvider. Safari sends a URL, Photos sends an image, Mail sends text. For a read-later app, URL plus title is enough.
extension ShareViewController { func extractURLAndTitle(completion: @escaping (SharePayload?) -> Void) { guard let item = extensionContext?.inputItems.first as? NSExtensionItem, let provider = item.attachments?.first else { completion(nil) return } let urlType = UTType.url.identifier if provider.hasItemConformingToTypeIdentifier(urlType) { provider.loadItem(forTypeIdentifier: urlType, options: nil) { (data, error) in let url: URL? = (data as? URL) ?? (data as? String).flatMap { URL(string: $0) } let title = item.attributedContentText?.string ?? url?.absoluteString ?? "" if let u = url { DispatchQueue.main.async { completion(SharePayload(url: u, title: title)) } } else { DispatchQueue.main.async { completion(nil) } } } } else { completion(nil) } }}
Expected console output when triggered from Safari:
[ShareExt] Loaded payload — url: https://example.com/article-title, title: "Article Title - Example"
[ShareExt] Saved to app group queue — pending count: 1
[ShareExt] Opened readlater://import?count=1
The "title is empty" / "URL is nil" failure modes almost always trace back to other host apps using a different UTType. Twitter (X), LINE, and YouTube each pass content via slightly different identifiers. In production, fall back through UTType.url, UTType.plainText, and UTType.text rather than assuming public.url.
Step 4: Hand data to the main app via App Groups
The extension and main app are separate processes — UserDefaults.standard does not cross that boundary. The supported channel is an App Group, accessed via UserDefaults(suiteName:).
Setup checklist:
Create the App Group group.net.rorklab.readlater.shared in the Apple Developer portal.
In Xcode's Signing & Capabilities tab, add the App Group capability to both the main app and the share extension. Forgetting one side is the single most common cause of "writes succeed but reads return empty."
Confirm the bundle IDs of both targets share the same team prefix.
Once that's wired, the main app reads the same suite and drains the queue.
import SwiftUIimport SwiftData@MainActorfinal class PendingImporter: ObservableObject { @Published var importedCount: Int = 0 private let suite = "group.net.rorklab.readlater.shared" func drainPending(into context: ModelContext) { let defaults = UserDefaults(suiteName: suite) let queue = defaults?.array(forKey: "pendingItems") as? [[String: String]] ?? [] var importedURLs: [String] = [] for entry in queue { guard let urlStr = entry["url"], let url = URL(string: urlStr) else { continue } let title = entry["title"] ?? url.absoluteString if !importedURLs.contains(urlStr) { let item = ReadingItem(url: url, title: title, savedAt: .now) context.insert(item) importedURLs.append(urlStr) } } try? context.save() defaults?.removeObject(forKey: "pendingItems") importedCount = importedURLs.count }}
The pattern is "extension queues, main app drains." Trying to write directly to SwiftData from the extension lands you in lock contention and entitlement weirdness. Keep the extension light — it has only seconds before the OS reaps it.
Step 5: Wire up the URL scheme
To trigger the import, the extension launches the main app with a custom URL. Register readlater in Info.plist under CFBundleURLTypes, and handle it via onOpenURL.
@mainstruct ReadLaterApp: App { @StateObject private var importer = PendingImporter() var body: some Scene { WindowGroup { ContentView() .environmentObject(importer) .onOpenURL { url in guard url.scheme == "readlater" else { return } Task { @MainActor in importer.drainPending(into: modelContext) } } } .modelContainer(for: ReadingItem.self) }}
Universal Links are the preferred long-term solution for security and UX, but URL schemes are simpler to ship for an MVP — no apple-app-site-association file, no domain verification headaches. Ship the URL scheme version first; promote to Universal Links once you have real users.
Step 6: Constrain which content types your extension accepts
If you stop here, your extension may appear when sharing photos or PDFs (where it does nothing useful) and disappear from places it should appear. The fix is NSExtensionAttributes in ShareExt/Info.plist.
This restricts activation to web URLs, web pages, and text. Excluding images and video is usually the right default for a read-later or notes app, since users get confused when an extension claims to support images but silently ignores them.
Common pitfalls I've actually shipped
You will run into nearly all of these. Each is something I shipped, watched users report, and quietly hot-fixed.
App Group only enabled on one target
The classic. Open Capabilities for both targets in turn and confirm the same identifier is checked. When data isn't crossing, check this first.
Conflating Universal Links with URL schemes
Schemes (readlater://...) launch apps directly. Universal Links require a server-hosted association file and domain validation. From an extension, the URL scheme path is more reliable; switch to Universal Links once your domain plumbing is solid.
Extension UI overflowing the share sheet
The host gives the extension a fixed-height panel — closer to a .medium sheet detent than a full screen. Aggressive padding (.padding(40)) pushes the Save button off-screen. Keep extension UI ruthlessly minimal.
Doing real work inside the extension
Extensions have only a few seconds of CPU before they're killed. Fetching an article body or generating a thumbnail inside the extension is a recipe for silent failures. Queue light metadata; do the heavy lifting once the main app launches.
App Review rejection
Apple expects extensions to be a natural extension (no pun intended) of the main app's purpose. A translation app shouldn't ship a share extension that saves locations. Add one sentence in your release notes about what the share extension does — review pass-through rates noticeably improve.
Real-world variations
The minimal "queue then drain" design covers most cases, but real products usually need a couple of upgrades.
Social clients accepting shared text: enable SupportsText, then let the extension UI pick which account or board to post to. Persist tokens via Keychain Sharing so both the main app and extension can read them.
Photo-handling apps (organizers, collage tools): pull UIImage from the item provider, but for large originals, copy the file URL into the App Group's shared container rather than passing bytes through UserDefaults. Resizing inside the extension blows the memory cap roughly half the time.
Tying into paid features: extensions can read the StoreKit receipt to gate behavior, but they can't present a purchase UI. The realistic flow is "save locally, then prompt the user to upgrade in the main app" — usually as a one-line note on the extension's confirmation screen.
Architecture decisions and trade-offs
Before writing a line of code, it's worth pausing on a few design decisions that materially affect how your extension feels in production.
Where to render the extension UI. UIKit is what Apple's template generates and is genuinely fine for a share-sheet panel that lives for ten seconds. SwiftUI gives you faster iteration and lets you reuse design tokens with the main app, at the cost of a slightly heavier launch. In my experience the SwiftUI launch overhead is invisible on iPhone 13 and newer; on older devices, the difference is on the order of 80–150ms. I default to SwiftUI now and only fall back to UIKit when I need very tight launch performance.
Whether to render any UI at all. Apple supports a "no UI" mode where the extension processes the share without showing a panel. This is great for "save instantly" UX but bad for discoverability — users don't see anything happen, and they often re-tap thinking it didn't work. I always show a confirmation panel with the title and a Save button. The two-tap cost is worth the user's confidence.
Where to put the heavy lifting. Three options exist: do the work inline inside the extension, queue light metadata and let the main app process on next launch, or queue and trigger a background URLSession from the extension. Inline is fragile, in-extension URLSession is unreliable because the extension may be killed mid-request, and the queue-and-drain pattern is the only one I've shipped that doesn't generate support tickets. The article above uses the queue-and-drain approach for that reason.
One App Group or many. A single shared App Group across all of your app's targets keeps things simple. As your app grows you may want a separate suite for Keychain Sharing or a logging suite for diagnostics, but until you have a concrete reason, one is enough. If you do split, document who owns which keys somewhere in the repo — sharing names quietly across modules is how nil returns sneak into production.
Testing the share extension end-to-end
Share extensions are notoriously hard to debug because they're triggered by other apps. Here's the workflow I've settled on after debugging several apps' worth of edge cases.
Local testing on a real device. Always build to a real iPhone, not the simulator. The simulator's share sheet is unreliable for third-party extensions. Run the main app once first to register the bundle ID, then open Safari and trigger your extension. If it doesn't appear, scroll the share-sheet's icon row to the right and tap "More" — your extension is hidden by default until enabled.
Attaching the debugger. In Xcode, choose Debug → Attach to Process by PID or Name and type your extension's bundle name (e.g., ShareExt). This gives you breakpoints inside the extension. Logging via os_log (subsystem set to your bundle ID) shows up in Console.app filtered by your subsystem — very handy for capturing events that fire after Xcode has detached.
Snapshot testing. SwiftUI views inside the extension can be unit tested with the same techniques you use elsewhere — ViewInspector or snapshot testing libraries like swift-snapshot-testing. The extension's view controller logic is a separate story; mock the extensionContext boundary or factor the parsing logic into a pure function that takes NSExtensionItem and returns SharePayload.
Test with real source apps. Once you've verified Safari → extension, run through Twitter (X), LINE, YouTube, Threads, and Mail. Each has subtle differences in how they pass content. The first time I tested with the YouTube app, the URL came through as a string rather than a URL object — easy to fix once you know to look, hard to find by reading docs.
Performance considerations
Extensions live inside a tight memory and CPU budget that's separate from your main app's. iOS will silently kill an extension that exceeds roughly 120 MB of resident memory or runs longer than about 30 seconds (the exact numbers shift between OS versions, but assume tight constraints).
The most common cause of mysterious extension failures is image processing. Loading a full-resolution image from Photos and decoding it into a UIImage can easily blow past the memory budget. If your extension touches images, downscale them before holding onto a decoded representation, or copy the original file into the App Group container and process it in the main app instead.
Networking is the second pitfall. The OS may suspend the extension at any time, so an in-flight URLSession task that's three seconds from completion can be cut off without warning. If you absolutely need to upload from the extension itself, use a background URLSession configured with sharedContainerIdentifier set to your App Group; the system will continue the upload after the extension exits and notify the main app's application(_:handleEventsForBackgroundURLSession:completionHandler:). This pattern is non-trivial; the queue-and-drain approach is far easier.
Localizing the extension
If you ship in multiple languages, your extension also needs to be localized. The extension target has its own InfoPlist.strings files where the CFBundleDisplayName lives — that's the name shown in the share-sheet row. I've seen teams localize the main app perfectly and forget the extension, leaving Japanese users with an English-named extension in their share sheet.
For SwiftUI strings inside the extension, normal NSLocalizedString and LocalizedStringKey work as expected, but they read from the extension target's Localizable.strings, not the main app's. Either duplicate strings into the extension target or factor shared strings into a Swift Package consumed by both targets. I've found the package approach scales better past two or three languages.
App Review and privacy considerations
Extensions are surfaced prominently in the system share sheet, so they get extra scrutiny. Two things have triggered rejections for projects I've shipped:
Privacy manifest mismatches. If your extension reads a required reason API (for example, UserDefaults or file timestamps for cached data), make sure the extension target's PrivacyInfo.xcprivacy declares the same reason codes as your main app. The reviewer's tool flags inconsistencies between targets even when both are technically correct.
Misleading activation rules. Setting NSExtensionActivationSupportsImageWithMaxCount to a high number when your app does nothing useful with images is a fast path to rejection. Reviewers test the extension by sharing each declared content type. If yours just shows a generic "Saved!" message for content it doesn't actually save, expect a rejection note.
A short paragraph in your App Store release notes describing what the extension does and why users would want it has, in my experience, materially shortened review times.
Advanced patterns: enriching shared content before save
Once the basic save flow is shipping, the next reasonable upgrade is enriching the saved item with metadata. Read-later apps fetch open-graph titles and hero images; notes apps detect language and add tags; social clients show the user a preview of how their cross-post will look. The temptation is to do this enrichment inside the extension because the user is staring at a panel and the work feels related.
I've tried both placements. Doing the enrichment in the main app, after the queue drains, is faster to ship and more reliable. The user clicks Save, the extension closes immediately, and the main app fetches metadata in the background and updates the entry. By the time the user opens the main app, the title and image are already filled in. The "wait for fetch in extension" path adds latency that users notice and brings unpredictable failure modes when networks are slow.
If you do need synchronous enrichment in the extension — for example, your business logic must reject an unsupported URL before saving — keep the network call below two seconds with an explicit timeout. Show a ProgressView immediately so the user understands why the panel is waiting. And always fall back gracefully: a save with title = url is better than refusing to save at all because the network was slow.
Migrating from a JavaScript bridge to native
Some readers will arrive here from an existing React Native app that uses one of the older share-extension bridges. Migrating to a native extension while keeping the main app on React Native is entirely possible — and is sometimes a better stopping point than rewriting the whole app.
The migration shape I recommend is:
Keep the main React Native app exactly as it is.
Add a new native share-extension target written in SwiftUI as described above.
Replace the bridge's data path with App Group UserDefaults.
On the React Native side, expose a tiny native module that reads the App Group queue when the app launches or returns to the foreground, and emits the items as a JS event for your existing handler logic.
The native module is roughly 30 lines of Swift plus a thin Objective-C header. Both the bridge to JS and the App Group read are familiar territory for any React Native team, and you avoid the maintenance hazards of an unmaintained share-extension OSS package.
This kind of "split the toughest 5% off as native" migration is exactly where Rork Max shines: even if the main app stays in Rork's React Native flow, having Rork Max generate the native target with proper signing, App Group capability, and a sensible SwiftUI scaffold removes the most painful parts of starting from scratch in Xcode.
Versioning the App Group payload format
A subtle but important consideration: once your extension is shipping, you may want to change the shape of the data you queue. Maybe today you queue { url, title, savedAt }, and a year from now you'd like to add { tags, sourceApp }. The risk is that an older version of the main app, still installed on a user's device, might receive payloads from a newer extension after the user updates only the extension portion of the app — and crash on a missing key.
In practice, all targets in your bundle update together when the user installs the new build, so this isn't a real risk for the same app. But it becomes a real risk if you split your extension into a separate Swift Package or share App Group data with another app on disk. The defensive pattern is to include a formatVersion field in every queued item and to write the main app's drain logic to ignore unknown versions rather than crash. Three lines of code today saves a class of mysterious crashes later.
When draining, check entry["formatVersion"] as? Int == 1 before decoding. Future versions add new branches; old data passes through cleanly.
Wrapping up
Share extensions aren't a "nice to have" — for any app whose value comes from capturing content, they're table stakes. Pairing Rork Max's native SwiftUI generation with a small extension target finally makes them practical for indie developers who started in React Native land.
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.