●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
Bolting WidgetKit onto a Rork iOS App: Implementation Notes from Rolling It Out to Six Wallpaper Apps Simultaneously
A hands-on note on adding a WidgetKit Extension to a Rork-generated iOS app, with operational lessons from rolling out widgets to six wallpaper apps at once — App Group plumbing, Timeline Provider choices, memory ceilings, and ASO side effects.
I'm Masaki Hirokawa, building iOS apps as a solo developer since 2014. I recently bolted WidgetKit-based widgets onto a set of wallpaper apps whose foundations were originally generated by Rork, and rolled them out to my existing lineup that has now passed 50 million cumulative downloads. Rork ships a React Native foundation quickly, but a Widget Extension cannot host a JavaScript runtime — so adding one after the fact means designing a small, self-contained native world that sits on top of the codebase Rork gave you.
These are the rollout notes from doing that across six wallpaper apps at once. I'll focus on the parts that tend to bite when widgets are added retroactively: Timeline Provider choices, App Group disk I/O, and the strict 30 MB memory ceiling for widgets.
Why I added widgets to Rork-based apps after the fact
The trigger was a small cluster of App Store reviews asking to "see today's wallpaper without opening the app." Until then, all wallpaper data lived in the Rork-generated React Native UI, so adding widgets meant drawing a new boundary between the JS layer and the native layer.
A second reason was ASO. App Store listings display a "Widgets" badge in screenshots once an app ships a widget, and searches for widget-related terms become more likely to surface the listing. In the two weeks after Beautiful HD Wallpapers shipped its widget update, impressions for the query "wallpaper widget" rose about 11% versus the prior two weeks. The absolute volume is modest, but when each app takes only about a day to a day and a half to retrofit, the math works out.
Operationally, widgets also let users "browse a little" without launching the app. That improved how often people came back to the wallpaper detail screen, which is harder to measure in numbers but visible in retention. Of the six apps, only the home-screen widget kept consistent weekly use; lock-screen widgets spiked briefly on release and then settled into a much smaller share of impressions.
App Group and shared storage — bridging Rork's JS layer to a native widget
A widget extension cannot execute JavaScript, so whatever the Rork-generated app stores via AsyncStorage or React state is invisible to the widget by default. The bridge is an App Group: a shared UserDefaults suite plus a shared container directory on disk.
In my setup, the main app exposes a thin native module that writes to the App Group from React Native via a bridge. The widget reads the same App Group suite directly.
// AppGroup.swift — included in BOTH the main app target and the widget extensionimport Foundationenum AppGroup { static let identifier = "group.net.dolice.wallpapers.shared" static var defaults: UserDefaults { guard let d = UserDefaults(suiteName: identifier) else { fatalError("App Group suite missing. Check Capabilities.") } return d } static var containerURL: URL { guard let url = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: identifier ) else { fatalError("App Group container unavailable. Check provisioning.") } return url }}
Two pitfalls are worth flagging. First, the App Group identifier has to be enabled on the Signing & Capabilities tab of both the main app and the widget extension. Forgetting this on the widget target produces a release build whose widget is silently blank; the simulator can mask it, so I always re-verify on a TestFlight build.
Second, keep filenames in the shared container ASCII and short. I observed several intermittent failures on devices in low-power mode when filenames included localized characters or were unusually long. Today I store thumbnails as wallpaper-thumb.jpg, and any metadata goes into UserDefaults via Codable.
✦
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
✦A minimum-viable recipe for adding a WidgetKit Extension to a Rork-generated React Native iOS project, without touching the JS layer
✦How I chose between three Timeline Provider patterns (static, timer-based, app-driven) when rolling widgets out to six wallpaper apps in parallel
✦Operational lessons on AppIntent-based configuration, App Group disk I/O, the 30 MB widget memory ceiling, and Lock Screen widget tradeoffs
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.
Minimum scaffolding to add a Widget Extension target
Adding a Widget Extension to a Rork-generated Xcode project takes three steps when you go the shortest route.
In Xcode, choose File > New > Target..., pick "Widget Extension", and turn on "Include Configuration App Intent".
Enable the same App Group ID in Signing & Capabilities on both the main app and the new widget extension.
Confirm that the widget extension shows up under "Embed Foundation Extensions" in the main target.
One Rork-specific gotcha: the React Native build steps in the main target's Build Phases tend to get duplicated onto the new widget target, which causes the build to fail trying to copy a JavaScript bundle into the widget. I removed any Bundle React Native code and images-style run scripts from the widget extension's Build Phases. The widget should be pure Swift/SwiftUI.
// WallpaperWidgetBundle.swiftimport WidgetKitimport SwiftUI@mainstruct WallpaperWidgetBundle: WidgetBundle { var body: some Widget { WallpaperHomeWidget() WallpaperLockWidget() }}
Putting @main on the widget bundle lets the home-screen and lock-screen widgets ship in the same extension. I keep their SwiftUI views deliberately separate because the lock-screen environment has very different memory and rendering constraints, which I cover later.
Timeline Provider — choosing between static, timer-based, and app-driven
A TimelineProvider declares when the widget should refresh. When retrofitting widgets, I find myself reaching for one of three patterns.
Static timeline — only updates when the app updates.
Timer timeline — refreshes every 30 minutes to a few hours.
App-driven timeline — refreshed manually via WidgetCenter.shared.reloadAllTimelines().
The smallest possible widget is purely static. It does not consume any of the widget refresh budget the OS allocates per app per day. For four of the six wallpaper apps I went with the timer pattern, and for the two whose users tend to swap favorites manually (Beautiful HD Wallpapers and Healing Wallpaper Plus) I added an app-driven reload alongside it.
If you make timer intervals too short, the system stops honoring them and the widget appears "stuck" to users. In practice I settled on a minimum 30-minute spacing, and only force an immediate reload when the main app explicitly changes the user's favorite.
struct WallpaperTimelineProvider: TimelineProvider { func placeholder(in context: Context) -> WallpaperEntry { WallpaperEntry(date: Date(), wallpaper: .placeholder) } func getSnapshot(in context: Context, completion: @escaping (WallpaperEntry) -> Void) { completion(WallpaperEntry(date: Date(), wallpaper: WallpaperRepository.current)) } func getTimeline(in context: Context, completion: @escaping (Timeline<WallpaperEntry>) -> Void) { let now = Date() let entries: [WallpaperEntry] = (0..<6).map { offset in let date = Calendar.current.date(byAdding: .minute, value: 30 * offset, to: now)! return WallpaperEntry(date: date, wallpaper: WallpaperRepository.pick(at: date)) } let next = Calendar.current.date(byAdding: .hour, value: 3, to: now)! completion(Timeline(entries: entries, policy: .after(next))) }}
The trick is to seed getTimeline with several entries up front and place the .after boundary a bit further out. The OS reschedules timeline reloads liberally, so a buffered run of entries hides the jitter from users.
AppIntent-based configuration — letting the widget pick a category
Since iOS 17, AppIntent-based configuration is the recommended pattern. Pairing it with AppIntentTimelineProvider lets the widget surface categories and favorites that the Rork-side React Native screens already manage.
import AppIntentsimport WidgetKitstruct PickCategoryIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Pick a category" @Parameter(title: "Category") var category: WallpaperCategory init() {} init(category: WallpaperCategory) { self.category = category }}struct WallpaperHomeWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "WallpaperHomeWidget", intent: PickCategoryIntent.self, provider: WallpaperIntentProvider() ) { entry in WallpaperWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Today's Wallpaper") .description("Show a single picture from the category you choose.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) }}
WallpaperCategory conforms to AppEntity, with its data source reading from the App Group. When you retrofit widgets, that App Group plumbing is what lets the widget configuration sheet show the full list of categories without the main app being running. I learned this the hard way: in early TestFlight builds, the picker showed an empty list because the widget couldn't see the categories until the main app had run at least once and seeded the App Group.
Image display — the 30 MB memory ceiling and App Group disk I/O
Widget extensions have a hard memory budget of roughly 30 MB. Loading a full-resolution wallpaper via UIImage(contentsOfFile:) will cross that line for any 3K–4K image and the widget will simply render as a black box.
The fix is to make the main app emit two image sizes when it picks "today's wallpaper": the original full-resolution file, and a 600×900 JPEG thumbnail. The widget only reads the thumbnail.
extension WallpaperEntry { static func loadThumb() -> UIImage? { let url = AppGroup.containerURL.appendingPathComponent("wallpaper-thumb.jpg") guard let data = try? Data(contentsOf: url) else { return nil } return UIImage(data: data) }}
You still want users to see the high-resolution wallpaper, so the widget tap goes back into the main app's detail screen, which is allowed to load the full image. The lock-screen variant uses accentedRenderingMode and is therefore reduced to a monochrome silhouette in many cases, so I render it as a simple emblem rather than as a representation of the wallpaper itself.
Disk I/O has a quieter trap. Widgets all reload around the same moments (screen-on events, focus-mode changes), and when the main app is writing to the App Group container at the same time, you can occasionally observe what looks like a corrupted file from the widget's perspective. I use atomic writes via a temporary file, and read with a coordinator in performance-sensitive paths.
Lock Screen vs. Home Screen widgets
Lock Screen widgets come in three shapes: circular, rectangular, and inline. For a wallpaper app, the circular and rectangular variants make sense; the inline (a single line under the clock) doesn't.
struct WallpaperLockWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration( kind: "WallpaperLockWidget", provider: WallpaperTimelineProvider() ) { entry in WallpaperLockView(entry: entry) .containerBackground(.clear, for: .widget) } .configurationDisplayName("Today's Pick") .description("A small marker for today's wallpaper on the Lock Screen.") .supportedFamilies([.accessoryCircular, .accessoryRectangular]) }}
The accentedRenderingMode flattens images to the user's tint color, so trying to show a photographic wallpaper there does not work. I ended up rendering a small "logo + date" emblem, which acts more as a tap target back into the app than as a way to display the picture itself. Lock-screen widgets earn their keep as an entry point rather than as a display surface.
Rollout strategy across six apps — Phased Release and Remote Config gating
I shipped widgets to Beautiful HD Wallpapers, Healing Wallpaper Plus, Aurora Wallpapers, Cute Animals Wallpapers, Anime Wallpapers HD, and Minimal Wallpapers in the same TestFlight wave. The App Store releases were staggered over seven days using Phased Release.
I also wired a Firebase Remote Config flag (widget_enabled) that gates calls to WidgetCenter.shared.reloadAllTimelines() and switches the widget's snapshot view content. The widget extension itself ships in every build, but its visible behavior can be silenced server-side. If something goes wrong, I do not need a new app review to mute it.
That safety net paid off on day four. One report came in about a date-formatting glitch in the lock-screen widget under the Japanese locale. I toggled the Remote Config flag for the lock-screen widget while I diagnosed the issue, and it propagated to users within minutes. Avoiding a re-review cycle on a tiny issue is genuinely valuable when you ship as a solo developer.
ASO side effects — the "Widgets" badge and search impressions
On the App Store, widget-capable apps get a "Widgets" badge in their listings and become more eligible for widget-related search queries. In the two weeks around the rollout, Beautiful HD Wallpapers saw impressions for "wallpaper widget" rise about 23% and for "widget" alone about 9%. Click-through change was modest, but impression volume compounds over time.
A small ASO tactic that helped: include a screenshot that shows the widget installed on a home screen. For Healing Wallpaper Plus, putting that screenshot in the seventh slot bumped the share of users who scrolled deep into the screenshot gallery. Recent App Store listings allow more screenshots than before, so placing the widget shot earlier — perhaps in the fourth slot — might be even more effective for newer projects.
What a month of running these widgets actually changed
Looking at the first month: the build cost was roughly a day to a day and a half per app, across six apps. The ad revenue impact was a small positive, but the seven-day retention of the main app moved by about 1.2 percentage points two weeks after the rollout. I attribute that to widgets giving users a low-friction reason to "open for a moment" again.
Widget impressions are not something users typically write about in reviews. The handful of reviews that did mention widgets praised the convenience, but there was no review-volume spike. Treat widgets as a quiet ASO and retention move, not a launch-style driver of downloads.
For me, the pattern of using Rork to bootstrap an app and then layering monetization, observability, and now widgets on top of it has held up well. WidgetKit fits naturally as one more retrofit layer outside of the React Native code. If you also run multiple apps in parallel from a Rork-generated foundation, hopefully these notes save you a few of the late-evening dead ends I walked into.
Share
Thank You for Reading
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.