React Native iOS Home Screen Widgets with Expo (2026): apple-targets, WidgetKit, and App Groups

Ship iOS home screen widgets from an Expo React Native app using @bacons/apple-targets. Covers App Groups, SwiftUI layouts, App Intents on iOS 17+, deep-linking, and the debugging traps that eat weekends.

Updated: August 15, 2026

iOS home screen widgets in a React Native Expo app are built with a separate Swift widget extension target that talks to your React Native code through an App Group and UserDefaults. The pragmatic path in 2026 is @bacons/apple-targets: it generates the Xcode target, wires up the App Group entitlement during prebuild, and lets you write a real SwiftUI layout that the system can render even when your app isn't running. So, let's walk through the full pipeline: target generation, shared storage, SwiftUI layout, App Intents for iOS 17+ interactivity, deep-linking, and the debugging traps that eat weekends.

  • iOS widgets are separate WidgetKit extension targets. React Native never runs inside them, so the UI has to be written in SwiftUI.
  • @bacons/apple-targets is the production-ready path today. expo-widgets is an alpha that renders @expo/ui components as SwiftUI but isn't ready for the App Store yet.
  • Data crosses the sandbox boundary through an App Group plus a shared UserDefaults suite. ExtensionStorage from @bacons/apple-targets handles both sides.
  • iOS 17+ lets you drop SwiftUI Buttons wired to AppIntents directly into the widget for real interactivity without opening your app.
  • Widgets deep-link into React Native screens with the widgetURL modifier, which fires an expo-linking URL you handle in your router.
  • Requires Xcode 16, CocoaPods 1.16.2, and Expo SDK 53 or newer. The New Architecture is fully supported.

What are iOS home screen widgets in an Expo app?

An iOS home screen widget is a small, glanceable UI surface that the system renders on a schedule using WidgetKit. From your React Native app's perspective, a widget is a completely separate Xcode target. It has its own bundle identifier, its own sandbox, its own memory budget, and its own product binary. React Native never boots inside a widget. The layout has to be expressed in SwiftUI (or SwiftUI-shaped primitives) because the system needs to render your widget even when your app is not running, sometimes in the background, sometimes days after last launch.

That constraint is why "just render my React tree" has never worked for widgets. The OS gives the extension a hard time budget (historically around 30 seconds of CPU per render pass, and less than that on cold widget rendering) and no ability to spin up a JS runtime, load bundles from disk, or hit the network arbitrarily. You describe the widget as a timeline of entries, each entry contains the data needed to render one snapshot, and WidgetKit picks the entry whose date matches the current time.

Interactive widgets (iOS 17 and later) added AppIntent-backed buttons that run a tiny piece of Swift when tapped, and then re-render the timeline. Everything else your widget shows is baked into the entries you supplied. Once you accept that split, the rest of the pipeline stops feeling weird.

apple-targets vs expo-widgets: which should you pick in 2026?

Two libraries dominate the space right now. @bacons/apple-targets is Evan Bacon's config plugin that generates a real Xcode extension target and lets you write SwiftUI directly. expo-widgets is the newer, Expo-team-maintained alpha that lets you describe the widget as a React component using @expo/ui, which then compiles down to SwiftUI at prebuild time. Both use Continuous Native Generation, so neither requires you to hand-edit the ios/ directory.

Dimension@bacons/apple-targetsexpo-widgets (alpha)
Release status (Aug 2026)Stable, used in productionAlpha, API changes weekly
How you describe the layoutSwiftUI in a widget.swift fileReact components from @expo/ui
Learning curveYou need to learn SwiftUI basicsFamiliar JSX, but a small subset of props
Layout ceilingAnything SwiftUI can render, including CanvasOnly what @expo/ui maps: Text, VStack, HStack, Image
Interactive widgets (iOS 17+)Yes, native AppIntents in SwiftLimited, intents surface is still in flux
Live Activities supportYes, same pluginYes, unified API
Build complexityStandard prebuild + Xcode targetPrebuild only, no Xcode edits
Recommended forAnyone shipping a widget todayPrototypes and future-you in 12 months

