This is the official Customer.io Expo plugin, supporting mobile apps.
The Expo plugin takes advantage of our React Native SDK, and requires very little setup. It extends the Expo config to let you customize the pre-build phase of managed workflow builds, which means you don't need to eject to a bare workflow.
After you add the plugin to your project, you'll need to install our React Native SDK and run pre-build. The plugin automatically generates and configures the necessary native code files required to make our React Native SDK to work on your project.
You'll find our complete SDK documentation at https://customer.io/docs/sdk/expo.
The SDK ships no text of its own in the visual notification inbox — the empty state is an icon and the loading state is a spinner — so accessibility labels are the one place a string is still needed. Apps supply their own through inApp.notificationInboxAccessibilityLabels when they call CustomerIO.initialize() from JavaScript:
CustomerIO.initialize({
cdpApiKey: '...',
inApp: {
siteId: '...',
notificationInboxAccessibilityLabels: {
bell: t('inbox.bell'),
bellWithUnreadCount: t('inbox.unread'), // e.g. "{count} unread notifications"
loadingIndicator: t('inbox.loading'),
emptyState: t('inbox.empty'),
},
},
});bellWithUnreadCount is a template: {count} is replaced with the number of unread messages when the bell is announced. Spell it any other way and nothing is substituted, so the screen reader reads the text verbatim, braces included, and never says the count — the SDK warns in the JavaScript console when it spots that. That warning is a development-build diagnostic only — it is compiled out of release builds, so a malformed template ships silently.
These labels require JavaScript initialization. With native auto-initialization (a config block in the plugin options), the SDK is initialized before JavaScript loads, so a later CustomerIO.initialize() call is a no-op and the labels never reach the SDK. They are deliberately not exposed as plugin options: values in app.json are baked in at prebuild, which would ship a capability that only works for one locale.
No label falls back to English, but the elements do not all behave the same way when one is unset:
| Unset label | Result |
|---|---|
bell |
The bell is announced as an unnamed button. |
bellWithUnreadCount |
Falls back to bell, so the button is still named — only the count goes unannounced. The badge itself is always hidden from screen readers, so the count is only ever spoken through this label. |
loadingIndicator |
The spinner stops being an accessibility element: VoiceOver skips it, while TalkBack still reports Android's underlying progress role. |
emptyState |
The empty-state icon is hidden from assistive technologies. |
So an auto-initializing app gets no configured labels, which is not the same as every element becoming unlabeled.
In-app messages follow the device's light or dark appearance by default. An app with its own
appearance setting — one that can disagree with the operating system — tells the SDK which variant
to render with inApp.colorScheme:
import { CioColorScheme, CustomerIO } from 'customerio-reactnative';
CustomerIO.initialize({
cdpApiKey: '...',
inApp: {
siteId: '...',
colorScheme: CioColorScheme.Dark, // Auto (default) | Light | Dark
},
});Because an appearance setting can change while the app is running, the scheme can also be changed at any time:
CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Light);That takes effect immediately — messages already on screen, inline views included, are re-themed in place, so it can be called straight from the app's own appearance toggle.
Unlike the inbox accessibility labels above, native auto-initialization only affects the config
option, not the setter — provided the plugin config sets siteId. With a config block in the
plugin options the SDK starts before JavaScript loads, so inApp.colorScheme never arrives, while
setColorScheme() reaches the already-initialized SDK and works normally. Such an app can
therefore still pin a variant by calling it once after startup.
That depends on config.siteId, because the plugin only adds the in-app messaging module when one
is present. Without it there is no module for either path to reach: the config option is dropped as
above, and the setter logs that in-app messaging is unavailable and leaves the scheme unchanged.
Whichever light and dark variants the message renders come from the Customer.io editor. A message authored with a single style looks the same under every scheme.
When using Expo's scene lifecycle with Customer.io native auto-initialization, register your React Native Linking URL listener and then call CustomerIO.setDeepLinkRoutingReady(). This lets the plugin deliver URLs buffered during cold launch without requiring a second SDK initialization from JavaScript.
Customer.io notification deep links are delivered to that listener as url events after readiness; they are not returned by Linking.getInitialURL(). If readiness is not signaled within ten seconds, Customer.io falls back to opening the destination through the system.
Expo SDK 58 and later deliver cold-start URLs through SceneDelegate, so handleDeeplinkInKilledState is not injected for those versions. The scene router replaces that legacy AppDelegate launch-options workaround.
Expo Router apps using the scene lifecycle must process Live Activity URLs once in a top-level
app/+native-intent.tsx file:
import { CustomerIO } from 'customerio-reactnative';
export async function redirectSystemPath({ path }: { path: string }) {
return CustomerIO.liveActivities.handleWidgetUrl(path);
}The helper reports the opened event and returns the customer's destination before Expo Router
navigates. Ordinary URLs pass through unchanged, and a Customer.io tracking URL without a
destination returns null. Do not also call the helper from a Linking listener because processing
the same tracking URL twice reports two opened events.
Expo apps without Expo Router should apply the same helper exactly once in their central initial-URL and URL-subscription pipeline. The plugin does not edit customer-owned routing files.
After disabling Live Notifications in a previously generated iOS project, run npx expo prebuild --clean --platform ios. An incremental prebuild stops with this instruction so it cannot leave the generated widget target or linked Live Activities code behind.
Here is a quick start guide to start with local development.
Thanks for taking an interest in our project! We welcome your contributions.
We value an open, welcoming, diverse, inclusive, and healthy community for this project. We expect all contributors to follow our code of conduct.