@layers/expo
v3.3.2
Published
Layers Analytics Expo SDK — convenience wrapper with config plugin
Readme
Layers Expo SDK
@layers/expo is the Layers analytics SDK for Expo managed workflow projects. It wraps @layers/react-native and adds Expo-specific integrations: a config plugin for native setup automation, expo-tracking-transparency for ATT, expo-linking for deep links, expo-clipboard for clipboard attribution, and React context/hooks for idiomatic usage.
Use this package for Expo managed workflow projects. For bare React Native projects, use @layers/react-native instead.
Requirements
- Expo SDK 50.0.0+
- React Native 0.73.0+
- React 18.0+
Installation
npx expo install @layers/expoOptional Peer Dependencies
For full functionality, install these Expo packages:
npx expo install expo-tracking-transparency expo-linking expo-clipboard- expo-tracking-transparency -- ATT permission dialog (iOS)
- expo-linking -- Deep link handling
- expo-clipboard -- Clipboard attribution for deferred deep links (iOS)
AsyncStorage version compatibility
@layers/expo persists identity and queued events through
@react-native-async-storage/async-storage, and supports 1.21+, 2.x and 3.x
(3.x from @layers/expo 3.2.10).
Managed workflow projects get a compatible version automatically: npx expo
install resolves async-storage to the version pinned to your Expo SDK -- 2.x --
so there is nothing to configure and neither issue below applies.
If you install async-storage manually with npm install or yarn add, you get
3.x, which is npm's latest. Two defects have affected that combination:
- Batch read (fixed in 3.2.8). async-storage 3.0.0 renamed its batch read,
and
@layers/expoversions before 3.2.8 could not read back anything they had saved on it: every launch reported a freshanonymous_idanddevice_id, re-sent the first-open event, and heldsession_numberat 1. - Identity split on offline-first installs (3.2.8 and 3.2.9, fixed in
3.2.10). async-storage 3.0.0 also stopped serialising storage writes, so two
writes to the same key in one tick can commit in either order. The SDK wrote
its identity record twice at startup; when the wrong one won, an install whose
first launch had no network could return under a second
anonymous_idanddevice_idwith a duplicate first-open event. 3.2.10 writes the record once.
Both are fixed as of @layers/expo 3.2.10, so async-storage 3.x needs no
version pin. Earlier @layers/expo releases still do.
Quick Start
1. Add the Config Plugin
In your app.config.js or app.json:
export default {
expo: {
plugins: [
[
'@layers/expo',
{
ios: {
attUsageDescription: 'We use this to show you relevant content.',
urlSchemes: ['myapp'],
associatedDomains: ['myapp.com']
},
android: {
intentFilters: [
{ scheme: 'myapp', host: 'open' },
{ scheme: 'https', host: 'myapp.com', pathPrefix: '/app' }
]
}
}
]
]
}
};Then run prebuild:
npx expo prebuild2. Initialize with the Provider
import { LayersProvider } from '@layers/expo';
export default function App() {
return (
<LayersProvider
config={{
appId: 'your-app-id',
environment: 'production'
}}
requestTracking={true}
enableDeepLinks={true}
onDeepLink={(data) => console.log('Deep link:', data.url)}
onError={(error) => console.error('Layers error:', error)}
>
<MyApp />
</LayersProvider>
);
}3. Use Hooks in Components
import { useLayersScreen, useLayersTrack } from '@layers/expo';
function SignupButton() {
const track = useLayersTrack();
return <Button title="Sign Up" onPress={() => track('signup_click', { source: 'hero' })} />;
}
function ProfileScreen() {
const screen = useLayersScreen();
useEffect(() => {
screen('Profile');
}, []);
return <View />;
}Config Plugin
The Layers Expo config plugin automates native project configuration via npx expo prebuild.
Plugin Options
interface LayersExpoPluginProps {
ios?: {
/** Custom ATT usage description for the permission dialog. */
attUsageDescription?: string;
/** URL schemes for deep linking (e.g., ['myapp']). */
urlSchemes?: string[];
/** Associated domains for Universal Links (e.g., ['myapp.com']). */
associatedDomains?: string[];
/** Additional SKAdNetwork identifiers to register. */
skAdNetworkIds?: string[];
/** Include default SKAdNetwork IDs (23 major ad networks). Default: true. */
includeDefaultSKAdNetworkIds?: boolean;
/** SKAdNetwork postback endpoint Apple copies postbacks to. Default: Layers ingest. */
advertisingAttributionReportEndpoint?: string;
/** Set NSAdvertisingAttributionReportEndpoint so Apple delivers postbacks to Layers. Default: true. */
includeAdvertisingAttributionReportEndpoint?: boolean;
};
android?: {
/** Intent filters for deep linking. */
intentFilters?: Array<{
scheme: string; // e.g., 'myapp' or 'https'
host?: string; // e.g., 'myapp.com'
pathPrefix?: string; // e.g., '/app'
}>;
};
}What the Plugin Configures
iOS (Info.plist):
NSUserTrackingUsageDescription-- ATT usage description (defaults to a generic message if not provided)SKAdNetworkItems-- 23 default SKAdNetwork IDs (Meta, Google, TikTok, Snapchat, X, Unity, AppLovin, IronSource, Mintegral, Vungle/Liftoff, Moloco, Pangle, Chartboost, Digital Turbine/Fyber, InMobi) plus any custom IDs you specifyNSAdvertisingAttributionReportEndpoint-- the on-switch that tells Apple to deliver SKAdNetwork postbacks to Layers (https://layers.click; Apple appends/.well-known/skadnetwork/report). Override withios.advertisingAttributionReportEndpoint, or disable withios.includeAdvertisingAttributionReportEndpoint: falseif another MMP owns the endpointCFBundleURLTypes-- Custom URL schemes for deep linking
iOS (Entitlements):
com.apple.developer.associated-domains-- Associated domains for Universal Links (auto-prefixed withapplinks:)
Android (AndroidManifest.xml):
- Intent filters on
.MainActivityfor deep link and App Link handling android:autoVerify="true"is set automatically for HTTPS intent filters
Default SKAdNetwork IDs
The plugin includes 23 SKAdNetwork IDs by default from these networks:
- Meta/Facebook
- Google/YouTube
- TikTok
- Snapchat
- Twitter/X
- Unity Ads
- AppLovin
- IronSource
- Mintegral
- Vungle / Liftoff Monetize
- Moloco
- Pangle (ByteDance)
- Chartboost
- Digital Turbine / Fyber
- InMobi
Set includeDefaultSKAdNetworkIds: false to disable defaults. Add any networks not
listed here (or override entirely) via ios.skAdNetworkIds — the authoritative
current list for your campaigns comes from your MMP / each network's docs.
React Provider & Hooks
LayersProvider
import { LayersProvider } from '@layers/expo';
<LayersProvider
config={LayersRNConfig}
requestTracking?: boolean // Request ATT on mount. Default: false
enableDeepLinks?: boolean // Listen for deep links. Default: true
autoTrackScreens?: boolean // screen_view per Expo Router route change. Default: true
expoRouter?: ExpoRouterHooks // expo-router's { usePathname, useGlobalSearchParams }, only when auto-detection cannot see the module
onDeepLink?: (data) => void // Deep link callback
onError?: (error) => void // Error callback (init + runtime)
>
{children}
</LayersProvider>The provider:
- Creates a
LayersReactNativeinstance - Calls
init()with AsyncStorage persistence - Optionally requests ATT permission via
expo-tracking-transparencyand records the resulting status and authorized IDFA without changing Layers consent - Optionally sets up deep link listening via
expo-linking - Tracks a
screen_viewfor every Expo Router route change whenexpo-routeris installed (see Expo Router Integration) - Forwards runtime errors (from track/screen/flush) to
onError - Shuts down the SDK on unmount
useLayers
function useLayers(): { isReady: boolean; layers: LayersReactNative | null };Access the SDK context. Returns isReady: false until initialization completes.
function MyComponent() {
const { isReady, layers } = useLayers();
if (!isReady) return <Text>Loading...</Text>;
return <Button title="Track" onPress={() => layers?.track('button_press')} />;
}useRequiredLayers
function useRequiredLayers(): LayersReactNative;Returns the SDK instance, throwing an error if not yet initialized or used outside a <LayersProvider>. Use when your component requires the SDK to be available.
function CheckoutButton() {
const layers = useRequiredLayers();
return (
<Button
title="Checkout"
onPress={() => layers.track('checkout_started', { cart_value: 49.99 })}
/>
);
}useLayersTrack
function useLayersTrack(): (eventName: string, properties?: EventProperties) => void;Returns a stable, memoized track function. No-op until the SDK initializes.
const track = useLayersTrack();
track('signup_click', { source: 'hero' });useLayersScreen
function useLayersScreen(): (screenName: string, properties?: EventProperties) => void;Returns a stable, memoized screen tracking function. No-op until the SDK initializes.
const screen = useLayersScreen();
useEffect(() => {
screen('Profile');
}, []);Imperative API
All exports from @layers/react-native are re-exported from @layers/expo. You can use the SDK without the provider:
import { LayersReactNative } from '@layers/expo';
const layers = new LayersReactNative({
appId: 'your-app-id',
environment: 'production'
});
await layers.init();
layers.track('event_name', { key: 'value' });
layers.screen('ScreenName');
layers.setAppUserId('user_123');
await layers.setUserProperties({ plan: 'premium' });
await layers.setConsent({ analytics: true, advertising: false });
await layers.flush();
layers.shutdown();See the @layers/react-native README for the full imperative API documentation.
ATT (App Tracking Transparency) -- iOS
Via Provider
Set requestTracking={true} on <LayersProvider> to automatically request ATT permission on mount.
Via Expo Functions
import { getExpoTrackingStatus, requestExpoTrackingPermission } from '@layers/expo';
// Request permission and record ATT status plus an authorized IDFA on the Layers instance
const status = await requestExpoTrackingPermission(layers);
// Returns: 'authorized' | 'denied' | 'restricted' | 'not_determined'
// Check current status without prompting
const currentStatus = await getExpoTrackingStatus();When layers is passed, requestExpoTrackingPermission automatically:
- Records the ATT status while preserving the existing device context
- Collects IDFA only when ATT is authorized
ATT controls IDFA availability only. It does not change Layers consent. Call
layers.setConsent(...) separately when your app's own consent flow changes
Layers collection or delivery policy.
Attribution continues when IDFA is unavailable using the other signals the SDK has collected, including its install/device context and available click or deep link identifiers.
Via SDK Instance
const status = await layers.requestTrackingPermission();This also auto-detects expo-tracking-transparency and prefers it when available.
Deep Links
Via Provider
Set enableDeepLinks={true} (default) and provide an onDeepLink callback:
<LayersProvider
config={config}
enableDeepLinks={true}
onDeepLink={(data) => {
console.log('Deep link:', data.url);
navigation.navigate(data.path);
}}
>Via Expo Functions
import { parseDeepLink, setupExpoDeepLinkListener } from '@layers/expo';
const cleanup = await setupExpoDeepLinkListener((data) => {
console.log('Deep link:', data.url);
console.log('Source:', data.queryParams.utm_source);
});
// Later: cleanup()Auto-Tracking
The SDK automatically tracks deep_link_opened events (configurable via autoTrackDeepLinks in the config). This runs in addition to your onDeepLink callback.
Clipboard Attribution -- iOS
import { readExpoClipboardAttribution } from '@layers/expo';
const data = await readExpoClipboardAttribution();
if (data) {
console.log('Click URL:', data.clickUrl);
console.log('Click ID:', data.clickId);
}Uses expo-clipboard under the hood. The SDK's init() also reads clipboard attribution automatically when enabled by remote config.
Expo Router Integration
<LayersProvider> tracks screen views for you. When expo-router is
installed, every route change becomes a screen_view named by the route
pattern (/(tabs)/profile/[user]), with the resolved pathname as path and
previous_screen_name set, starting with the landing route. Views recorded
this way are kept out of the SKAN engine, so turning the provider on changes
no iOS conversion value:
// app/_layout.tsx
import { LayersProvider } from '@layers/expo';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<LayersProvider config={{ appId: 'YOUR_APP_ID' }}>
<Stack />
</LayersProvider>
);
}Route params stay off by default: a deep link can put a password-reset token,
a one-time code or an email address in the URL, and those would otherwise
become event properties. Pass trackRouteParams to include them as
param_<name>; pass autoTrackScreens={false} to turn screen tracking off.
Render the provider inside the router tree (a root layout is). Above
ExpoRoot the router hooks report the default route / until the router
mounts, so a provider placed there records a phantom / screen. If your
bundler resolves @layers/expo from a different node_modules tree than the
app, hand the hooks over explicitly:
import { useGlobalSearchParams, usePathname, useSegments } from 'expo-router';
<LayersProvider
config={config}
expoRouter={{ usePathname, useGlobalSearchParams, useSegments }}
>The hook is still available on its own. It takes the SDK instance (null is
tolerated until init completes) and the two expo-router hooks, and records
the resolved pathname with the route params as properties. Routes are
deduplicated per SDK instance, so an app that keeps this call next to the
provider records each route once:
import { useLayers, useLayersExpoRouterTracking } from '@layers/expo';
import { usePathname, useGlobalSearchParams } from 'expo-router';
function RootLayout() {
const { sdk } = useLayers();
useLayersExpoRouterTracking(sdk, usePathname, useGlobalSearchParams);
return <Stack />;
}SKAdNetwork (SKAN) -- iOS
SKAN is auto-configured from the server's remote config. No additional setup is required beyond including SKAdNetwork IDs in your config plugin (which is done by default).
Access the auto-configured SKAN manager:
const skanManager = layers.getSkanManager();
if (skanManager) {
const metrics = skanManager.getMetrics();
console.log('Current conversion value:', metrics.currentValue);
}For manual SKAN configuration, see the @layers/react-native documentation.
Full Export List
From @layers/react-native
// Core
(LayersReactNative, LayersError);
// Types
(LayersRNConfig,
ConsentState,
DeviceContext,
Environment,
EventProperties,
UserProperties,
DeepLinkData,
ClipboardAttribution,
SKANConversionRule,
SKANPresetConfig,
SKANMetrics,
ATTStatus);
// SKAN
SKANManager;
// ATT
(getATTStatus, requestTrackingAuthorization, isATTAvailable, getAdvertisingId, getVendorId);
// Deep Links
(parseDeepLink, setupDeepLinkListener);
// Utilities
(getOrSetInstallId, readClipboardAttribution, useLayersExpoRouterTracking);Expo-Specific
// Config plugin
withLayers (default export from '@layers/expo/plugin')
LayersExpoPluginProps
// ATT
requestExpoTrackingPermission, getExpoTrackingStatus
// Deep Links
setupExpoDeepLinkListener
// Clipboard
readExpoClipboardAttribution
// React
LayersProvider, LayersProviderProps,
useLayers, useRequiredLayers, useLayersTrack, useLayersScreen