Honestly, I've shipped widgets in three production apps this year, all on @bacons/apple-targets. The pitch for expo-widgets is real (writing widget UI in JSX is genuinely nicer), but the alpha's layout primitives are still a strict subset of SwiftUI. Every non-trivial design I've tried, whether a chart, a progress ring, or a two-column stat block with custom fonts, ran into either "not supported yet" or a rendering quirk that I couldn't debug because the generated Swift is opaque. Until the API freezes and Live Activities parity lands, I'd take the SwiftUI hit.

How do you add an iOS widget to a React Native Expo app?

Assume you have an Expo SDK 53+ project with Continuous Native Generation (i.e. no committed ios/ folder). Adding a widget target is three commands:

# 1. Add the plugin
npx expo install @bacons/apple-targets

# 2. Generate a widget target scaffold into /targets/widget
npx create-target widget

# 3. Regenerate the native project so Xcode picks up the new target
npx expo prebuild --clean

The scaffold drops a targets/widget/ folder into your repo containing four files: expo-target.config.js (where the App Group and entitlements are declared), widget.swift (the SwiftUI layout and timeline provider), Info.plist, and a placeholder assets.xcassets. Everything under targets/ is authoritative. expo prebuild regenerates the actual Xcode target from these files on every run, which is why you should never edit the extension inside Xcode and expect the change to survive.

Open app.json (or app.config.ts) and add the plugin plus the App Group entitlement to your main app:

{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.acme.taskflow",
      "entitlements": {
        "com.apple.security.application-groups": [
          "group.com.acme.taskflow"
        ]
      }
    },
    "plugins": [
      "@bacons/apple-targets"
    ]
  }
}

Then edit targets/widget/expo-target.config.js to declare the matching group:

/** @type {import('@bacons/apple-targets').Config} */
module.exports = {
  type: 'widget',
  icon: '../../assets/widget-icon.png',
  colors: {
    $accent: '#0A84FF',
    $widgetBackground: '#FFFFFF',
  },
  entitlements: {
    'com.apple.security.application-groups': ['group.com.acme.taskflow'],
  },
  frameworks: ['SwiftUI', 'WidgetKit'],
};

Run npx expo prebuild --clean one more time and then npx expo run:ios to build. Open the resulting workspace in Xcode 16, pick the widget scheme from the target dropdown, and hit Run. The simulator will boot straight into a "widget preview" view with your scaffold layout on the home screen.

Sharing data with App Groups and shared UserDefaults

The widget target has its own sandbox, so it cannot read your app's AsyncStorage, MMKV, or SecureStore. To move data across the boundary, iOS provides App Groups: a shared container that both the app process and the extension process can read and write. The canonical shared-storage API on top of an App Group is UserDefaults with a suite name that matches the group identifier.

On the Swift side, the widget reads shared values like this:

import Foundation

struct SharedStore {
  static let suite = UserDefaults(suiteName: "group.com.acme.taskflow")

  static func todayTaskCount() -> Int {
    return suite?.integer(forKey: "todayTaskCount") ?? 0
  }

  static func nextDueTitle() -> String {
    return suite?.string(forKey: "nextDueTitle") ?? "All caught up"
  }
}

On the React Native side, @bacons/apple-targets exposes ExtensionStorage, a thin wrapper around the same UserDefaults suite:

import { ExtensionStorage } from '@bacons/apple-targets';

const widgetStore = new ExtensionStorage('group.com.acme.taskflow');

export function syncWidget(state: {
  todayTaskCount: number;
  nextDueTitle: string;
}) {
  widgetStore.set('todayTaskCount', state.todayTaskCount);
  widgetStore.set('nextDueTitle', state.nextDueTitle);
  // Nudge WidgetKit to re-render the timeline with the new values.
  ExtensionStorage.reloadWidget();
}

