ReadyNative

PostHog

Set up PostHog for analytics in an Expo app with ReadyNative: product analytics + feature flags (analytics.isFeatureEnabled / useFeatureFlag) · Expo Go OK

Pro

Expo Go: yes

Runs in Expo Go; no dev build needed for this module.

This sends product analytics to PostHog: src/lib/analytics.ts builds a posthog-react-native 4.x client once analytics consent is given and implements analytics.track/identify/screen, with an AnalyticsProvider (order 40) that follows consent and asks for ATT when enabled. PostHogProvider is not mounted - it needs a client at mount, and swapping one in after consent would remount the app - so usePostHog() and posthog-react-native's own hooks don't work; use analytics.*, useFeatureFlag, or getPostHog() / usePostHogClient() for the raw client (both undefined until consent). The root layout already calls analytics.screen(pathname) on every route change, so screen autocapture is off and touch autocapture is off too. If you set up with --with-examples there's an example at /examples/analytics with a list of the last events sent. @/lib/analytics keeps the same API whichever option you pick - pick PostHog when you want events, feature flags and a generous free tier from one service you can also self-host.

Setup

bun run setup --analytics posthog
  1. Create a project at app.posthog.com, then open [Settings] → [Project] and copy the Project API key (phc_…) into .env as EXPO_PUBLIC_POSTHOG_KEY. It's publishable and safe in the client. Without it every call is a no-op and the example shows "Configure EXPO_PUBLIC_POSTHOG_KEY".
  2. If your project lives in the EU, set EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com in .env. It's optional and defaults to https://us.i.posthog.com.
  3. Restart the dev server with bun run start -- -c so the new env vars are picked up, then open [Activity] → [Live events] in PostHog and watch for Application Opened and $screen.
  4. Persistence goes through the storage adapter (customStorage: storage from @/lib/storage), not expo-file-system, so it follows your storage module. expo-application, expo-device and expo-localization are installed to fill in $app_version, $device_name and $locale.

Going to production?

Use a separate PostHog project for production so your own test events don't skew the funnels, and swap the key per build profile (eas.json env). The key is public, so there's nothing to keep secret - don't point release builds at your dev project.

  1. Run bun run doctor - every row for this module should be green.

The keys, in one place:

KeyWhere to get it
EXPO_PUBLIC_POSTHOG_KEYhttps://app.posthog.com/settings/project → Project API key (phc_…). Publishable.
EXPO_PUBLIC_POSTHOG_HOSToptional; https://us.i.posthog.com (default) or https://eu.i.posthog.com for EU projects

Dashboards: Live events · People · Project settings. Deps: posthog-react-native ^4.71, expo-application, expo-device, expo-localization. Expo Go works, since it's all JS.

Usage

Track an event and identify the user:

import { analytics } from "@/lib/analytics";

analytics.track("checkout_started", { plan: "pro" });
analytics.identify(user.id, { email: user.email });

Reset on sign-out, so the next person isn't the same person:

import { analytics } from "@/lib/analytics";

analytics.reset();

Feature flags are on the contract, so feature code stays provider-agnostic:

import { analytics, useFeatureFlag } from "@/lib/analytics";

// Reactive: `undefined` until flags load, then the boolean or variant key.
const paywall = useFeatureFlag("new-paywall");
if (paywall === true) return <NewPaywall />;

// One-shot, outside React:
if (analytics.isFeatureEnabled("new-paywall")) analytics.track("paywall_variant", { v: "new" });
const copy = analytics.getFeatureFlag("cta-copy"); // "variant-b" | true | false | undefined

Flags load after boot and after every identify; useFeatureFlag re-renders on each load. Payloads and forced reloads use the raw client: getPostHog()?.getFeatureFlagPayload("flag"), getPostHog()?.reloadFeatureFlagsAsync(). With --analytics none or amplitude every flag reads undefined, so gate on === true.

