Back to Blog
article

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

ioswidgetreact nativeexpoapple-targets

Cover

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"                 │
        └──────────────────────────────────────────────────────────┘
  1. 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.
  2. The App Group is a shared sandbox — the only legal way for the app and its extensions to share data. Concretely it's a UserDefaults instance that both processes can read and write.
  3. 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 --clean blows 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.
  • version and buildNumber are 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:

FileWhat it is
index.swiftThe WidgetBundle — your target's entry point.
widgets.swiftYour Widget definition, the timeline provider, and the SwiftUI views.
AppIntent.swiftIf your widget is configurable, the entity, query, and intent live here.
Info.plistMinimal — just NSExtension boilerplate. The plugin handles the rest.
Assets.xcassetsIcons, colors. Auto‑populated by the plugin from expo-target.config.js.
generated.entitlementsDon'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:

  1. StaticConfiguration vs AppIntentConfiguration. Static = same content for every user. Intent = the user can pick something during "Edit Widget". Almost any product widget worth shipping is the latter.
  2. 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, JSONDecoder with .iso8601 out.
  • Schema mismatch is loudCodable will 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 syncEventsToWidget whenever 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:

  1. npm run prewidget — generates a minimal native project containing your widget target only.
  2. open ios/*.xcworkspace.
  3. Pick the widget scheme in the toolbar.
  4. 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

SymptomLikely causeFix
Picker opens, then disappearsApp Group entitlement is a string, not an arrayapp.json → array, prebuild --clean -p ios, rebuild
Widget says "No event selected" no matter whatApp hasn't synced to UserDefaults yetCall syncEventsToWidget on app boot, not just on CRUD
Widget never updates after data changesYou wrote to UserDefaults but didn't call reloadWidgetAlways pair the two calls
Build fails: database is lockedStale xcodebuild / Xcode holding DerivedDatakillall xcodebuild, or rm -rf ~/Library/Developer/Xcode/DerivedData/<YourApp>-*
CFBundleShortVersionString of an app extension ('1.0') must match…Widget version diverged from app versionversion: config.version in expo-target.config.js
availableParallelism is not a functionNode too oldNode 20+
accentColor refers to instance methodFree function shadowed by View.accentColorRename 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:

  1. npm install @bacons/apple-targets and add to plugins.
  2. Create targets/widget/ and an expo-target.config.js that reads version, buildNumber, and the App Group entitlement from the parent config.
  3. Make sure app.json's App Group entitlement is an array.
  4. Write your widget in widgets.swift + index.swift. If it's configurable, add AppIntent.swift with an EventEntity, EventQuery, and WidgetConfigurationIntent.
  5. Bridge the JS app to the widget via ExtensionStorage + a JSON payload in shared UserDefaults, and call ExtensionStorage.reloadWidget(kind) on every data mutation.
  6. Iterate fast with npm run prewidget + Xcode SwiftUI Previews; ship with npm 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

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.