Call syncWidget from wherever your task list mutates: the reducer, the mutation callback of your TanStack Query hook, or the write path of your local database. Don't spray it on every render. Widget reloads are cheap but not free, and calling reloadWidget in a tight loop will burn CPU on the extension. If you're already storing tasks in a fast key-value store, take a look at the tradeoffs in our comparison of MMKV versus AsyncStorage. MMKV plays especially well here because a single write path can feed both the JS side and the widget's shared UserDefaults.

Writing the SwiftUI widget layout

The widget.swift file generated by create-target has three logical pieces: a TimelineEntry struct (the data snapshot at a point in time), a TimelineProvider (how WidgetKit gets entries), and the SwiftUI View that renders one entry. Here's a compact but real layout for a "today's tasks" widget that reads from the shared store:

import WidgetKit
import SwiftUI

struct TasksEntry: TimelineEntry {
  let date: Date
  let count: Int
  let nextTitle: String
}

struct TasksProvider: TimelineProvider {
  func placeholder(in context: Context) -> TasksEntry {
    TasksEntry(date: Date(), count: 3, nextTitle: "Review PR #482")
  }

  func getSnapshot(in context: Context,
                   completion: @escaping (TasksEntry) -> Void) {
    completion(currentEntry())
  }

  func getTimeline(in context: Context,
                   completion: @escaping (Timeline<TasksEntry>) -> Void) {
    let entry = currentEntry()
    // Refresh at least every 30 minutes even if the app never nudges us.
    let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
    completion(Timeline(entries: [entry], policy: .after(next)))
  }

  private func currentEntry() -> TasksEntry {
    TasksEntry(
      date: Date(),
      count: SharedStore.todayTaskCount(),
      nextTitle: SharedStore.nextDueTitle()
    )
  }
}

struct TasksWidgetView: View {
  var entry: TasksEntry

  var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      Text("Today")
        .font(.caption).foregroundStyle(.secondary)
      Text("\(entry.count)")
        .font(.system(size: 40, weight: .bold, design: .rounded))
      Text(entry.nextTitle)
        .font(.footnote).lineLimit(2)
    }
    .padding()
    .containerBackground(.background, for: .widget)
  }
}

@main
struct TasksWidget: Widget {
  var body: some WidgetConfiguration {
    StaticConfiguration(kind: "TasksWidget", provider: TasksProvider()) { entry in
      TasksWidgetView(entry: entry)
    }
    .configurationDisplayName("Today's Tasks")
    .description("Glance at your day.")
    .supportedFamilies([.systemSmall, .systemMedium])
  }
}

Two things worth internalising here. First, the policy: .after(next) is your only real refresh cadence guarantee. Everything else is at WidgetKit's discretion. Set it based on how stale your data can safely go, not "as fast as possible." Second, containerBackground(.background, for: .widget) is required on iOS 17+ for the widget to render correctly on the lock screen and in StandBy mode. Ship without it and you'll get System-Log-only rejections during App Review. Ask me how I know.

Interactive widgets with App Intents on iOS 17+

Since iOS 17, widgets can contain Button and Toggle controls that fire a Swift AppIntent without opening the app. This is the mechanism behind the "check off a reminder from the widget" flow that Reminders.app ships. The pattern is: define an intent, wire the button to the intent in SwiftUI, and have the intent mutate the shared store then request a widget reload.

import AppIntents
import WidgetKit

struct CompleteNextTaskIntent: AppIntent {
  static var title: LocalizedStringResource = "Complete next task"
  static var description = IntentDescription("Marks the next due task as done.")

  func perform() async throws -> some IntentResult {
    let suite = UserDefaults(suiteName: "group.com.acme.taskflow")
    let queue = suite?.stringArray(forKey: "pendingCompletions") ?? []
    let nextId = suite?.string(forKey: "nextDueId") ?? ""
    suite?.set(queue + [nextId], forKey: "pendingCompletions")
    WidgetCenter.shared.reloadAllTimelines()
    return .result()
  }
}