No PostHog client exists until consent.get().analytics is true. The consent store is loaded when @/lib/consent is imported and the module follows it from import time on, so no event, /flags or remote-config request leaves the device before consent. On "yes" the client is created with defaultOptIn: false and optIn() is called explicitly; withdrawing calls optOut() and detaches the client (every call is then a no-op); consent again re-attaches the same client - a second one is never constructed. With consent/consent that means: nothing runs in the EU/EEA, UK, CH, CA, BR until the sheet is answered, it runs elsewhere, and the Settings → Privacy toggle flips it live. With consent/none it reads true. isAnalyticsConfigured only means the key is set.

analytics.reset() with a live client calls reset([InstalledAppBuild, InstalledAppVersion]), so both the anonymous id and the device id are regenerated, then re-applies consent (optIn / optOut) because PostHog's reset clears its persisted opt-out. With no live client it deletes the stored identity (storage key .posthog-rn.json), and a detached client is reset when consent comes back. The root layout's useSignOutCleanup calls wipeLocalData() - which calls analytics.reset() - on every sign-out, not only the Settings button, and on account deletion.

Privacy declarations: ProductInteraction, DeviceID, UserID and CoarseLocation, all linked: true - identify links them to the user and PostHog's GeoIP gives a coarse location.

Tracking (ATT)

The module installs expo-tracking-transparency with a purpose string in app.config.ts (NSUserTrackingUsageDescription) and ships src/lib/tracking.ts - the same tracking.getStatus() / requestPermission() + useTrackingStatus() surface as the core shim. First-party product analytics is not tracking, so features.tracking defaults to false in readynative.config.ts: nothing ever prompts and you answer "No" to tracking in App Store Connect. Flip it to true when you link data across companies (ads attribution, IDFA): AnalyticsProvider then asks once the app is in the foreground (iOS answers denied silently if asked from the background) and analytics.identify waits for granted. To pitch the value first, remove that call from the provider and call tracking.requestPermission() behind your own explainer screen. app.config.ts adds the expo-tracking-transparency plugin only when features.tracking is true. With it false the plugin is dropped, com.google.android.gms.permission.AD_ID goes into android.blockedPermissions, and the privacy manifest gets NSPrivacyTracking: false; the NSUserTrackingUsageDescription string stays, because the library is still linked and App Store Connect flags a linked ATT framework without one (ITMS-90683). doctor --store reports the flag/library state either way.

Gotchas

  • Session replay and surveys aren't enabled - they need extra native packages and a dev build.
  • bun run setup --analytics none removes everything; with --with-examples, /examples/analytics then shows "Module not installed".

Check it works (the Examples steps need a tree set up with --with-examples):

  1. Put a real EXPO_PUBLIC_POSTHOG_KEY in .env, run bun run start -- -c, and open the app on a device (Expo Go is fine).
  2. Open PostHog → Activity → Live events and wait for Application Opened and a $screen for /.
  3. Examples → "Analytics event" → tap Track event → example_pressed with source=examples/analytics appears in Live events. The SDK flushes every 10 seconds or 20 events, so background the app to force a flush.
  4. Tap Identify user, then search People for demo-user: the person exists with plan=free.
  5. Navigate between tabs and watch one $screen event arrive per pathname.
  6. Remove the key and restart: the example shows "Configure EXPO_PUBLIC_POSTHOG_KEY" and the app still boots.

Remove it

While modules/ exists (a tree set up with --keep-modules), setup does all of it:

