React Native and Expo
@x3growth/react-native is a thin TypeScript facade over the native iOS and Android SDKs, built as
an Expo Module (new architecture, React Native 0.82 or later, Expo SDK 58 tested). Consent, queue,
sessions, hashing, attribution and recordings run natively; the wrapper adds navigation hooks,
React components for consent and masking, and an Expo config plugin for the native project files.
Install
Expo (managed or with prebuild):
npx expo install @x3growth/react-nativeBare React Native: install the Expo Modules runtime once, then the package and the pods:
npx install-expo-modules@latest
npm install @x3growth/react-native
npx pod-installThe native SDKs ship inside the npm package: X3Kit (iOS, pod X3Kit with Core and Replay)
and io.x3growth:sdk / io.x3growth:sdk-replay (Android, Gradle projects) are linked from
node_modules/@x3growth/react-native/vendor by Expo autolinking. No CocoaPods trunk, Maven
Central or extra repository is needed; do not add X3Kit or io.x3growth:sdk to the app yourself.
Config plugin
Add the plugin to app.json or app.config.ts; it writes the native settings on every
npx expo prebuild and EAS build. All props are optional and validated with clear errors.
{
"expo": {
"plugins": [
["@x3growth/react-native", {
"linkDomains": ["nordlicht.app"],
"attributionEndpoint": "https://nordlicht.app",
"attUsageDescription": "We use this permission to measure which ad led to your sign-up."
}]
]
}
}| Prop | Effect |
|---|---|
linkDomains: string[] | iOS: adds applinks:<host> to the Associated Domains entitlement. Android: adds an App Links intent filter (autoVerify, https, host, pathPrefix = linkPathPrefix) to the main activity |
linkPathPrefix: string (default /n3/go) | path prefix of your X3 links for the Android intent filter |
attributionEndpoint: string (https origin) | iOS: NSAdvertisingAttributionReportEndpoint (SKAdNetwork) and AdAttributionKit › AttributionCopyEndpoint (postback copies) |
reengagementPostbackCopies: boolean (default true, only with attributionEndpoint) | iOS: AdAttributionKit › OptInForReengagementPostbackCopies, so Apple also sends copies of re-engagement postbacks |
attUsageDescription: string | iOS: NSUserTrackingUsageDescription (only when you call requestTrackingAuthorization) |
removeAdIdPermission: boolean (default true) | Android: removes com.google.android.gms.permission.AD_ID from the merged manifest |
privacyManifest: boolean (default true) | iOS: adds X3's required-reason API entry (NSPrivacyAccessedAPICategorySystemBootTime, reason 35F9.1) to the app's privacy manifest, merged without duplicates (privacy manifest) |
You still serve apple-app-site-association and assetlinks.json on your domain
(iOS, Android).
Configure
import { X3 } from "@x3growth/react-native";
X3.configure("pk_3x_YOUR_APP_KEY", {
consent: { mode: "opt_in", purposes: { recordings: true } },
host: "https://nordlicht.app/n3",
recording: { mode: "wireframe" },
});Import this file once at the top of your entry point. configure is the only call that throws: a
missing or invalid consent.mode throws a TypeError, because a forgotten mode would otherwise
silently track nothing. An invalid publishable key (not pk_3x_ plus 30 letters or digits) logs a
warning and leaves the SDK off: every call is ignored and consentState() stays pending. Every other call returns immediately and never throws (lead rejects on
errors). The wrapper always runs the native SDK with autoScreens: false (native screens would
report MainActivity or RNSScreen); screens come from the navigation hooks.
Screens
React Navigation:
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
import { useX3NavigationTracking } from "@x3growth/react-native";
export function App() {
const navigationRef = useNavigationContainerRef();
useX3NavigationTracking(navigationRef); // current route name → X3.screen on ready and on every state change
return <NavigationContainer ref={navigationRef}>{/* … */}</NavigationContainer>;
}Expo Router:
import { useX3ExpoRouterTracking } from "@x3growth/react-native";
export default function RootLayout() {
useX3ExpoRouterTracking(); // usePathname() → X3.screen(pathname), e.g. "/(tabs)/home" stays a path
return <Stack />;
}React Navigation and Expo Router are optional peer dependencies. Without either, call
X3.screen("Paywall") yourself.
Events, identify, leads, purchases
X3.track("plan_selected", { plan: "year", trial: true });
X3.identify(user.id, { email: user.email });
X3.reset();
await X3.lead({ form: "consultation", email, firstName, message, marketingConsent: true });
X3.purchase({ orderId: transactionId, value: 59.99, currency: "EUR", productId: "pro_yearly" });
const token = await X3.purchaseAccountToken(); // appAccountToken (iOS) / obfuscatedAccountId (Android)lead rejects with an Error whose code is one of notConfigured, invalid, disabled,
rateLimited, network, server. With RevenueCat, log users in with the same id you pass to
identify (Purchases.logIn(user.id)), so RevenueCat's app_user_id maps to the person
(RevenueCat).
Components
import { X3ConsentSheet, X3PrivacySettings, X3Mask, X3Unmask, useX3Consent } from "@x3growth/react-native";
<X3ConsentSheet visible={ask} onDone={(state) => setAsk(false)} privacyPolicyUrl="https://nordlicht.app/privacy" appName="Nordlicht" />
<X3PrivacySettings privacyPolicyUrl="https://nordlicht.app/privacy" />
const { state, consent, consentAll, setOptOut } = useX3Consent(); // live consent state for your own UIThe components use Modal, Switch and Pressable, follow the device's language (German for
de*, else English) and color scheme, and carry accessible roles and labels. texts overrides any
default text (default texts).
Deep links
With trackDeepLinks (default true) the wrapper passes Linking.getInitialURL() and every
Linking url event to handleOpenURL, so X3 links and UTM links are attributed without code. Call
X3.handleOpenURL(url) yourself only for URLs that reach your app another way (for example a push
notification payload) and X3.applyLinkToken(token) for magic link tokens.
Masking
<X3Mask>
<CreditCardPreview /> {/* always masked in recordings */}
</X3Mask>
<X3Unmask>
<Text>{plan.price}</Text> {/* text visible; inputs stay masked */}
</X3Unmask>
<Pressable testID="buy_button" onPress={buy} /> {/* tap target name in recordings */}X3Mask and X3Unmask are native views. testID becomes the anchor (iOS maps it to
accessibilityIdentifier, Android reads it through the module's anchor resolver). Recordings need
the purpose recordings and recording: { mode: "wireframe" }
(session recordings in apps).