Then in your TasksWidgetView:

Button(intent: CompleteNextTaskIntent()) {
  Label("Done", systemImage: "checkmark.circle.fill")
}
.buttonStyle(.borderedProminent)
.tint(.accentColor)

Here's the key architectural point: the widget's intent can't call back into React Native directly. Instead, write the "user did a thing" event into a queue in the shared UserDefaults, then drain that queue the next time your app foregrounds. It's the same pattern as background tasks in general (see our guide on React Native background tasks with Expo for how to structure the drain logic). Don't try to hit the network from the intent. You have less than a second of CPU and the process will be killed.

Deep-linking from a widget into a React Native screen

Tapping a widget outside of a button opens your app. To route the tap to a specific screen, attach a widgetURL modifier to the widget's root view and handle the URL in your React Native linking layer. In SwiftUI:

TasksWidgetView(entry: entry)
  .widgetURL(URL(string: "taskflow://tasks/next"))

On the JS side, register the scheme in app.json under expo.scheme, then handle the URL with expo-linking or your Expo Router deep-link config. Every widget family can carry a different URL, and inside a medium or large widget individual Link views can point at their own deep links. For example, one tappable row per task. If you're on Expo Router, the URL just needs to match a route file. That's covered end-to-end in our deep linking with Expo Router guide. Universal Links work too, but for a widget that only ever routes to your own app, a custom scheme is faster to set up and has one fewer thing to break.

Refreshing widget timelines from React Native

The widget only re-renders when WidgetKit fetches a new timeline. There are three ways this happens: the timeline policy expires (from your .after(next) return), you call WidgetCenter.shared.reloadAllTimelines() from Swift, or your JS calls ExtensionStorage.reloadWidget(). In practice, the pattern is:

  • Update shared UserDefaults whenever the underlying data changes on the JS side.
  • Call ExtensionStorage.reloadWidget() once per meaningful mutation batch, not per key.
  • Set the timeline policy to a reasonable ceiling (30 to 60 minutes for most content) so the widget stays fresh even if the app hasn't launched.
  • Avoid .atEnd unless your entries genuinely include a "final" state. WidgetKit interprets it as "never refresh me again."

If your app runs a background task that fetches new data, the same rule applies: write to the shared store from the task and call WidgetCenter.shared.reloadAllTimelines() from within your Swift task handler. Since 2025, WidgetCenter also exposes invalidateConfigurationRecommendations(), which you should call whenever the set of configuration options your widget supports has changed. For instance, after a user creates a new project that should now appear in the widget's configuration picker.

Debugging widgets and the pitfalls that eat weekends

Widgets are the single hardest part of the iOS platform to debug because so much of the failure mode is silent. Here's the short list I keep pinned in my terminal.

The widget shows placeholder values even though the app updated the store

Almost always an App Group mismatch. Print the suite name in the widget process (NSLog("suite: \(SharedStore.suite?.dictionaryRepresentation() ?? [:])")) and confirm the keys are actually landing. If the dictionary is empty, the group identifier is wrong somewhere in the chain of three declarations.

Changes to widget.swift don't show up in the simulator

Xcode caches widget snapshots aggressively. Delete the app from the simulator, run xcrun simctl shutdown all && xcrun simctl erase all for a full reset, and rebuild. On a device, remove the widget from the home screen and re-add it after installing the new build.

Swift compile fails after adding React Native pods

This is the classic "React Native is shipped uncompiled" trap. Widget targets don't need React Native at all, but if the extension accidentally links against RN pods it will explode at build time. Evan Bacon documents the fix in the expo-apple-targets README: run npx expo prebuild --template ./node_modules/@bacons/apple-targets/prebuild-blank.tgz --clean to get a clean project without RN pods leaking into your extension target.

Interactive button does nothing when tapped

The AppIntent must be visible to both the widget target and the app target. That's what the _shared/ folder inside targets/widget/ is for. Move the intent file into _shared/ and re-prebuild.

