Better I18NBetter I18N
Expo

Widget Extension

Use BetterI18n Swift SDK to bring translations into WidgetKit extensions from your Expo app.

Widget extensions require Xcode, Expo Dev Client (bare workflow or EAS Build), and an Apple Developer account with an active App Group entitlement.

Architecture

WidgetKit extensions run in a separate process and have no direct access to your Expo JS bundle. BetterI18n bridges the gap using iOS App Groups — a shared storage area that both your app and its extensions can read and write.

┌─────────────────────────┐        ┌──────────────────────────┐
│     Expo JS (React)     │        │    WidgetKit Extension   │
│                         │        │      (Swift / SwiftUI)   │
│  changeLanguage("tr")   │        │                          │
│  saveLocaleToWidget()   │──────▶ │  BetterI18n(config:      │
│                         │  App   │    I18nConfig(           │
│  BetterI18n CDN fetch   │  Group │      appGroupIdentifier  │
│  → saves to AppGroup    │──────▶ │    ))                    │
└─────────────────────────┘  User  └──────────────────────────┘
                            Defaults

The Expo side writes the locale preference and cached translations to UserDefaults(suiteName: appGroup). The Swift widget reads from the same location — no network call needed at render time.


App Group Entitlement

Add an App Group to your app.config.js (or app.json). The group identifier must start with group. and match what you'll use in Swift.

app.config.js
export default {
  expo: {
    // ...
    ios: {
      bundleIdentifier: 'com.mycompany.myapp',
      entitlements: {
        'com.apple.security.application-groups': ['group.com.mycompany.myapp'],
      },
    },
  },
};

Add the Widget Target with @bacons/apple-targets

Install the plugin and create a widget target config:

npx expo install @bacons/apple-targets
targets/widget/expo-target.config.js
module.exports = () => ({
  type: 'widget',
  name: 'MyWidget',
  bundleIdentifier: 'com.mycompany.myapp.widget',
  deploymentTarget: '16.0',
  entitlements: {
    'com.apple.security.application-groups': ['group.com.mycompany.myapp'],
  },
});

Register it in app.config.js:

app.config.js
plugins: [
  ['@bacons/apple-targets', { targets: ['./targets/widget'] }],
],

Add the BetterI18n Swift Package

In Xcode, open your workspace and go to File → Add Package Dependencies:

  • URL: https://github.com/better-i18n/swift
  • Dependency Rule: Up to Next Major Version, from 0.1.0

Add both products:

  • BetterI18n → your widget extension target
  • BetterI18nUI → your main app target (for I18nProvider)

Bridge the Locale from JS

Install the shared preferences library:

npx expo install react-native-shared-group-preferences

Create a helper that writes the selected locale to the App Group:

lib/utils/save-locale-to-widget.ts
import SharedGroupPreferences from 'react-native-shared-group-preferences';
import { Platform } from 'react-native';

const APP_GROUP = 'group.com.mycompany.myapp';
const PROJECT = 'my-org/my-app';

export async function saveLocaleToWidget(locale: string) {
  if (Platform.OS !== 'ios') return;
  await SharedGroupPreferences.setItem(
    `@better-i18n:locale:${PROJECT}`,
    locale,
    APP_GROUP,
  );
}

Call this function whenever the user changes the language:

app/(tabs)/settings.tsx
import { useLocale } from '@better-i18n/expo';
import { saveLocaleToWidget } from '@/lib/utils/save-locale-to-widget';

const { changeLanguage } = useLocale();

async function handleLanguageChange(locale: string) {
  await changeLanguage(locale);
  await saveLocaleToWidget(locale);
}

Read Translations in the Widget

In your widget Swift target, use BetterI18n with appGroupIdentifier. The storage-only API skips the network and reads cached translations instantly — ideal for widget timelines.

Widget.swift
import WidgetKit
import SwiftUI
import BetterI18n

struct Provider: TimelineProvider {
    let i18n = BetterI18n(config: I18nConfig(
        project: "my-org/my-app",
        defaultLocale: "en",
        appGroupIdentifier: "group.com.mycompany.myapp"
    ))

    func getTimeline(
        in context: Context,
        completion: @escaping (Timeline<SimpleEntry>) -> Void
    ) {
        Task {
            let locale = await i18n.detectLocaleFromStorageOnly()
            let translator = await i18n.getTranslatorFromStorageOnly(locale: locale)

            let entry = SimpleEntry(
                date: .now,
                title: translator?("widget.title") ?? "My App",
                subtitle: translator?("widget.subtitle") ?? ""
            )

            completion(Timeline(entries: [entry], policy: .atEnd))
        }
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let title: String
    let subtitle: String
}

struct MyWidgetView: View {
    let entry: SimpleEntry

    var body: some View {
        VStack(alignment: .leading) {
            Text(entry.title).font(.headline)
            Text(entry.subtitle).font(.caption)
        }
    }
}

@main
struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "com.mycompany.myapp.widget", provider: Provider()) { entry in
            MyWidgetView(entry: entry)
        }
        .configurationDisplayName("My App Widget")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

Test Language Switching

Build and run the app via EAS Build or a connected device (simulators support widgets). Change the language in your app settings — the widget should reflect the new locale on the next timeline refresh.

To force a refresh during development:

WidgetCenter.shared.reloadAllTimelines()

Storage Key Schema

The JS SDK and Swift SDK use the same UserDefaults keys. If you write custom bridge code, match these exactly:

@better-i18n:locale:{project}              ← selected locale (string)
@better-i18n:messages:{project}:{locale}   ← cached translations (JSON string)

The {project} placeholder is your project identifier from the dashboard (e.g. my-org/my-app). Both the JS saveLocaleToWidget() call and the Swift I18nConfig(project:) parameter must use the same value for the bridge to work correctly.


On this page