Building an iOS Home‑Screen Widget for Your React Native App (with Expo)

Why bother with a native widget?
Widgets are one of the few iOS surfaces users see without opening your app. For tracker‑style apps, journals, habit apps, fitness apps, anything time‑based, a widget isn't a "nice to have" — it's the whole product, just smaller. And contrary to most React Native lore, you don't need to eject, you don't need to fork your project, and you don't need to write a single line of Objective‑C. With Expo and a config plugin called @bacons/apple-targets you can drop a real Swift/SwiftUI widget into the same repo as your JS code, prebuild, and ship.
This article shows exactly how I did it for a "time since" tracker app — a widget that lets the user pick which event to display, in any of 10 accent colors, and keeps itself updated as the app's data changes.
Prerequisites
- An Expo project (managed workflow with config plugins). EAS not required.
- Node 20+ (more on this below — there's a trap).
- Xcode 15 or newer.
- An Apple Developer account (App Groups need a Team ID).
The mental model
Before any code: you're going to be juggling three things.
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Your JS App │ writes │ App Group │ reads │ Swift Widget │
│ (React Native) ├────────▶│ Shared │◀────────┤ Extension │
│ │ │ UserDefaults │ │ (WidgetKit) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ ▲
│ "reload your timeline, please" │
└──────────────────────────────────────────────────────────┘
- The widget extension is a separate Swift target — its own bundle, its own process. It cannot reach into your app's SQLite, its AsyncStorage, or anything else.
- The App Group is a shared sandbox — the only legal way for the app and its extensions to share data. Concretely it's a
UserDefaultsinstance that both processes can read and write. - Your JS code writes whatever the widget needs into the App Group, then asks WidgetKit to reload. WidgetKit caches timelines aggressively, so if you don't ask, your widget will display stale data for hours.
Internalize that picture and the rest is plumbing.
Step 1 — Install @bacons/apple-targets
npm install @bacons/apple-targets
Add the plugin to app.json:
{
"expo": {
"plugins": ["@bacons/apple-targets"]
}
}
That's it for installation. The plugin runs during expo prebuild and turns a folder of Swift files into a real Xcode target wired up to your app. You'll never touch the .xcodeproj by hand.
Why a config plugin and not just opening Xcode and adding a target? Because every
expo prebuild --cleanblows away your iOS folder. Anything you set up by hand in Xcode is gone next time. A plugin regenerates it from source on every build.
Step 2 — Create the target folder
By convention, targets live in targets/<name>/ next to your src/. Create targets/widget/. The plugin treats this folder as the entire widget target.
The single required file is expo-target.config.js. Here's mine:
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
module.exports = (config) => ({
type: "widget",
icon: "../../assets/icon.png",
entitlements: {
"com.apple.security.application-groups":
config.ios.entitlements["com.apple.security.application-groups"],
},
colors: {
accent: "#fcba03",
},
version: config.version,
buildNumber: config.ios?.buildNumber ?? "1",
});
Three things to notice:
- The entitlement is read from the parent app's config. Don't hardcode it in two places.
versionandbuildNumberare read from the parent app's config too. This avoids a real, infuriating build error explained below.type: "widget"is what tells the plugin to generate a Widget Extension target.
🪤 Trap #1 — version mismatch
If you don't pass version, your build will eventually fail with:
The CFBundleShortVersionString of an app extension ('1.0') must
match that of its containing parent app ('1.3.0').
Apple requires the extension's version string to match the parent app's exactly. Reading them from config.version and config.ios.buildNumber makes them automatically track each other, so a npm run release that bumps the app version doesn't break the widget.
What goes in targets/widget/?
Alongside expo-target.config.js, the actual widget code:
| File | What it is |
|---|---|
index.swift | The WidgetBundle — your target's entry point. |
widgets.swift | Your Widget definition, the timeline provider, and the SwiftUI views. |
AppIntent.swift | If your widget is configurable, the entity, query, and intent live here. |
Info.plist | Minimal — just NSExtension boilerplate. The plugin handles the rest. |
Assets.xcassets | Icons, colors. Auto‑populated by the plugin from expo-target.config.js. |
generated.entitlements | Don't edit by hand — the plugin regenerates this from your config. |
Step 3 — Wire up the App Group entitlement
This is the bridge that lets the JS app and the widget share data. Edit app.json:
{
"expo": {
"ios": {
"appleTeamId": "YOURTEAMID",
"bundleIdentifier": "com.yourorg.app",
"entitlements": {
"com.apple.security.application-groups": ["group.com.yourorg.app"]
}
}
}
}
🪤 Trap #2 — the trap (string vs. array)
Look very carefully:
// ❌ This compiles, builds, runs, and silently breaks everything
"com.apple.security.application-groups": "group.com.yourorg.app"
// ✅ This is what iOS actually wants
"com.apple.security.application-groups": ["group.com.yourorg.app"]
App Group entitlements must be an array of strings, not a single string. With a single string, prebuild generates a malformed .entitlements file. iOS doesn't error — it just silently doesn't grant your parent app access to the App Group. The widget, weirdly, often still reads fine. So:
- The widget tries to render, sees no data, falls back to its empty state.
- The user long‑presses → "Edit Widget" → taps the event picker → it pops up for half a second and disappears, because
EntityQuery.suggestedEntities()returned[].
I lost two hours to this. The smell is "the picker won't stay open".
After fixing app.json, regenerate the iOS project:
npx expo prebuild --clean -p ios
Step 4 — Author the widget in Swift
index.swift — the bundle
import WidgetKit
import SwiftUI
@main
struct exportWidgets: WidgetBundle {
var body: some Widget {
widget()
}
}
If you ever want a Live Activity or a Control Widget, that's where you add them.
widgets.swift — the widget itself
The two big decisions:
StaticConfigurationvsAppIntentConfiguration. Static = same content for every user. Intent = the user can pick something during "Edit Widget". Almost any product widget worth shipping is the latter.- Timeline provider: how often, and when, do you want to give iOS a new entry?
Here's the skeleton I ended up with — a configurable, midnight‑refreshing single‑event widget:
import WidgetKit
import SwiftUI
import AppIntents
struct Provider: AppIntentTimelineProvider {
typealias Entry = SimpleEntry
typealias Intent = ConfigurationAppIntent
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), event: nil)
}
func snapshot(for configuration: ConfigurationAppIntent,
in context: Context) async -> SimpleEntry {
loadEntry(for: configuration, at: Date())
}
func timeline(for configuration: ConfigurationAppIntent,
in context: Context) async -> Timeline<SimpleEntry> {
let now = Date()
let entry = loadEntry(for: configuration, at: now)
// Tell iOS to refresh at the next midnight so the day count rolls over.
let cal = Calendar.current
let tomorrow = cal.date(byAdding: .day, value: 1, to: now)!
let nextMidnight = cal.startOfDay(for: tomorrow)
let midnightEntry = loadEntry(for: configuration, at: nextMidnight)
return Timeline(entries: [entry, midnightEntry], policy: .atEnd)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let event: WidgetEventEntity?
}
struct widget: Widget {
let kind: String = "widget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: ConfigurationAppIntent.self,
provider: Provider()
) { entry in
widgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Since Widget")
.description("Display an event and how long it's been.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
The midnight trick is worth calling out: WidgetKit does not re‑run your view every minute. It runs your timeline() function, gets back a list of entries, and then mostly leaves you alone. If you only return one entry for "now", the day count visible on the widget can lag the real day count by 24 hours. Returning a second entry pre‑computed for the next midnight forces a re‑render at exactly the moment the count changes.
AppIntent.swift — making it configurable
Three pieces. The entity (one selectable thing), the query (how iOS asks "what's available?"), and the intent (the parameters the user fills in during "Edit Widget").
import AppIntents
struct EventEntity: AppEntity {
var id: String
var title: String
var startDate: Date
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
static var defaultQuery = EventQuery()
}
struct EventQuery: EntityQuery {
func entities(for ids: [EventEntity.ID]) async throws -> [EventEntity] {
loadFromAppGroup().filter { ids.contains($0.id) }
}
func suggestedEntities() async throws -> [EventEntity] {
loadFromAppGroup().sorted { $0.title < $1.title }
}
private func loadFromAppGroup() -> [EventEntity] { /* see Step 5 */ }
}
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Select Event" }
@Parameter(title: "Event")
var selectedEvent: EventEntity?
@Parameter(title: "Color", default: .auto)
var tintColor: WidgetTint
}
enum WidgetTint: String, AppEnum {
case auto, red, orange, amber, green, teal, blue, indigo, violet, pink, gray
static var typeDisplayRepresentation: TypeDisplayRepresentation { "Color" }
static var caseDisplayRepresentations: [WidgetTint: DisplayRepresentation] {
[.auto: "Auto", .red: "Red", /* ...all 10... */]
}
}
The non‑obvious bit: EventQuery runs inside the widget extension's process. It cannot read your SQLite, your AsyncStorage, or anything else from the app. The only data source it has is whatever the JS side already wrote into the App Group. Which brings us to…
Step 5 — The bridge: shared UserDefaults
@bacons/apple-targets ships a tiny native module called ExtensionStorage that wraps UserDefaults(suiteName:) for the App Group. No additional setup — installing the plugin is enough.
JS side — src/lib/widget.ts
import { ExtensionStorage } from "@bacons/apple-targets";
import type { Event } from "@/features/events/types";
const APP_GROUP_ID = "group.com.yourorg.app";
const KEY = "events";
const WIDGET_KIND = "widget";
export async function syncEventsToWidget(events: Event[]) {
const storage = new ExtensionStorage(APP_GROUP_ID);
// Only ship the fields the widget actually renders — keep the payload small.
const payload = events.map((e) => ({
id: e.id,
title: e.title,
startDate: e.startDate.toISOString(),
showTimeAs: e.showTimeAs,
color: e.color,
icon: e.icon,
isPinned: e.isPinned,
}));
storage.set(KEY, JSON.stringify(payload));
ExtensionStorage.reloadWidget(WIDGET_KIND); // matches `kind` in widgets.swift
}
Swift side — read from the same place
private struct WidgetEvent: Codable {
let id: String
let title: String
let startDate: Date
let showTimeAs: String?
let color: String?
let icon: String?
let isPinned: Bool?
}
private func loadFromAppGroup() -> [WidgetEvent] {
guard let defaults = UserDefaults(suiteName: "group.com.yourorg.app"),
let json = defaults.string(forKey: "events"),
let data = json.data(using: .utf8)
else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([WidgetEvent].self, from: data)) ?? []
}
Why a JSON string instead of setArray?
The native module also exposes setArray and setObject helpers. They work, but I prefer JSON strings:
- Date handling is trivial — ISO‑8601 in,
JSONDecoderwith.iso8601out. - Schema mismatch is loud —
Codablewill tell you exactly which key is missing or the wrong type, instead of silently giving you a wrong value. - One cross‑process contract — the same JSON shape on both sides.
Whatever you choose, keep the payload narrow. Every byte in UserDefaults is paid for by widget launch latency.
Step 6 — When (exactly) to push data
This was my biggest "obvious in hindsight" question. The answer: every time your data changes.
In our Zustand store (src/features/events/eventsStore.ts):
loadEvents: async () => {
const events = await eventsService.getAll();
set({ events });
await syncEventsToWidget(events); // initial app start
},
createEvent: async (input) => {
const created = await eventsService.create(input);
const updated = [...get().events, created];
set({ events: updated });
await syncEventsToWidget(updated); // user added an event
return created;
},
updateEvent: async (id, input) => {
const next = await eventsService.update(id, input);
const updated = get().events.map(e => e.id === id ? next : e);
set({ events: updated });
await syncEventsToWidget(updated); // user edited an event
return next;
},
deleteEvent: async (id) => {
await eventsService.delete(id);
const updated = get().events.filter(e => e.id !== id);
set({ events: updated });
await syncEventsToWidget(updated); // user deleted an event
},
If you forget any of these, the widget will display stale data after the user takes that action — sometimes for hours, because WidgetKit caches the timeline. The ExtensionStorage.reloadWidget(kind) call inside syncEventsToWidget is what tells iOS "throw away your cached timeline and ask me again". Without it, writing to UserDefaults does nothing visible.
Pro tip: also call
syncEventsToWidgetwhenever your app boots and after any background fetch / sync. The first launch after install needs to populate the App Group before the widget is even configurable.
Step 7 — The Xcode iteration loop
SwiftUI Previews are the one truly delightful part of widget development. But Expo's normal prebuild generates the full iOS app, dependencies and all — slow, and noisy if all you want is to iterate on your widget's layout.
@bacons/apple-targets ships a stripped‑down prebuild template specifically for this. Add a script:
{
"scripts": {
"prewidget": "EXPO_NO_GIT_STATUS=1 npx expo prebuild --template ./node_modules/@bacons/apple-targets/prebuild-blank.tgz --clean"
}
}
Workflow:
npm run prewidget— generates a minimal native project containing your widget target only.open ios/*.xcworkspace.- Pick the widget scheme in the toolbar.
- Open
widgets.swift, drop a#Preview { ... }at the bottom, hit ⌘⌥P (or just edit live).
#Preview(as: .systemMedium) {
widget()
} timeline: {
SimpleEntry(
date: .now,
event: WidgetEventEntity(
id: "preview",
title: "Started running",
startDate: Calendar.current.date(byAdding: .day, value: -52, to: Date())!,
showTimeAs: "weeks"
)
)
}
You get an inline rendering of every widget family, instantly, with whatever fake data you want. Iterate to your heart's content; when the layout's right, switch back to:
npm run rebuild:ios # -> npx expo prebuild --platform ios --clean && npx expo run:ios
…to get the full app + widget building together.
🪤 Trap #3 — Node version
If expo run:ios builds successfully but Metro then crashes with:
TypeError: _os.default.availableParallelism is not a function
…your Node is too old. os.availableParallelism requires Node 18.14+ (or 19.4+). Bump to Node 20 and stop fighting it.
Step 8 — Configurability and styling polish
Some things that make the widget feel premium rather than placeholder:
Different layouts per widget family.
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemMedium: MediumEventView(/* ... */)
default: SmallEventView(/* ... */)
}
}
systemSmall is square and forces vertical stacking. systemMedium is wide and almost always wants a horizontal layout. Treating them with the same view leaves you with either cramped small widgets or whitespace‑swamped medium ones.
Per‑widget color via a second @Parameter.
Already shown in Step 4 — exposes a "Color" picker in "Edit Widget" with 10 brand‑friendly accents plus an "Auto" mode that derives a deterministic color from the event ID. Users get personalization with zero in‑app UI work.
Hex string → Color.
If your JS palette uses hex strings, plumb them straight through:
extension Color {
init?(hex: String) {
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if s.hasPrefix("#") { s.removeFirst() }
guard let v = UInt64(s, radix: 16), s.count == 6 else { return nil }
self.init(.sRGB,
red: Double((v >> 16) & 0xFF) / 255,
green: Double((v >> 8) & 0xFF) / 255,
blue: Double(v & 0xFF) / 255,
opacity: 1)
}
}
🪤 Trap #4 — accentColor shadowing
This Swift error will stop your build dead and is non‑obvious:
use of 'accentColor' refers to instance method rather than
global function 'accentColor' in module 'widget'
View already has an instance member named accentColor. If you write a free helper called accentColor(for:), the compiler picks the instance member inside any View body. Rename it. I went with resolveAccent(for:) and moved on.
Step 9 — When it doesn't work: a debug checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Picker opens, then disappears | App Group entitlement is a string, not an array | app.json → array, prebuild --clean -p ios, rebuild |
| Widget says "No event selected" no matter what | App hasn't synced to UserDefaults yet | Call syncEventsToWidget on app boot, not just on CRUD |
| Widget never updates after data changes | You wrote to UserDefaults but didn't call reloadWidget | Always pair the two calls |
Build fails: database is locked | Stale xcodebuild / Xcode holding DerivedData | killall xcodebuild, or rm -rf ~/Library/Developer/Xcode/DerivedData/<YourApp>-* |
CFBundleShortVersionString of an app extension ('1.0') must match… | Widget version diverged from app version | version: config.version in expo-target.config.js |
availableParallelism is not a function | Node too old | Node 20+ |
accentColor refers to instance method | Free function shadowed by View.accentColor | Rename the helper |
Don't underestimate Console.app filtered by your widget's bundle ID, by the way. The EntityQuery is a great place to drop os.Logger calls — when something goes silently wrong inside the extension, that's the only place you'll see it.
Closing
Recap the recipe:
npm install @bacons/apple-targetsand add toplugins.- Create
targets/widget/and anexpo-target.config.jsthat readsversion,buildNumber, and the App Group entitlement from the parent config. - Make sure
app.json's App Group entitlement is an array. - Write your widget in
widgets.swift+index.swift. If it's configurable, addAppIntent.swiftwith anEventEntity,EventQuery, andWidgetConfigurationIntent. - Bridge the JS app to the widget via
ExtensionStorage+ a JSON payload in sharedUserDefaults, and callExtensionStorage.reloadWidget(kind)on every data mutation. - Iterate fast with
npm run prewidget+ Xcode SwiftUI Previews; ship withnpm run rebuild:ios.
Once you internalize the three‑boxes mental model, the rest is mechanical: write Swift in targets/widget/, write JS in src/lib/widget.ts, never let those two diverge on the JSON shape. The hardest part of widget development isn't the SwiftUI — it's the entitlement that's almost right but silently wrong. Hopefully this saved you that one.
Resources
@bacons/apple-targets— the plugin that does all the heavy lifting.- Apple — WidgetKit — reference for
Widget,TimelineProvider, families. - Apple — App Intents — for configurable widgets.
- Apple — App Groups — capability and provisioning.
If this saved you a few hours, you can find the working code (and the gotchas, in commit history) in the repo this article was extracted from. Happy shipping.