General debugging strategy is the same as any other native issue in your Expo app: attach Xcode to the widget's process (Debug > Attach to Process > TasksWidgetExtension) and watch the console. If you haven't set up structured native logging yet, our React Native debugging guide walks through the pieces that work with widget extensions too.

Production checklist before shipping

Before you tag the release, run through this list. Every item is something I've been bitten by:

  • Icons and colours: the widget target has its own asset catalog. Set the AccentColor and WidgetBackground entries, not just the ones in your main app's catalog.
  • Sizes: declare supportedFamilies explicitly. If you support .accessoryCircular and .accessoryRectangular, test them on the lock screen. Lock screen widgets are single-colour and will render your gradients as flat blobs.
  • StandBy mode: iOS 17+ renders widgets in StandBy at night with heavy tinting. Verify legibility, or opt out with .disfavoredLocations([.standBy]).
  • Localisation: strings inside the widget must be localised through the extension's own .strings files, not the main app's. Your i18n framework does not run inside the extension.
  • Privacy: if the widget shows anything the user might not want on a shared home screen, add a "Blur when locked" toggle in your app settings and honour it via a shared UserDefaults flag.
  • App Store metadata: mention widgets in your App Store description and add a widget screenshot. App Review has rejected widget-first apps that didn't show a widget in the screenshots.
  • New Architecture compatibility: widgets are entirely independent of Bridgeless mode. If you've already migrated your JS side using the React Native New Architecture migration checklist, the widget target is not affected either way.

Shipping a widget is one of the highest-signal features you can add to a React Native app. Home screen real estate is precious, and users who pin your widget are your most engaged cohort. It's also one of the easier native integrations to get right, if you accept the constraint that the widget UI has to be written natively. Pick @bacons/apple-targets, use App Groups for the data boundary, keep intents small and idempotent, and treat the widget like the separate app it is.

Frequently Asked Questions

Can you build iOS widgets in Expo Go?

No. Widgets require a native extension target, entitlements, and App Group configuration that Expo Go's runtime cannot provide. You need a development build (npx expo run:ios) or EAS Build. Config plugins like @bacons/apple-targets only take effect during expo prebuild, which Expo Go skips entirely.

Can React Native run inside an iOS widget?

No. iOS widgets are rendered by WidgetKit under a tight time and memory budget, sometimes while the parent app isn't running. There's no JavaScript runtime available inside the extension. All widget UI has to be SwiftUI (or SwiftUI-shaped primitives generated by a tool like expo-widgets).

How do you refresh an iOS widget from React Native?

Write the new data into the App Group's shared UserDefaults suite, then call ExtensionStorage.reloadWidget() from @bacons/apple-targets. WidgetKit will re-invoke your timeline provider, which reads the shared store and produces a fresh entry.

Do interactive widgets work on Android?

Not through the same API. Android has its own AppWidgetProvider system with a different lifecycle and RemoteViews layout language. @bacons/apple-targets is iOS-only by design. For cross-platform widgets in 2026, the Expo team's alpha expo-widgets library is the direction to watch. It's designed to compile the same React component to both iOS SwiftUI and Android RemoteViews.

What Xcode and Expo SDK versions do I need for widgets?

Xcode 16 or newer, macOS 15 Sequoia, CocoaPods 1.16.2 (Ruby 3.2.0), and Expo SDK 53+. Interactive widgets with App Intents require iOS 17 as the deployment target on the widget scheme. StandBy mode tuning applies to iOS 17+ and later.

Why is my widget stuck showing placeholder data?

Nine times out of ten it's an App Group identifier mismatch between your app's entitlement, the expo-target.config.js entitlement, and the suite name passed to UserDefaults(suiteName:). All three must be byte-identical. Print suite?.dictionaryRepresentation() from the widget target to confirm whether the shared container is empty or the keys just aren't landing.

Jake Morrison
About the Author Jake Morrison

React Native lead engineer who's shipped six apps and learned six different lessons. Bullish on the New Architecture.