BUILT INTO EVERY GOODSPEED APP
PostHog Analytics + Feature Flags
PostHog is initialized null-safe at boot (no crash when the key is absent in dev), with auto screen tracking, user identification on sign-in, and a useFlag hook that polls once a minute for server-controlled feature flags with deterministic rollout bucketing.
- Tier: Core
- Status: Config-toggled
- Config: features.analytics.enabled
WHY IT MATTERS
Most analytics integrations are copy-paste jobs: drop in an API key, sprinkle captureEvent calls across the codebase, and accept the risk that the SDK throws when the key is missing in a development environment. The GAS template takes a different approach across every layer. The PostHog client is never instantiated at module load time. Instead, getPostHog() uses a lazy singleton: it reads the API key from gasConfig.backend.posthog.apiKey, checks whether analytics consent has been granted (respecting GDPR opt-in flow when gasConfig.features.compliance.gdprConsent is true), and only then creates the PostHog instance. If the key is absent in a dev environment, the function returns null and every downstream caller is a no-op. No crash, no console error, no conditional guard in product code. The same null-safe pattern applies to identifyPostHogUser(), captureEvent(), and the useAnalytics() hook exported from hooks/useAnalytics.ts. Product engineers call track(EVENTS.paywall_viewed, { screen: 'home' }) without checking whether PostHog is configured, because the check happens once inside the module.
Feature flags are available through the same SDK instance without additional setup. The PostHog React Native SDK ships flag evaluation and polling in the same package as event capture, so there is no second initialization, no second API key, and no second network request path. The useFlag hook polls for flag updates once a minute using the SDK's built-in getFeatureFlag polling. Rollout bucketing is deterministic per distinct ID: the same user always evaluates the same flag value across devices and sessions, which is critical for A/B experiments where inconsistent assignment produces confounded results. The EVENTS const in lib/events.ts defines every tracked event name as a typed string literal, so TypeScript catches typos at compile time rather than at the analytics dashboard when you notice a mysterious drop in a metric you expected to track. Consent revocation is handled in the onConsentChanged() export: calling it with false calls ph.reset() and clears the singleton, so no further events are sent until the user re-consents.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from lib/posthog.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// lib/posthog.ts: consent-aware lazy PostHog singleton
import PostHog from 'posthog-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { gasConfig } from '../gas.config';
let _posthog: PostHog | null = null;
let _consentChecked = false;
let _consentGranted = false;
const CONSENT_KEY = `@${gasConfig.app.slug}:consent`;
export async function checkAnalyticsConsent(): Promise<boolean> {
if (_consentChecked) return _consentGranted;
if (!gasConfig.features.compliance.gdprConsent) {
_consentChecked = true; _consentGranted = true; return true;
}
try {
const raw = await AsyncStorage.getItem(CONSENT_KEY);
_consentGranted = raw ? JSON.parse(raw).analytics === true : true;
} catch { _consentGranted = true; }
_consentChecked = true; return _consentGranted;
}
export async function getPostHog(): Promise<PostHog | null> {
if (!gasConfig.features.analytics.enabled) return null;
if (!gasConfig.backend.posthog.apiKey) return null;
if (!await checkAnalyticsConsent()) return null;
if (!_posthog) {
_posthog = new PostHog(gasConfig.backend.posthog.apiKey, {
host: gasConfig.backend.posthog.host,
});
}
return _posthog;
}
export async function identifyPostHogUser(userId: string): Promise<void> {
const ph = await getPostHog();
ph?.identify(userId);
}Source: goodspeed-apps/gas-template → lib/posthog.ts
HONEST LIMITS
When PostHog Analytics + Feature Flags is the wrong choice
The managed PostHog cloud (app.posthog.com) stores events and flag evaluations on PostHog's infrastructure in the United States by default. For apps operating under EU General Data Protection Regulation or similar data-residency mandates, event data containing personally identifiable information must remain within a defined geographic boundary. PostHog offers a self-hosted deployment (Docker Compose or Kubernetes) that keeps all data on your own infrastructure. Switching to self-hosted requires updating gasConfig.backend.posthog.host to your PostHog instance URL and provisioning the PostHog stack separately; the template code itself does not change. EU healthcare apps governed by GDPR Article 9 (special-category health data) face a higher bar: a data protection impact assessment may conclude that even a self-hosted PostHog instance is insufficient if session recording captures screen content that includes health information. In those cases the right path is to disable session recording via gasConfig.features.analytics.sessionRecording and to sanitize all event properties through the sanitize() wrapper already present in lib/posthog.ts before they leave the device. Government and public-sector apps in jurisdictions like FedRAMP-authorized US federal environments or UK OFFICIAL-SENSITIVE classification tiers face procurement rules that prohibit third-party analytics services entirely, including self-hosted open-source ones. Those apps should disable PostHog completely (features.analytics.enabled: false) and instrument telemetry through an approved in-house logging pipeline instead. The null-safe architecture of the module means disabling the feature leaves zero dead code paths in the generated app: every captureEvent() call becomes a no-op, no network traffic is generated, and the posthog-react-native package can be removed from package.json without breaking any import.
Tier: Core · Config-toggled
Evaluate your use case
Check whether posthog analytics + feature flags aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.analytics.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes posthog analytics + feature flags. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
PostHog Analytics + Feature Flags capability breakdown
Concrete dimensions of what the built-in posthog analytics + feature flags implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Initialization | getPostHog() returns null when the API key is absent or consent has not been granted. No crash, no silent data loss. | Null-safe / lazy |
| GDPR consent gate | checkAnalyticsConsent() reads the AsyncStorage consent record before instantiating PostHog. Non-GDPR apps skip the check via gasConfig.features.compliance.gdprConsent. | Opt-in guard |
| User identity | identifyPostHogUser() associates the Supabase user ID with the PostHog distinct ID after sign-in. Logout calls ph.reset() to clear the identity. | Supabase-linked |
| Feature flag polling | Feature flags are re-fetched once a minute via PostHog's built-in SDK polling. Values are deterministic per distinct ID for consistent rollout bucketing. | 60-second poll |
| Event catalog | lib/events.ts exports a typed EVENTS const (app_opened, signed_up, paywall_viewed, etc.); all captureEvent() calls reference EVENTS keys, not raw strings. | Typed EVENTS const |
COMMON QUESTIONS
What happens if I forget to set the PostHog API key in dev?
Nothing crashes. getPostHog() returns null when gasConfig.backend.posthog.apiKey is falsy, and every downstream function (captureEvent, identifyPostHogUser, useAnalytics().track) is a safe no-op. In the GAS template pattern, the API key is read from an environment variable at build time via gasConfig. If the variable is absent from your .env.local in development, the module silently disables itself. You will not see PostHog events in your dashboard, but you will also not see any errors or warnings in the console. This design means you can run the full app in development without a PostHog account and switch it on for staging and production by setting the environment variable. The same null-safe pattern applies to all analytics calls scattered through the app code, so you never need a conditional like if (posthog) around individual events.
How does GDPR consent interact with PostHog initialization?
The checkAnalyticsConsent() function is the gating mechanism. When gasConfig.features.compliance.gdprConsent is true (opt-in posture), the function reads a consent record from AsyncStorage keyed by the app slug. If the record is absent, the default is true (opt-out posture, meaning the user has not explicitly declined). The ConsentBanner component in the template calls onConsentChanged(false) when the user declines analytics, which calls ph.reset() and clears the singleton. Once cleared, any future call to getPostHog() re-checks consent and returns null until the user re-accepts. For apps without GDPR requirements, set gdprConsent: false in gasConfig and the consent check is skipped entirely, reducing the initialization path to a simple API key null check. The distinction between opt-in (require affirmative consent before firing) and opt-out (fire unless declined) is controlled solely by the gasConfig flag, with no code changes required.
How do I add a new tracked event?
Add the event name as a new key to the EVENTS const in lib/events.ts. The const is typed as Record with string literal values, and EventName is derived from it via typeof EVENTS[keyof typeof EVENTS]. TypeScript will then enforce that every captureEvent() call uses a valid event name. In your component or service, call const { track } = useAnalytics() and then track(EVENTS.your_new_event, { property: value }). The useAnalytics hook is a thin memoized wrapper around captureEvent that avoids creating a new function reference on every render. For events outside React components, import captureEvent directly from lib/posthog and call it async. Properties are sanitized by the sanitize() function before being handed to PostHog.capture(), which runs them through sanitizeData from lib/sentry.ts to strip any PII fields defined in your sanitization config.
Can PostHog feature flags replace the built-in feature-flags module?
They serve different purposes and work well together. The built-in feature-flags module stores flags in your own Supabase table, which means zero external dependency, offline support via the cached snapshot, and full control over the flag schema (you can add segment columns, TTLs, or per-app overrides without waiting on a third-party roadmap). PostHog feature flags are the right choice when you want flag evaluation tightly coupled with analytics data, such as automatically logging which variant a user was assigned to in an A/B experiment alongside their behavioral events. For pure kill switches and gradual rollouts where you need offline fallback and want to keep flag data in your own database, use the built-in module. For A/B experiments where the assignment needs to be correlated with PostHog funnels and retention cohorts, use PostHog flags via the SDK. Both can coexist: the GAS template wires PostHog's SDK and the Supabase flag module independently, and you can call both useFlag('posthog_key') via the SDK and getFlag('supabase_key') via the built-in module in the same component.
GET IT BUILT INTO YOUR APP