bun run setup --analytics none --yes --keep-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. Delete the files that are still there (the demo screens are gone already unless you set up with --with-examples): src/app/examples/analytics.tsx, src/lib/__tests__/analytics-posthog.test.ts, src/lib/__tests__/tracking.test.ts, src/screens/examples/analytics-example-screen.tsx.
  2. Replace, don't delete src/lib/analytics.ts, src/lib/tracking.ts: core code imports them, so swap in the no-op version from modules/analytics/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. Uninstall the dependencies: bun remove expo-application expo-localization expo-tracking-transparency posthog-react-native. Keep any of expo-localization (also used by i18n/i18next, i18n/lingui, consent/consent) that another module you picked still needs.
  4. Drop the config plugin expo-tracking-transparency from .readynative.json → modules.app.expo.plugins (that is where app.config.ts reads it from), then rebuild the dev build.
  5. Unwrap the provider: delete <AnalyticsProvider> and its import from src/providers.tsx.
  6. Remove the env keys EXPO_PUBLIC_POSTHOG_KEY, EXPO_PUBLIC_POSTHOG_HOST from .env, .env.example and your EAS environment, and EXPO_PUBLIC_POSTHOG_KEY, EXPO_PUBLIC_POSTHOG_HOST from src/lib/env.ts.
  7. Update the privacy declarations: remove this module's entries from .readynative.json → modules.app.expo.ios.privacyManifests, then re-run bun run gen:privacy and revise your store privacy answers.
  8. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/ARCHITECTURE.md.

Reference

Everything below is generated from modules/analytics/posthog/module.json - the same file bun run setup reads, so it is what actually lands in your repo.

Install

bun run setup --analytics posthog

Module id: analytics/posthog (the default for this category).

Dependencies

PackageVersionKind
expo-application~57.0.3dependency (expo install)
expo-device~57.0.2dependency (expo install)
expo-localization~57.0.2dependency (expo install)
expo-tracking-transparency~57.0.2dependency (expo install)
posthog-react-native^4.75.0dependency

Config plugins

Merged into app.config.ts through .readynative.json (modules.app):

  • expo-tracking-transparency (with options)

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_POSTHOG_KEYyesnophc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxdashboard
EXPO_PUBLIC_POSTHOG_HOSTnonohttps://us.i.posthog.comdashboard

Keys go in .env (see .env.example). Required keys are checked by bun run doctor; Server-only keys have no EXPO_PUBLIC_ prefix, are read only by API routes and never reach the bundle.

Privacy

Play Data safety draft: collects App interactions (events, screens), Device or other ids (distinct id, device id), User IDs (when you call analytics.identify), Approximate location (GeoIP from the request IP); shared with PostHog (processor). Source of truth: vendor disclosure.

Apple privacy manifest data types (composed into ios.privacyManifests by setup):

TypeLinked to userTrackingPurposes
ProductInteractionyesnoAnalytics
DeviceIDyesnoAnalytics
UserIDyesnoAnalytics
CoarseLocationyesnoAnalytics

Providers

Rendered in src/providers.tsx (lower order = outermost):

OrderProviderFrom
40AnalyticsProvider@/lib/analytics

Compatibility

Doctor checks

  • env: EXPO_PUBLIC_POSTHOG_KEY

After setup

  1. PostHog: create a project at https://app.posthog.com, copy the Project API key into EXPO_PUBLIC_POSTHOG_KEY.
  2. PostHog: EU cloud? set EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com (default is the US cloud).
  3. PostHog: open Activity > Live events, call analytics.track("hello") from any screen (or open /examples/analytics with --with-examples) and watch it arrive.
  4. App Tracking Transparency: expo-tracking-transparency is installed, but app.config.ts only adds its plugin, the NSUserTrackingUsageDescription purpose string and NSPrivacyTracking: true when features.tracking is true in readynative.config.ts - set it only if you link data across companies (ads attribution, IDFA); the prompt then runs once the app is active and analytics.identify waits for it. With it off (the default) Android's AD_ID permission is blocked and you answer 'No' to tracking in App Store Connect.

Files

6 files copied to the project root
  • src/app/examples/analytics.tsx
  • src/lib/__tests__/analytics-posthog.test.ts
  • src/lib/__tests__/tracking.test.ts
  • src/lib/analytics.ts
  • src/lib/tracking.ts
  • src/screens/examples/analytics-example-screen.tsx

On this page

Get ReadyNative