AthenodeAthenode

Back to Product Ops (Stripe, Sentry, PostHog)

instrument-product-analytics

Created here

Add PostHog product analytics events to track user behavior. Use after implementing new features or reviewing PRs to ensure meaningful user actions are captured. Also handles initial PostHog SDK setup if not yet installed.

SKILL.md

Add PostHog product analytics events

Use this skill to add product analytics events (capture calls) that track meaningful user actions in new or changed code. Use it after implementing features or reviewing PRs to ensure key user behaviors are captured. If PostHog is not yet installed, this skill also covers initial SDK setup. Supports any framework or language.

Supported frameworks and languages: Next.js, React Router, Nuxt, Vue, TanStack Start, SvelteKit, Astro, Angular, Django, Flask, FastAPI, Laravel, PHP, Ruby on Rails, Go, Elixir, Android, iOS, Flutter, React Native, Expo, and more.

Instructions

Follow these steps IN ORDER:

STEP 1: Analyze the codebase and detect the platform.

Look for dependency files (package.json, pubspec.yaml, Podfile, Package.swift, requirements.txt, Gemfile, composer.json, go.mod, mix.exs, etc.) to determine the framework and language.

Look for lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb, go.sum, pubspec.lock, Podfile.lock, Package.resolved, mix.lock) to determine the package manager.

  • Check for existing PostHog setup. If PostHog is already installed and initialized, skip to STEP 5.

STEP 2: Research integration. (Skip if PostHog is already set up.) 2.1. Find the reference file below that matches the detected framework — it is the source of truth for SDK initialization, provider setup, and event capture patterns. Read it now. 2.2. If no reference matches, fall back to your general knowledge and web search. Use posthog.com/docs as the primary search source.

STEP 3: Install the PostHog SDK. (Skip if PostHog is already set up.)

  • Add the PostHog SDK package for the detected platform. Do not manually edit package.json — use the package manager's install command.

STEP 4: Initialize PostHog. (Skip if PostHog is already set up.)

  • Follow the framework reference for where and how to initialize. This varies significantly by framework (e.g., instrumentation-client.ts for Next.js 15.3+, AppConfig.ready() for Django, create_app() for Flask).

STEP 5: Plan event tracking.

  • From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events.
  • Also look for files related to login that could be used for identifying users, along with error handling.
  • Find any existing posthog.capture() code. Make note of event name formatting. Don't duplicate existing events; supplement them.
  • Track actions only, not pageviews (those can be captured automatically). Exceptions can be made for "viewed"-type events at the top of a conversion funnel.
  • Server-side events are REQUIRED if the project includes any instrumentable server-side code (API routes, server actions, webhook handlers, payment/checkout completion, authentication endpoints).

STEP 6: Implement event capture.

  • For each planned event, add posthog.capture() calls with useful properties.
  • If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
  • Do not alter the fundamental architecture of existing files. Make additions minimal and targeted.
  • You must read a file immediately before attempting to write it.

STEP 7: Identify users.

  • Add PostHog identify() calls on the client side during login and signup events. Use the contents of login and signup forms to identify users on submit.
  • If both frontend and backend exist, pass the client-side session and distinct ID using X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID headers to the server-side code. On the server side, make sure events have a matching distinct ID.

STEP 8: Add error tracking.

  • Add PostHog exception capture error tracking to relevant files, particularly around critical user flows and API boundaries.

STEP 9: Set up environment variables.

  • Check if the project already has PostHog environment variables configured (e.g. in .env, .env.local, or framework-specific env files). If valid values already exist, skip this step.
  • If the PostHog project token is missing, use the PostHog MCP server's projects-get tool to retrieve the project's api_token. If multiple projects are returned, ask the user which project to use. If the MCP server is not connected or not authenticated, ask the user for their PostHog project token instead.
  • For the PostHog host URL: check the projects-get MCP response for a region field — US maps to https://us.i.posthog.com, EU maps to https://eu.i.posthog.com. If the region is not available from the MCP response or from existing project configuration, ask the user: "Are you on PostHog US Cloud or EU Cloud?" Do not assume US Cloud.
  • Write these values to the appropriate env file using the framework's naming convention.
  • Reference these environment variables in code instead of hardcoding them.

STEP 10: Verify and clean up.

  • Check the project for errors. Look for type checking or build scripts in package.json.
  • Ensure any components created were actually used.
  • Run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Never run formatting or linting across the entire project's codebase.

Reference files

  • references/EXAMPLE-next-app-router.md - next-app-router example project code
  • references/EXAMPLE-next-pages-router.md - next-pages-router example project code
  • references/EXAMPLE-react-react-router-6.md - react-react-router-6 example project code
  • references/EXAMPLE-react-react-router-7-framework.md - react-react-router-7-framework example project code
  • references/EXAMPLE-react-react-router-7-data.md - react-react-router-7-data example project code
  • references/EXAMPLE-react-react-router-7-declarative.md - react-react-router-7-declarative example project code
  • references/EXAMPLE-nuxt-3-6.md - nuxt-3-6 example project code
  • references/EXAMPLE-nuxt-4.md - nuxt-4 example project code
  • references/EXAMPLE-vue-3.md - vue-3 example project code
  • references/EXAMPLE-react-tanstack-router-file-based.md - react-tanstack-router-file-based example project code
  • references/EXAMPLE-react-tanstack-router-code-based.md - react-tanstack-router-code-based example project code
  • references/EXAMPLE-tanstack-start.md - tanstack-start example project code
  • references/EXAMPLE-sveltekit.md - sveltekit example project code
  • references/EXAMPLE-astro-static.md - astro-static example project code
  • references/EXAMPLE-astro-view-transitions.md - astro-view-transitions example project code
  • references/EXAMPLE-astro-ssr.md - astro-ssr example project code
  • references/EXAMPLE-astro-hybrid.md - astro-hybrid example project code
  • references/EXAMPLE-angular.md - angular example project code
  • references/EXAMPLE-django.md - django example project code
  • references/EXAMPLE-flask.md - flask example project code
  • references/EXAMPLE-fastapi.md - fastapi example project code
  • references/EXAMPLE-python.md - python example project code
  • references/EXAMPLE-laravel.md - laravel example project code
  • references/EXAMPLE-php.md - php example project code
  • references/EXAMPLE-ruby-on-rails.md - ruby-on-rails example project code
  • references/EXAMPLE-ruby.md - ruby example project code
  • references/EXAMPLE-android.md - android example project code
  • references/EXAMPLE-swift.md - swift example project code
  • references/EXAMPLE-react-native.md - react-native example project code
  • references/EXAMPLE-expo.md - expo example project code
  • references/next-js.md - Next.js
  • references/react-router-v6.md - React router v6
  • references/react-router-v7-framework-mode.md - React router v7 framework mode (remix v3)
  • references/react-router-v7-data-mode.md - React router v7 data mode
  • references/react-router-v7-declarative-mode.md - React router v7 declarative mode
  • references/nuxt-js-3-6.md - Nuxt.js (v3.0 to v3.6)
  • references/nuxt-js.md - Nuxt.js
  • references/vue-js.md - Vue.js
  • references/tanstack-start.md - Tanstack start
  • references/svelte.md - Svelte
  • references/astro.md - Astro
  • references/angular.md - Angular
  • references/django.md - Django
  • references/flask.md - Flask
  • references/python.md - Python
  • references/posthog-python.md - PostHog python SDK
  • references/dotnet.md - .net
  • references/elixir.md - Elixir
  • references/go.md - Go
  • references/laravel.md - Laravel
  • references/php.md - Php
  • references/ruby-on-rails.md - Ruby on rails
  • references/ruby.md - Ruby
  • references/android.md - Android
  • references/ios.md - Ios
  • references/usage.md - Ios SDK usage
  • references/configuration.md - Ios SDK configuration
  • references/flutter.md - Flutter
  • references/react-native.md - React native
  • references/identify-users.md - Identify users
  • references/COMMANDMENTS.md - Framework-specific rules the integration must follow

Each framework reference contains SDK-specific installation, initialization, and usage patterns. Find the one matching the user's stack.

Key principles

  • Environment variables: Always use environment variables for PostHog keys. Never hardcode them.
  • Minimal changes: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
  • Match the docs: Follow the framework reference's initialization and capture patterns exactly.

SKILL.md

SKILL.md holds the skill's instructions; it is edited on the Instructions tab.

references/COMMANDMENTS.md

Framework rules

Follow these when integrating PostHog into this framework.

  • A missing PostHog configuration must never break the app — read keys optionally (never a required setting), guard init and capture behind their presence, and keep build and boot working with no PostHog environment set — but never silently: in development or debug builds fail loudly, using the language's idiomatic error, with the message "<VAR> variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once <VAR> is configured" (substituting the actual variable name); production stays a no-op

references/EXAMPLE-android.md

PostHog android Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/android


README.md

PostHog Android example

This is an Android example demonstrating PostHog integration with product analytics, session replay, and error tracking using Kotlin and Jetpack Compose.

This example uses the PostHog Android SDK (posthog-android) to provide automatic PostHog integration with built-in error tracking, session replay, and simplified configuration.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Automatic error capture and crash reporting
  • User Authentication: Demo login system with PostHog user identification
  • Event Tracking: Examples of custom event tracking throughout the app

Getting Started

1. Prerequisites
  • Android Studio (latest stable version)
  • Android SDK (API level 24 or higher)
  • JDK 11 or higher
  • Gradle 8.0 or higher
  • A PostHog account
2. Configure Environment Variables

The PostHog configuration is stored in local.properties (this file is gitignored):

# PostHog configuration
posthog.apiKey=your_posthog_project_token
posthog.host=https://us.i.posthog.com

Alternatively, you can configure PostHog in your build.gradle file:

android {
    defaultConfig {
        buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"your_posthog_project_token\""
        buildConfigField "String", "POSTHOG_HOST", "\"https://us.i.posthog.com\""
    }
}

Get your PostHog project token from your PostHog project settings.

3. Build and Run
  1. Open the project in Android Studio
  2. Sync Gradle files
  3. Run the app on an emulator or physical device

Project Structure

├── app/
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/com/example/posthog/
│   │   │   │   ├── BurritoApplication.kt      # Application class with PostHog initialization
│   │   │   │   ├── MainActivity.kt           # Main activity
│   │   │   │   ├── ui/
│   │   │   │   │   ├── screens/
│   │   │   │   │   │   ├── LoginScreen.kt     # Login screen with user identification
│   │   │   │   │   │   ├── BurritoScreen.kt   # Demo feature screen with event tracking
│   │   │   │   │   │   └── ProfileScreen.kt   # User profile with error tracking demo
│   │   │   │   │   └── components/            # Reusable UI components
│   │   │   │   └── utils/
│   │   │   │       └── PostHogHelper.kt       # PostHog utility functions
│   │   │   ├── res/                           # Resources (layouts, strings, etc.)
│   │   │   └── AndroidManifest.xml            # App manifest
│   │   └── test/                              # Unit tests
│   └── build.gradle                           # App-level Gradle configuration
├── build.gradle                               # Project-level Gradle configuration
├── settings.gradle                            # Gradle settings
└── local.properties                           # Local configuration (gitignored)

Key Integration Points

Application Initialization (BurritoApplication.kt)

PostHog is initialized in the Application class to ensure it's available throughout the app lifecycle:

class BurritoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        val posthogConfig = PostHogConfig(
            apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
            host = BuildConfig.POSTHOG_HOST
        ).apply {
            // Enable session replay
            sessionReplay = true
            
            // Enable automatic exception capture
            captureApplicationLifecycleEvents = true
            captureDeepLinks = true
            captureScreenViews = true
        }
        
        PostHog.setup(this, posthogConfig)
    }
}

Key Points:

  • PostHog is initialized in onCreate() to ensure it's initialized as early as possible
  • Configuration is loaded from BuildConfig (set in build.gradle)
  • Session replay, lifecycle events, and screen views are enabled
  • The Application class must be registered in AndroidManifest.xml
User Identification (LoginScreen.kt)

Users are identified when they log in:

val posthog = PostHog.getInstance()

fun handleLogin(username: String, password: String) {
    // Authenticate user
    val success = authenticateUser(username, password)
    
    if (success) {
        // Identify the user once on login/sign up
        posthog.identify(
            distinctId = username,
            properties = mapOf(
                "username" to username,
                "login_method" to "password"
            )
        )
        
        // Capture login event
        posthog.capture("user_logged_in", mapOf(
            "username" to username
        ))
    }
}

Key Points:

  • identify() is called once when the user logs in or signs up
  • User properties can be set during identification
  • Events are captured using capture() with event names and properties
  • The distinctId should be a unique identifier for the user
Event Tracking (BurritoScreen.kt)

Custom events are tracked throughout the app:

val posthog = PostHog.getInstance()

fun handleBurritoConsideration() {
    // Track custom event
    posthog.capture("burrito_considered", mapOf(
        "total_considerations" to considerationCount,
        "username" to currentUser.username,
        "timestamp" to System.currentTimeMillis()
    ))
    
    // Update user properties
    posthog.setUserProperties(mapOf(
        "last_burrito_consideration" to System.currentTimeMillis(),
        "total_burrito_considerations" to considerationCount
    ))
}

Key Points:

  • Events are captured with capture() method
  • Event properties provide context about the event
  • User properties can be updated with setUserProperties()
  • Properties can be strings, numbers, booleans, or dates
Error Tracking

Errors are captured automatically and can also be tracked manually:

Automatic Error Capture: PostHog automatically captures uncaught exceptions when configured:

val posthogConfig = PostHogConfig(
    apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
    host = BuildConfig.POSTHOG_HOST
).apply {
    // Automatic exception capture is enabled by default
    captureApplicationLifecycleEvents = true
}

Manual Error Capture:

val posthog = PostHog.getInstance()

try {
    // Risky operation
    performRiskyOperation()
} catch (e: Exception) {
    // Capture exception manually
    posthog.captureException(e, mapOf(
        "context" to "burrito_consideration",
        "user_id" to currentUser.id
    ))
}
Screen View Tracking

Screen views are automatically tracked when captureScreenViews is enabled. You can also manually track screen views:

val posthog = PostHog.getInstance()

// Manual screen view tracking
posthog.screen("BurritoScreen", mapOf(
    "screen_category" to "features",
    "user_type" to "premium"
))
Session Replay

Session replay is enabled in the PostHog configuration:

val posthogConfig = PostHogConfig(
    apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
    host = BuildConfig.POSTHOG_HOST
).apply {
    sessionReplay = true
    sessionReplayConfig = SessionReplayConfig(
        maskAllInputs = false, // Set to true to mask all input fields
        maskAllText = false    // Set to true to mask all text
    )
}
Accessing PostHog in Components

PostHog is accessed via the singleton instance:

val posthog = PostHog.getInstance()
posthog.capture("event_name", mapOf("property" to "value"))

The instance is available throughout your application after initialization.

Gradle Configuration

App-level build.gradle
android {
    defaultConfig {
        // PostHog configuration
        buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"${project.findProperty("posthog.apiKey") ?: ""}\""
        buildConfigField "String", "POSTHOG_HOST", "\"${project.findProperty("posthog.host") ?: "https://us.i.posthog.com"}\""
    }
}

dependencies {
    // PostHog Android SDK
    implementation 'com.posthog:posthog-android:3.+'
    
    // Other dependencies...
}
Reading from local.properties

The local.properties file is automatically read by Gradle:

def localProperties = new Properties()
localProperties.load(new FileInputStream(rootProject.file("local.properties")))

android {
    defaultConfig {
        buildConfigField "String", "POSTHOG_PROJECT_TOKEN", "\"${localProperties.getProperty("posthog.apiKey", "")}\""
        buildConfigField "String", "POSTHOG_HOST", "\"${localProperties.getProperty("posthog.host", "https://us.i.posthog.com")}\""
    }
}

Best Practices

  1. Initialize Early: Initialize PostHog in your Application.onCreate() method
  2. Identify Once: Call identify() once when the user logs in or signs up
  3. Use Meaningful Event Names: Use clear, descriptive event names (e.g., user_logged_in instead of login)
  4. Include Context: Add relevant properties to events for better analysis
  5. Handle Errors Gracefully: Don't let PostHog errors break your app
  6. Test in Development: Use a separate PostHog project for development/testing
  7. Respect Privacy: Be mindful of PII (Personally Identifiable Information) in events and properties

Learn More


app/src/main/java/com/example/posthog/BurritoApp.kt

package com.example.posthog

import android.app.Application
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig

class BurritoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        // Initialize PostHog early in Application lifecycle
        val config = PostHogAndroidConfig(
            apiKey = BuildConfig.POSTHOG_PROJECT_TOKEN,
            host = BuildConfig.POSTHOG_HOST,
        ).apply {
            debug = true
            errorTrackingConfig.autoCapture = true
        }
        
        PostHogAndroid.setup(this, config)
    }
}

app/src/main/java/com/example/posthog/data/User.kt

package com.example.posthog.data

data class User(
    val username: String,
    val burritoConsiderations: Int = 0
)

app/src/main/java/com/example/posthog/data/UserRepository.kt

package com.example.posthog.data

import android.content.Context
import android.content.SharedPreferences
import org.json.JSONObject

class UserRepository(context: Context) {

    private val prefs: SharedPreferences = context.getSharedPreferences(
        PREFS_NAME, Context.MODE_PRIVATE
    )

    companion object {
        private const val PREFS_NAME = "burrito_app_prefs"
        private const val KEY_CURRENT_USERNAME = "current_username"
        private const val KEY_USER_DATA_PREFIX = "user_data_"
    }

    fun getCurrentUsername(): String? {
        return prefs.getString(KEY_CURRENT_USERNAME, null)
    }

    fun getUser(username: String): User? {
        val json = prefs.getString("$KEY_USER_DATA_PREFIX$username", null) ?: return null
        return try {
            val obj = JSONObject(json)
            User(
                username = obj.getString("username"),
                burritoConsiderations = obj.getInt("burritoConsiderations")
            )
        } catch (e: Exception) {
            null
        }
    }

    fun saveUser(user: User) {
        val json = JSONObject().apply {
            put("username", user.username)
            put("burritoConsiderations", user.burritoConsiderations)
        }.toString()

        prefs.edit()
            .putString("$KEY_USER_DATA_PREFIX${user.username}", json)
            .putString(KEY_CURRENT_USERNAME, user.username)
            .apply()
    }

    fun clearCurrentUser() {
        prefs.edit()
            .remove(KEY_CURRENT_USERNAME)
            .apply()
    }

    fun getCurrentUser(): User? {
        val username = getCurrentUsername() ?: return null
        return getUser(username)
    }
}

app/src/main/java/com/example/posthog/MainActivity.kt

package com.example.posthog

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.posthog.navigation.NavGraph
import com.example.posthog.navigation.Screen
import com.example.posthog.ui.components.AppHeader
import com.example.posthog.ui.components.BottomNavBar
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.PostHogTheme
import com.example.posthog.viewmodel.AuthViewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            PostHogTheme {
                BurritoApp()
            }
        }
    }
}

@Composable
fun BurritoApp() {
    val navController = rememberNavController()
    val viewModel: AuthViewModel = viewModel()

    val isAuthenticated by viewModel.isAuthenticated.collectAsState()
    val currentUser by viewModel.currentUser.collectAsState()

    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            AppHeader(
                isAuthenticated = isAuthenticated,
                username = currentUser?.username,
                currentRoute = currentRoute,
                onNavigate = { route ->
                    navController.navigate(route) {
                        popUpTo(Screen.Home.route)
                        launchSingleTop = true
                    }
                },
                onLogout = {
                    viewModel.logout()
                    navController.navigate(Screen.Home.route) {
                        popUpTo(Screen.Home.route) { inclusive = true }
                    }
                }
            )
        },
        bottomBar = {
            BottomNavBar(
                isAuthenticated = isAuthenticated,
                currentRoute = currentRoute,
                onNavigate = { route ->
                    navController.navigate(route) {
                        popUpTo(Screen.Home.route)
                        launchSingleTop = true
                    }
                }
            )
        },
        containerColor = BackgroundGray
    ) { innerPadding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(BackgroundGray)
                .padding(innerPadding)
        ) {
            NavGraph(
                navController = navController,
                viewModel = viewModel
            )
        }
    }
}

app/src/main/java/com/example/posthog/navigation/NavGraph.kt

package com.example.posthog.navigation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.posthog.ui.screens.BurritoScreen
import com.example.posthog.ui.screens.HomeScreen
import com.example.posthog.ui.screens.ProfileScreen
import com.example.posthog.viewmodel.AuthViewModel

sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Burrito : Screen("burrito")
    object Profile : Screen("profile")
}

@Composable
fun NavGraph(
    navController: NavHostController,
    viewModel: AuthViewModel
) {
    val isAuthenticated by viewModel.isAuthenticated.collectAsState()
    val currentUser by viewModel.currentUser.collectAsState()

    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                isAuthenticated = isAuthenticated,
                username = currentUser?.username,
                onLogin = { username -> viewModel.login(username) }
            )
        }

        composable(Screen.Burrito.route) {
            if (!isAuthenticated) {
                LaunchedEffect(Unit) {
                    navController.navigate(Screen.Home.route) {
                        popUpTo(Screen.Home.route) { inclusive = true }
                    }
                }
            } else {
                BurritoScreen(
                    burritoCount = currentUser?.burritoConsiderations ?: 0,
                    onConsiderBurrito = { viewModel.incrementBurritoCount() }
                )
            }
        }

        composable(Screen.Profile.route) {
            if (!isAuthenticated) {
                LaunchedEffect(Unit) {
                    navController.navigate(Screen.Home.route) {
                        popUpTo(Screen.Home.route) { inclusive = true }
                    }
                }
            } else {
                ProfileScreen(
                    username = currentUser?.username ?: "",
                    burritoCount = currentUser?.burritoConsiderations ?: 0
                )
            }
        }
    }
}

app/src/main/java/com/example/posthog/ui/components/AppHeader.kt

package com.example.posthog.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.DarkHeader
import com.example.posthog.ui.theme.ErrorRed
import com.example.posthog.ui.theme.White

@Composable
fun AppHeader(
    isAuthenticated: Boolean,
    username: String?,
    currentRoute: String?,
    onNavigate: (String) -> Unit,
    onLogout: () -> Unit
) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(DarkHeader)
            .padding(horizontal = 16.dp, vertical = 12.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            // App title
            Text(
                text = "Burrito App",
                color = White,
                fontSize = 18.sp
            )

            // User section (right side)
            if (isAuthenticated && username != null) {
                Row(
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = username,
                        color = White,
                        fontSize = 14.sp
                    )

                    Button(
                        onClick = onLogout,
                        colors = ButtonDefaults.buttonColors(
                            containerColor = ErrorRed
                        ),
                        shape = RoundedCornerShape(4.dp),
                        contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
                    ) {
                        Text(
                            text = "Logout",
                            color = White,
                            fontSize = 14.sp
                        )
                    }
                }
            }
        }
    }
}

app/src/main/java/com/example/posthog/ui/components/BottomNavBar.kt

package com.example.posthog.ui.components

import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.navigation.Screen
import com.example.posthog.ui.theme.PrimaryBlue
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White

sealed class BottomNavItem(
    val route: String,
    val label: String,
    val selectedIcon: ImageVector?,
    val unselectedIcon: ImageVector?
) {
    object Home : BottomNavItem(
        route = Screen.Home.route,
        label = "Home",
        selectedIcon = Icons.Filled.Home,
        unselectedIcon = Icons.Outlined.Home
    )

    object Burrito : BottomNavItem(
        route = Screen.Burrito.route,
        label = "Burrito",
        selectedIcon = null, // We'll use a custom icon or emoji
        unselectedIcon = null
    )

    object Profile : BottomNavItem(
        route = Screen.Profile.route,
        label = "Profile",
        selectedIcon = Icons.Filled.Person,
        unselectedIcon = Icons.Outlined.Person
    )
}

@Composable
fun BottomNavBar(
    isAuthenticated: Boolean,
    currentRoute: String?,
    onNavigate: (String) -> Unit
) {
    val items = if (isAuthenticated) {
        listOf(BottomNavItem.Home, BottomNavItem.Burrito, BottomNavItem.Profile)
    } else {
        listOf(BottomNavItem.Home)
    }

    NavigationBar(
        containerColor = White
    ) {
        items.forEach { item ->
            val selected = currentRoute == item.route

            NavigationBarItem(
                selected = selected,
                onClick = { onNavigate(item.route) },
                icon = {
                    if (item.selectedIcon != null && item.unselectedIcon != null) {
                        Icon(
                            imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
                            contentDescription = item.label,
                            modifier = Modifier.size(24.dp)
                        )
                    } else {
                        // For Burrito, use text emoji as icon
                        Text(
                            text = "🌯",
                            fontSize = 24.sp
                        )
                    }
                },
                label = {
                    Text(
                        text = item.label,
                        fontSize = 12.sp
                    )
                },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = PrimaryBlue,
                    selectedTextColor = PrimaryBlue,
                    unselectedIconColor = TextGray,
                    unselectedTextColor = TextGray,
                    indicatorColor = PrimaryBlue.copy(alpha = 0.1f)
                )
            )
        }
    }
}

app/src/main/java/com/example/posthog/ui/components/StatsCard.kt

package com.example.posthog.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.LightGray
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray

@Composable
fun StatsCard(
    title: String,
    value: String,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .background(
                color = LightGray,
                shape = RoundedCornerShape(4.dp)
            )
            .padding(16.dp)
    ) {
        Text(
            text = title,
            color = TextGray,
            fontSize = 14.sp
        )
        Spacer(modifier = Modifier.height(4.dp))
        Text(
            text = value,
            color = TextDark,
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

app/src/main/java/com/example/posthog/ui/screens/BurritoScreen.kt

package com.example.posthog.ui.screens

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.components.StatsCard
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.SuccessGreen
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White
import kotlinx.coroutines.delay

@Composable
fun BurritoScreen(
    burritoCount: Int,
    onConsiderBurrito: () -> Unit
) {
    var showSuccess by remember { mutableStateOf(false) }

    LaunchedEffect(showSuccess) {
        if (showSuccess) {
            delay(2000)
            showSuccess = false
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(BackgroundGray)
            .padding(horizontal = 16.dp)
            .verticalScroll(rememberScrollState()),
        contentAlignment = Alignment.TopCenter
    ) {
        Column(
            modifier = Modifier
                .widthIn(max = 600.dp)
                .padding(vertical = 16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Main content card
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .shadow(
                        elevation = 4.dp,
                        shape = RoundedCornerShape(8.dp),
                        ambientColor = TextDark.copy(alpha = 0.1f),
                        spotColor = TextDark.copy(alpha = 0.1f)
                    )
                    .background(
                        color = White,
                        shape = RoundedCornerShape(8.dp)
                    )
                    .padding(24.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "Burrito consideration zone",
                    fontSize = 24.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = TextDark,
                    textAlign = TextAlign.Center
                )

                Spacer(modifier = Modifier.height(16.dp))

                Text(
                    text = "Take a moment to truly consider the burrito.",
                    fontSize = 16.sp,
                    color = TextGray,
                    textAlign = TextAlign.Center,
                    lineHeight = 26.sp
                )

                Spacer(modifier = Modifier.height(24.dp))

                Button(
                    onClick = {
                        onConsiderBurrito()
                        showSuccess = true
                    },
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(56.dp),
                    colors = ButtonDefaults.buttonColors(
                        containerColor = SuccessGreen
                    ),
                    shape = RoundedCornerShape(4.dp)
                ) {
                    Text(
                        text = "Consider the Burrito",
                        fontSize = 18.sp,
                        color = White
                    )
                }

                if (showSuccess) {
                    Spacer(modifier = Modifier.height(16.dp))

                    Text(
                        text = "You have considered the burrito. Well done!",
                        color = SuccessGreen,
                        fontSize = 16.sp,
                        textAlign = TextAlign.Center
                    )
                }

                Spacer(modifier = Modifier.height(24.dp))

                Text(
                    text = "Consideration stats",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = TextDark,
                    modifier = Modifier.fillMaxWidth()
                )

                Spacer(modifier = Modifier.height(16.dp))

                StatsCard(
                    title = "Total Burrito Considerations",
                    value = burritoCount.toString()
                )
            }
        }
    }
}

app/src/main/java/com/example/posthog/ui/screens/HomeScreen.kt

package com.example.posthog.ui.screens

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.BorderGray
import com.example.posthog.ui.theme.PrimaryBlue
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White

@Composable
fun HomeScreen(
    isAuthenticated: Boolean,
    username: String?,
    onLogin: (String) -> Unit
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(BackgroundGray)
            .padding(horizontal = 16.dp)
            .verticalScroll(rememberScrollState()),
        contentAlignment = if (isAuthenticated) Alignment.TopCenter else Alignment.Center
    ) {
        Column(
            modifier = Modifier
                .widthIn(max = 600.dp)
                .padding(vertical = 16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            if (isAuthenticated && username != null) {
                LoggedInContent(username = username)
            } else {
                LoginForm(onLogin = onLogin)
            }
        }
    }
}

@Composable
private fun LoggedInContent(username: String) {
    ContentCard {
        Text(
            text = "Welcome back, $username!",
            fontSize = 24.sp,
            fontWeight = FontWeight.SemiBold,
            color = TextDark
        )

        Spacer(modifier = Modifier.height(16.dp))

        Text(
            text = "Ready to consider some burritos?",
            fontSize = 16.sp,
            color = TextGray,
            lineHeight = 26.sp
        )
    }
}

@Composable
private fun LoginForm(onLogin: (String) -> Unit) {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    ContentCard {
        Text(
            text = "Welcome to Burrito Consideration App",
            fontSize = 24.sp,
            fontWeight = FontWeight.SemiBold,
            color = TextDark,
            textAlign = TextAlign.Center
        )

        Spacer(modifier = Modifier.height(24.dp))

        // Username field
        Column(modifier = Modifier.fillMaxWidth()) {
            Text(
                text = "Username",
                fontSize = 16.sp,
                fontWeight = FontWeight.Medium,
                color = TextDark,
                modifier = Modifier.padding(bottom = 8.dp)
            )
            OutlinedTextField(
                value = username,
                onValueChange = { username = it },
                modifier = Modifier.fillMaxWidth(),
                singleLine = true,
                shape = RoundedCornerShape(4.dp),
                colors = OutlinedTextFieldDefaults.colors(
                    unfocusedBorderColor = BorderGray,
                    focusedBorderColor = PrimaryBlue,
                    unfocusedContainerColor = White,
                    focusedContainerColor = White
                )
            )
        }

        Spacer(modifier = Modifier.height(16.dp))

        // Password field
        Column(modifier = Modifier.fillMaxWidth()) {
            Text(
                text = "Password",
                fontSize = 16.sp,
                fontWeight = FontWeight.Medium,
                color = TextDark,
                modifier = Modifier.padding(bottom = 8.dp)
            )
            OutlinedTextField(
                value = password,
                onValueChange = { password = it },
                modifier = Modifier.fillMaxWidth(),
                singleLine = true,
                visualTransformation = PasswordVisualTransformation(),
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                shape = RoundedCornerShape(4.dp),
                colors = OutlinedTextFieldDefaults.colors(
                    unfocusedBorderColor = BorderGray,
                    focusedBorderColor = PrimaryBlue,
                    unfocusedContainerColor = White,
                    focusedContainerColor = White
                )
            )
        }

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = {
                if (username.isNotBlank()) {
                    onLogin(username)
                }
            },
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = PrimaryBlue
            ),
            shape = RoundedCornerShape(4.dp)
        ) {
            Text(
                text = "Sign In",
                fontSize = 16.sp,
                color = White
            )
        }

        Spacer(modifier = Modifier.height(24.dp))

        Text(
            text = "Note: This is a demo app. Enter any username to sign in.",
            fontSize = 14.sp,
            color = TextGray,
            textAlign = TextAlign.Center,
            lineHeight = 21.sp
        )
    }
}

@Composable
private fun ContentCard(
    content: @Composable () -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .shadow(
                elevation = 4.dp,
                shape = RoundedCornerShape(8.dp),
                ambientColor = TextDark.copy(alpha = 0.1f),
                spotColor = TextDark.copy(alpha = 0.1f)
            )
            .background(
                color = White,
                shape = RoundedCornerShape(8.dp)
            )
            .padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        content()
    }
}

app/src/main/java/com/example/posthog/ui/screens/ProfileScreen.kt

package com.example.posthog.ui.screens

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.posthog.ui.components.StatsCard
import com.example.posthog.ui.theme.BackgroundGray
import com.example.posthog.ui.theme.TextDark
import com.example.posthog.ui.theme.TextGray
import com.example.posthog.ui.theme.White

@Composable
fun ProfileScreen(
    username: String,
    burritoCount: Int
) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(BackgroundGray)
            .padding(horizontal = 16.dp)
            .verticalScroll(rememberScrollState()),
        contentAlignment = Alignment.TopCenter
    ) {
        Column(
            modifier = Modifier
                .widthIn(max = 600.dp)
                .padding(vertical = 16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Main content card
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .shadow(
                        elevation = 4.dp,
                        shape = RoundedCornerShape(8.dp),
                        ambientColor = TextDark.copy(alpha = 0.1f),
                        spotColor = TextDark.copy(alpha = 0.1f)
                    )
                    .background(
                        color = White,
                        shape = RoundedCornerShape(8.dp)
                    )
                    .padding(24.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "User Profile",
                    fontSize = 24.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = TextDark
                )

                Spacer(modifier = Modifier.height(24.dp))

                // Your Information section
                Text(
                    text = "Your Information",
                    fontSize = 24.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = TextDark,
                    modifier = Modifier.fillMaxWidth()
                )

                Spacer(modifier = Modifier.height(16.dp))

                // Username display
                Column(modifier = Modifier.fillMaxWidth()) {
                    Text(
                        text = "Username",
                        fontSize = 14.sp,
                        color = TextGray
                    )
                    Text(
                        text = username,
                        fontSize = 20.sp,
                        fontWeight = FontWeight.SemiBold,
                        color = TextDark
                    )
                }

                Spacer(modifier = Modifier.height(24.dp))

                // Stats card
                StatsCard(
                    title = "Total Burrito Considerations",
                    value = burritoCount.toString()
                )

                Spacer(modifier = Modifier.height(24.dp))

                // Your Burrito Journey section
                Text(
                    text = "Your Burrito Journey",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = TextDark,
                    modifier = Modifier.fillMaxWidth()
                )

                Spacer(modifier = Modifier.height(8.dp))

                Text(
                    text = getJourneyMessage(burritoCount),
                    fontSize = 16.sp,
                    color = TextGray,
                    textAlign = TextAlign.Start,
                    lineHeight = 26.sp,
                    modifier = Modifier.fillMaxWidth()
                )
            }
        }
    }
}

private fun getJourneyMessage(count: Int): String = when {
    count == 0 -> "You haven't considered any burritos yet. Start your journey!"
    count == 1 -> "You've considered the burrito potential once. The journey begins!"
    count in 2..4 -> "You're getting the hang of burrito consideration!"
    count in 5..9 -> "You're becoming a burrito consideration expert!"
    else -> "You are a true burrito consideration master!"
}

app/src/main/java/com/example/posthog/ui/theme/Color.kt

package com.example.posthog.ui.theme

import androidx.compose.ui.graphics.Color

val PrimaryBlue = Color(0xFF0070F3)
val PrimaryBlueHover = Color(0xFF0051CC)
val SuccessGreen = Color(0xFF28A745)
val SuccessGreenHover = Color(0xFF218838)
val ErrorRed = Color(0xFFDC3545)
val ErrorRedHover = Color(0xFFC82333)
val DarkHeader = Color(0xFF333333)
val DarkHeaderHover = Color(0xFF555555)
val LightGray = Color(0xFFF8F9FA)
val BorderGray = Color(0xFFDDDDDD)
val TextGray = Color(0xFF666666)
val BackgroundGray = Color(0xFFF5F5F5)
val TextDark = Color(0xFF333333)
val White = Color(0xFFFFFFFF)

app/src/main/java/com/example/posthog/ui/theme/Theme.kt

package com.example.posthog.ui.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable

private val LightColorScheme = lightColorScheme(
    primary = PrimaryBlue,
    secondary = SuccessGreen,
    tertiary = DarkHeader,
    background = BackgroundGray,
    surface = White,
    onPrimary = White,
    onSecondary = White,
    onTertiary = White,
    onBackground = TextDark,
    onSurface = TextDark
)

@Composable
fun PostHogTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colorScheme = LightColorScheme,
        typography = Typography,
        content = content
    )
}

app/src/main/java/com/example/posthog/ui/theme/Type.kt

package com.example.posthog.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Typography based on design specification
// Uses system font stack (FontFamily.Default maps to Roboto on Android)
val Typography = Typography(
    // H1 - Page titles (32sp)
    displayLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp
    ),
    // H2 - Section titles (24sp)
    displayMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    // H3 - Subsection titles (20sp)
    displaySmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 20.sp,
        lineHeight = 26.sp,
        letterSpacing = 0.sp
    ),
    // Body text (16sp with 1.6 line height = 25.6sp)
    bodyLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 26.sp,
        letterSpacing = 0.sp
    ),
    // Small/Note text (14sp)
    bodySmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 21.sp,
        letterSpacing = 0.sp
    ),
    // Labels (16sp, medium weight)
    labelLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Medium,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.sp
    ),
    // Button text - Burrito button (18sp)
    titleLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 18.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.sp
    ),
    // Button text - Primary/Logout (16sp/14sp)
    titleMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.sp
    ),
    titleSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.sp
    )
)

app/src/main/java/com/example/posthog/viewmodel/AuthViewModel.kt

package com.example.posthog.viewmodel

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.posthog.data.User
import com.example.posthog.data.UserRepository
import com.posthog.PostHog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class AuthViewModel(application: Application) : AndroidViewModel(application) {

    private val repository = UserRepository(application)

    private val _currentUser = MutableStateFlow<User?>(null)
    val currentUser: StateFlow<User?> = _currentUser.asStateFlow()

    private val _isAuthenticated = MutableStateFlow(false)
    val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()

    init {
        loadCurrentUser()
    }

    private fun loadCurrentUser() {
        viewModelScope.launch {
            val user = repository.getCurrentUser()
            _currentUser.value = user
            _isAuthenticated.value = user != null
        }
    }

    fun login(username: String) {
        viewModelScope.launch {
            val existingUser = repository.getUser(username)
            val user = existingUser ?: User(username = username, burritoConsiderations = 0)
            repository.saveUser(user)
            _currentUser.value = user
            _isAuthenticated.value = true

            PostHog.identify(username)
            PostHog.capture(event = "user_logged_in")
        }
    }

    fun logout() {
        viewModelScope.launch {
            PostHog.capture("user_logged_out")
            PostHog.reset()
            repository.clearCurrentUser()
            _currentUser.value = null
            _isAuthenticated.value = false
        }
    }

    fun incrementBurritoCount() {
        viewModelScope.launch {
            val user = _currentUser.value ?: return@launch
            val updatedUser = user.copy(burritoConsiderations = user.burritoConsiderations + 1)
            repository.saveUser(updatedUser)
            _currentUser.value = updatedUser

            PostHog.capture(
                event = "burrito_considered",
                properties = mapOf(
                    "total_considerations" to updatedUser.burritoConsiderations,
                    "username" to updatedUser.username
                )
            )
        }
    }
}

references/EXAMPLE-angular.md

PostHog angular Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/angular


README.md

PostHog Angular Example

This is an Angular example demonstrating PostHog integration with product analytics, session replay, and error tracking.

Features

  • Product analytics: Track user events and behaviors
  • Session replay: Record and replay user sessions
  • Error tracking: Capture and track errors
  • User authentication: Demo login system with PostHog user identification
  • SSR-safe: Uses platform checks for browser-only PostHog calls
  • Reverse proxy: PostHog ingestion through Angular proxy

Getting started

1. Install dependencies
pnpm install
2. Configure environment variables

Create a .env file in the root directory:

VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the development server
pnpm start

Open http://localhost:3000 with your browser to see the app.

Project structure

src/
├── app/
│   ├── components/
│   │   └── header/            # Navigation header with auth state
│   ├── pages/
│   │   ├── home/              # Home/Login page
│   │   ├── burrito/           # Demo feature page with event tracking
│   │   └── profile/           # User profile with error tracking demo
│   ├── services/
│   │   ├── posthog.service.ts # PostHog service wrapper (SSR-safe)
│   │   └── auth.service.ts    # Auth service with PostHog integration
│   ├── guards/
│   │   └── auth.guard.ts      # Route guard for protected pages
│   ├── app.component.ts       # Root component with PostHog init
│   ├── app.routes.ts          # Route definitions
│   └── app.config.ts          # App configuration
├── environments/
│   ├── environment.ts         # Dev environment config
│   └── environment.production.ts
└── main.ts                    # App entry point

Key integration points

PostHog service (services/posthog.service.ts)

A wrapper service that handles SSR safety and provides access to the PostHog instance:

import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import posthog from 'posthog-js';

@Injectable({ providedIn: 'root' })
export class PostHogService {
  private readonly platformId = inject(PLATFORM_ID);

  get posthog(): typeof posthog {
    if (isPlatformBrowser(this.platformId)) {
      return posthog;
    }
    // Return a no-op proxy for SSR safety
    return new Proxy({} as typeof posthog, {
      get: () => () => undefined,
    });
  }

  init(apiKey: string, options: Partial<PostHogConfig>): void {
    if (isPlatformBrowser(this.platformId)) {
      posthog.init(apiKey, options);
    }
  }
}
PostHog initialization (app.component.ts)

PostHog is initialized in the root component's ngOnInit:

import { PostHogService } from './services/posthog.service';
import { environment } from '../environments/environment';

export class AppComponent implements OnInit {
  private readonly posthogService = inject(PostHogService);

  ngOnInit(): void {
    this.posthogService.init(environment.posthogKey, {
      api_host: '/ingest',
      ui_host: environment.posthogHost || 'https://us.posthog.com',
      capture_exceptions: true,
    });
  }
}
User identification (services/auth.service.ts)
import { PostHogService } from './posthog.service';

const posthogService = inject(PostHogService);

posthogService.posthog.identify(username, {
  username,
  isNewUser,
});
Event tracking (pages/burrito/burrito.component.ts)
import { PostHogService } from '../../services/posthog.service';

const posthogService = inject(PostHogService);

posthogService.posthog.capture('burrito_considered', {
  total_considerations: count,
  username: username,
});
Error tracking (pages/profile/profile.component.ts)
posthogService.posthog.captureException(error);

Angular-specific details

This example uses Angular 21 with modern features:

  1. Standalone components: No NgModules, all components use standalone: true
  2. Signals: Reactive state management with Angular signals
  3. SSR support: Uses isPlatformBrowser() checks for SSR safety
  4. Dependency injection: PostHog wrapped in an injectable service
  5. Proxy configuration: Uses proxy.conf.json for PostHog API calls
  6. Environment files: Generated from .env at build time via prebuild script

Environment variable handling

Angular CLI doesn't natively support .env files. This project uses a prebuild script:

  1. scripts/generate-env.js reads .env and generates environment.generated.ts
  2. The script runs automatically before pnpm start and pnpm build
  3. Environment files import from the generated file

Learn more


.env.example

NG_APP_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NG_APP_POSTHOG_HOST=https://us.posthog.com

src/app/app.component.ts

import {
  Component,
  inject,
  OnInit,
  PLATFORM_ID,
  ChangeDetectionStrategy,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { PostHogService } from './services/posthog.service';
import { environment } from '../environments/environment';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, HeaderComponent],
  template: `
    <app-header />
    <router-outlet />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  private readonly platformId = inject(PLATFORM_ID);
  private readonly posthogService = inject(PostHogService);

  ngOnInit(): void {
    if (isPlatformBrowser(this.platformId)) {
      this.posthogService.init(environment.posthogKey, {
        api_host: '/ingest',
        ui_host: environment.posthogHost || 'https://us.posthog.com',
        capture_exceptions: true,
      });
    }
  }
}

src/app/app.config.server.ts

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

const serverConfig: ApplicationConfig = {
  providers: [provideServerRendering(withRoutes(serverRoutes))],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

src/app/app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import {
  provideClientHydration,
  withEventReplay,
} from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withFetch()),
    provideClientHydration(withEventReplay()),
  ],
};

src/app/app.routes.server.ts

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: RenderMode.Server,
  },
  {
    path: 'burrito',
    renderMode: RenderMode.Client, // Protected route, render client-side
  },
  {
    path: 'profile',
    renderMode: RenderMode.Client, // Protected route, render client-side
  },
  {
    path: '**',
    renderMode: RenderMode.Server,
  },
];

src/app/app.routes.ts

import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';

export const routes: Routes = [
  {
    path: '',
    title: 'Burrito Consideration App',
    loadComponent: () =>
      import('./pages/home/home.component').then((m) => m.HomeComponent),
  },
  {
    path: 'burrito',
    title: 'Burrito Consideration - Burrito Consideration App',
    loadComponent: () =>
      import('./pages/burrito/burrito.component').then(
        (m) => m.BurritoComponent
      ),
    canActivate: [authGuard],
  },
  {
    path: 'profile',
    title: 'Profile - Burrito Consideration App',
    loadComponent: () =>
      import('./pages/profile/profile.component').then(
        (m) => m.ProfileComponent
      ),
    canActivate: [authGuard],
  },
  {
    path: '**',
    redirectTo: '',
  },
];

src/app/components/header/header.component.ts

import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-header',
  imports: [RouterLink],
  template: `
    <a class="skip-link" href="#main-content">Skip to main content</a>
    <header class="header" role="banner">
      <div class="header-container">
        <nav aria-label="Main navigation">
          <a routerLink="/">Home</a>
          @if (auth.isAuthenticated()) {
            <a routerLink="/burrito">Burrito Consideration</a>
            <a routerLink="/profile">Profile</a>
          }
        </nav>
        <div class="user-section">
          @if (auth.user(); as user) {
            <span>Welcome, {{ user.username }}!</span>
            <button (click)="auth.logout()" class="btn-logout">Logout</button>
          } @else {
            <span>Not logged in</span>
          }
        </div>
      </div>
    </header>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderComponent {
  readonly auth = inject(AuthService);
}

src/app/guards/auth.guard.ts

import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) {
    return true;
  }

  return router.createUrlTree(['/']);
};

src/app/pages/burrito/burrito.component.ts

import {
  Component,
  inject,
  signal,
  ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { PostHogService } from '../../services/posthog.service';

@Component({
  selector: 'app-burrito',
  template: `
    <main id="main-content" tabindex="-1">
      <div class="container">
        <h1>Burrito consideration zone</h1>
        <p>Take a moment to truly consider the potential of burritos.</p>

        <div style="text-align: center">
          <button (click)="handleConsideration()" class="btn-burrito">
            I have considered the burrito potential
          </button>

          @if (hasConsidered()) {
            <p class="success" role="status" aria-live="polite">
              Thank you for your consideration! Count:
              {{ auth.user()?.burritoConsiderations }}
            </p>
          }
        </div>

        <div class="stats">
          <h3>Consideration stats</h3>
          <p>Total considerations: {{ auth.user()?.burritoConsiderations }}</p>
        </div>
      </div>
    </main>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BurritoComponent {
  readonly auth = inject(AuthService);
  private readonly posthogService = inject(PostHogService);
  private readonly router = inject(Router);

  hasConsidered = signal(false);

  constructor() {
    // Redirect if not authenticated
    if (!this.auth.isAuthenticated()) {
      this.router.navigate(['/']);
    }
  }

  handleConsideration(): void {
    const user = this.auth.user();
    if (!user) return;

    this.auth.incrementBurritoConsiderations();
    this.hasConsidered.set(true);
    setTimeout(() => this.hasConsidered.set(false), 2000);

    this.posthogService.posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    });
  }
}

src/app/pages/home/home.component.ts

import {
  Component,
  inject,
  signal,
  ChangeDetectionStrategy,
} from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-home',
  imports: [ReactiveFormsModule],
  template: `
    <main id="main-content" tabindex="-1">
      @if (auth.user(); as user) {
        <div class="container">
          <h1>Welcome back, {{ user.username }}!</h1>
          <p>You are logged in. Feel free to explore:</p>
          <ul>
            <li>Consider the potential of burritos</li>
            <li>View your profile and statistics</li>
          </ul>
        </div>
      } @else {
        <div class="container">
          <h1>Welcome to Burrito Consideration App</h1>
          <p>Please sign in to begin your burrito journey</p>

          <form [formGroup]="loginForm" (ngSubmit)="handleSubmit()" class="form">
            <div class="form-group">
              <label for="username">Username:</label>
              <input
                type="text"
                id="username"
                formControlName="username"
                placeholder="Enter any username"
                autocomplete="username"
              />
            </div>

            <div class="form-group">
              <label for="password">Password:</label>
              <input
                type="password"
                id="password"
                formControlName="password"
                placeholder="Enter any password"
                autocomplete="current-password"
              />
            </div>

            @if (error()) {
              <p class="error" role="alert">{{ error() }}</p>
            }

            <button type="submit" class="btn-primary">Sign In</button>
          </form>

          <p class="note">
            Note: This is a demo app. Use any username and password to sign in.
          </p>
        </div>
      }
    </main>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent {
  private readonly fb = inject(FormBuilder);
  readonly auth = inject(AuthService);

  loginForm = this.fb.nonNullable.group({
    username: ['', Validators.required],
    password: ['', Validators.required],
  });

  error = signal('');

  handleSubmit(): void {
    this.error.set('');

    if (this.loginForm.invalid) {
      this.error.set('Please provide both username and password');
      return;
    }

    const { username, password } = this.loginForm.getRawValue();

    const success = this.auth.login(username, password);
    if (success) {
      this.loginForm.reset();
    } else {
      this.error.set('Please provide both username and password');
    }
  }
}

src/app/pages/profile/profile.component.ts

import {
  Component,
  inject,
  computed,
  ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { PostHogService } from '../../services/posthog.service';

@Component({
  selector: 'app-profile',
  template: `
    <main id="main-content" tabindex="-1">
      <div class="container">
        <h1>User Profile</h1>

        <div class="stats">
          <h2>Your Information</h2>
          <p><strong>Username:</strong> {{ auth.user()?.username }}</p>
          <p>
            <strong>Burrito Considerations:</strong>
            {{ auth.user()?.burritoConsiderations }}
          </p>
        </div>

        <div style="margin-top: 2rem">
          <button
            (click)="triggerTestError()"
            class="btn-primary"
            style="background-color: #dc3545"
          >
            Trigger Test Error (for PostHog)
          </button>
        </div>

        <div style="margin-top: 2rem">
          <h3>Your Burrito Journey</h3>
          <p>{{ journeyMessage() }}</p>
        </div>
      </div>
    </main>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileComponent {
  readonly auth = inject(AuthService);
  private readonly posthogService = inject(PostHogService);
  private readonly router = inject(Router);

  journeyMessage = computed(() => {
    const count = this.auth.user()?.burritoConsiderations ?? 0;

    if (count === 0) {
      return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
    } else if (count === 1) {
      return "You've considered the burrito potential once. Keep going!";
    } else if (count < 5) {
      return "You're getting the hang of burrito consideration!";
    } else if (count < 10) {
      return "You're becoming a burrito consideration expert!";
    } else {
      return 'You are a true burrito consideration master!';
    }
  });

  constructor() {
    if (!this.auth.isAuthenticated()) {
      this.router.navigate(['/']);
    }
  }

  triggerTestError(): void {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      const error = err as Error;
      this.posthogService.posthog.captureException(error);
      console.error('Captured error:', err);
      alert('Error captured and sent to PostHog!');
    }
  }
}

src/app/services/auth.service.ts

import {
  Injectable,
  signal,
  computed,
  inject,
  PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { PostHogService } from './posthog.service';

export interface User {
  username: string;
  burritoConsiderations: number;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly platformId = inject(PLATFORM_ID);
  private readonly posthogService = inject(PostHogService);

  // In-memory user store (matches TanStack behavior)
  private readonly users = new Map<string, User>();

  // Signals for reactive state
  private readonly _user = signal<User | null>(null);

  // Public computed signals
  readonly user = this._user.asReadonly();
  readonly isAuthenticated = computed(() => this._user() !== null);

  constructor() {
    // Initialize from localStorage on browser
    if (isPlatformBrowser(this.platformId)) {
      const storedUsername = localStorage.getItem('currentUser');
      if (storedUsername) {
        const existingUser = this.users.get(storedUsername);
        if (existingUser) {
          this._user.set(existingUser);
        }
      }
    }
  }

  login(username: string, password: string): boolean {
    if (!username || !password) {
      return false;
    }

    // Get or create user in local map (no API call)
    let user = this.users.get(username);
    const isNewUser = !user;

    if (!user) {
      user = { username, burritoConsiderations: 0 };
      this.users.set(username, user);
    }

    this._user.set(user);

    if (isPlatformBrowser(this.platformId)) {
      localStorage.setItem('currentUser', username);
    }

    // PostHog identification (client-side only)
    this.posthogService.posthog.identify(username, {
      username,
      isNewUser,
    });

    this.posthogService.posthog.capture('user_logged_in', {
      username,
      isNewUser,
    });

    return true;
  }

  logout(): void {
    this.posthogService.posthog.capture('user_logged_out');
    this.posthogService.posthog.reset();

    this._user.set(null);

    if (isPlatformBrowser(this.platformId)) {
      localStorage.removeItem('currentUser');
    }
  }

  incrementBurritoConsiderations(): void {
    const currentUser = this._user();
    if (currentUser) {
      const updated = {
        ...currentUser,
        burritoConsiderations: currentUser.burritoConsiderations + 1,
      };
      this.users.set(currentUser.username, updated);
      this._user.set(updated);
    }
  }
}

src/app/services/posthog.service.ts

import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import posthog, { PostHogConfig } from 'posthog-js';

@Injectable({ providedIn: 'root' })
export class PostHogService {
  private readonly platformId = inject(PLATFORM_ID);
  private initialized = false;

  /**
   * The posthog instance. Use this directly to call posthog methods.
   * Returns the actual posthog instance on browser, or a no-op proxy on server.
   */
  get posthog(): typeof posthog {
    if (isPlatformBrowser(this.platformId) && this.initialized) {
      return posthog;
    }
    // Return a no-op proxy for SSR safety
    return new Proxy({} as typeof posthog, {
      get: () => () => undefined,
    });
  }

  init(apiKey: string, options: Partial<PostHogConfig>): void {
    if (isPlatformBrowser(this.platformId) && !this.initialized) {
      posthog.init(apiKey, options);
      this.initialized = true;
    }
  }
}

src/env.d.ts

// Define the type of the environment variables.
declare interface Env {
  readonly NODE_ENV: string;
  readonly NG_APP_POSTHOG_PROJECT_TOKEN: string;
  readonly NG_APP_POSTHOG_HOST: string;
}

// Use import.meta.env.YOUR_ENV_VAR in your code.
declare interface ImportMeta {
  readonly env: Env;
}

src/environments/environment.prod.ts

export const environment = {
  production: true,
  posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
  posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};

src/environments/environment.production.ts

export const environment = {
  production: true,
  posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
  posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};

src/environments/environment.ts

export const environment = {
  production: false,
  posthogKey: import.meta.env['NG_APP_POSTHOG_PROJECT_TOKEN'] || '<ph_project_token>',
  posthogHost: import.meta.env['NG_APP_POSTHOG_HOST'] || 'https://us.posthog.com',
};

src/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Burrito Consideration App</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="Consider the potential of burritos">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

src/main.server.ts

import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = (context: BootstrapContext) =>
  bootstrapApplication(AppComponent, config, context);

export default bootstrap;

src/main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);

references/EXAMPLE-astro-hybrid.md

PostHog astro-hybrid Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-hybrid


README.md

PostHog Astro Hybrid Example

This is an Astro hybrid rendering example demonstrating PostHog integration with both static and on-demand rendered pages.

Hybrid mode allows you to have most pages prerendered (static) while opting specific pages into server-side rendering (SSR) when needed.

It uses:

  • Client-side: PostHog web snippet for browser analytics
  • Server-side: posthog-node for API route event tracking

This shows how to:

  • Configure Astro for hybrid rendering (static default with per-page SSR opt-in)
  • Opt specific pages into SSR with export const prerender = false
  • Keep most pages static for performance
  • Track events from API routes using posthog-node
  • Link client and server sessions automatically with the tracing_headers option

Features

  • Hybrid rendering: Static pages by default, SSR when needed
  • API routes: Server-side endpoints for auth and event tracking
  • Dual tracking: Events captured on both client and server
  • Session continuity: Session and distinct ID forwarded automatically via tracing_headers
  • Product analytics: Track login and burrito consideration events
  • Error tracking: Manual error capture sent to PostHog

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env file in the project root:

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your project settings in PostHog.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:4321 in your browser.

Project structure

src/
  components/
    posthog.astro      # PostHog snippet for client-side tracking
    Header.astro       # Navigation + logout, calls posthog.reset()
  layouts/
    PostHogLayout.astro # Root layout that includes PostHog + Header
  lib/
    auth.ts            # Client-side auth utilities
    posthog-server.ts  # Server-side PostHog client singleton
  pages/
    index.astro        # Static (prerendered) - login form
    burrito.astro      # SSR (prerender=false) - calls API routes
    profile.astro      # Static (prerendered) - user profile
    api/
      auth/
        login.ts       # Server-side login endpoint with PostHog tracking
      events/
        burrito.ts     # Server-side event capture endpoint
  styles/
    global.css         # Global styles

Key integration points

Hybrid mode configuration (astro.config.mjs)

In Astro 5, output: 'static' is the default and supports per-page SSR opt-in. You need an adapter for the SSR pages to work:

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  // 'static' is the default - pages are prerendered unless they opt out
  output: "static",
  adapter: node({ mode: "standalone" }),
});
Opting a page into SSR (src/pages/burrito.astro)
---
// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---
Server-side PostHog client (src/lib/posthog-server.ts)

A singleton pattern ensures only one PostHog client is created:

import { PostHog } from "posthog-node";

let posthogClient: PostHog | null = null;

export function getPostHogServer(): PostHog {
  if (!posthogClient) {
    posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, {
      host: import.meta.env.PUBLIC_POSTHOG_HOST,
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return posthogClient;
}
API route with server-side tracking (src/pages/api/events/burrito.ts)
import { getPostHogServer } from "../../../lib/posthog-server";

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json();
  const sessionId = request.headers.get("X-PostHog-Session-Id");

  const posthog = getPostHogServer();
  posthog.capture({
    distinctId: body.username,
    event: "burrito_considered",
    properties: {
      $session_id: sessionId || undefined,
      source: "api",
    },
  });

  return new Response(JSON.stringify({ success: true }));
};

When to use Hybrid mode

Use hybrid mode when you want:

  • Performance: Most pages prerendered as static HTML
  • Flexibility: Some pages need server-side logic (auth, personalization)
  • API routes: Server-side endpoints for data processing

Scripts

# Run dev server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

Learn more


.env.example

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

astro.config.mjs

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  // In Astro 5, 'static' is the default and supports per-page SSR opt-in
  // Use `export const prerender = false` in pages that need server rendering
  output: "static",
  adapter: node({
    mode: "standalone",
  }),
  image: {
    service: { entrypoint: "astro/assets/services/noop" },
  },
});

src/components/Header.astro

---
// Header component with navigation and logout functionality
---
<header class="header">
  <div class="header-container">
    <nav>
      <a href="/">Home</a>
      <a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
      <a href="/profile" class="auth-link" style="display: none;">Profile</a>
    </nav>
    <div class="user-section">
      <span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
      <span class="not-logged-in">Not logged in</span>
      <button class="btn-logout" style="display: none;">Logout</button>
    </div>
  </div>
</header>

<script is:inline>
  function updateHeader() {
    const currentUser = localStorage.getItem('currentUser');
    const authLinks = document.querySelectorAll('.auth-link');
    const welcomeText = document.querySelector('.welcome-text');
    const notLoggedIn = document.querySelector('.not-logged-in');
    const logoutBtn = document.querySelector('.btn-logout');
    const usernameSpan = document.querySelector('.username');

    if (currentUser) {
      authLinks.forEach(link => link.style.display = 'inline');
      welcomeText.style.display = 'inline';
      notLoggedIn.style.display = 'none';
      logoutBtn.style.display = 'inline';
      usernameSpan.textContent = currentUser;
    } else {
      authLinks.forEach(link => link.style.display = 'none');
      welcomeText.style.display = 'none';
      notLoggedIn.style.display = 'inline';
      logoutBtn.style.display = 'none';
    }
  }

  function handleLogout() {
    const currentUser = localStorage.getItem('currentUser');
    if (currentUser) {
      window.posthog?.capture('user_logged_out');
    }
    localStorage.removeItem('currentUser');
    localStorage.removeItem('burritoConsiderations');
    // IMPORTANT: Reset the PostHog instance to clear the user session
    window.posthog?.reset();
    window.location.href = '/';
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateHeader();
    document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
  });

  // Listen for storage changes (login/logout in other tabs)
  window.addEventListener('storage', updateHeader);
</script>

<style>
  .header {
    background-color: #333;
    color: white;
    padding: 1rem;
  }

  .header-container {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .header nav {
    display: flex;
    gap: 1rem;
  }

  .header a {
    color: white;
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    transition: background-color 0.2s;
  }

  .header a:hover {
    background-color: #555;
    text-decoration: none;
  }

  .user-section {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .btn-logout {
    background-color: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
  }

  .btn-logout:hover {
    background-color: #c82333;
  }
</style>

src/components/posthog.astro

---
// PostHog analytics snippet for client-side tracking
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
  // POSTHOG_BROWSER_SNIPPET_START
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
  // POSTHOG_BROWSER_SNIPPET_END
  posthog.init(apiKey || '', {
    api_host: apiHost || 'https://us.i.posthog.com',
    defaults: '2026-01-30',
    // Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
    // to same-origin requests so server-side events join the same session.
    tracing_headers: [window.location.hostname]
  })
</script>

src/layouts/PostHogLayout.astro

---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';

interface Props {
  title: string;
}

const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Astro PostHog SSR Integration Example" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
    <PostHog />
  </head>
  <body>
    <Header />
    <main>
      <slot />
    </main>
  </body>
</html>

src/lib/auth.ts

// Client-side auth utilities for localStorage-based authentication

export interface User {
  username: string;
  burritoConsiderations: number;
}

export function getCurrentUser(): User | null {
  if (typeof window === "undefined") return null;

  const username = localStorage.getItem("currentUser");
  if (!username) return null;

  const considerations = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );

  return {
    username,
    burritoConsiderations: considerations,
  };
}

export function login(username: string, password: string): boolean {
  if (!username || !password) return false;

  localStorage.setItem("currentUser", username);
  // Initialize burrito considerations if not set
  if (!localStorage.getItem("burritoConsiderations")) {
    localStorage.setItem("burritoConsiderations", "0");
  }

  return true;
}

export function logout(): void {
  localStorage.removeItem("currentUser");
  localStorage.removeItem("burritoConsiderations");
}

export function incrementBurritoConsiderations(): number {
  const current = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );
  const newCount = current + 1;
  localStorage.setItem("burritoConsiderations", newCount.toString());
  return newCount;
}

src/lib/posthog-server.ts

import { PostHog } from "posthog-node";

let posthogClient: PostHog | null = null;

/**
 * Get the PostHog server-side client.
 * Uses a singleton pattern to avoid creating multiple clients.
 */
export function getPostHogServer(): PostHog {
  if (!posthogClient) {
    posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN || "", {
      host: import.meta.env.PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
      // Flush immediately for demo purposes
      // In production, you might want to batch events
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return posthogClient;
}

/**
 * Shutdown the PostHog client gracefully.
 * Call this when your server is shutting down.
 */
export async function shutdownPostHog(): Promise<void> {
  if (posthogClient) {
    await posthogClient.shutdown();
    posthogClient = null;
  }
}

src/pages/api/auth/login.ts

import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";

export const prerender = false;

// In-memory user store for demo purposes
const users = new Map<string, { username: string; createdAt: string }>();

export const POST: APIRoute = async ({ request }) => {
  try {
    const body = await request.json();
    const { username, password } = body;

    if (!username || !password) {
      return new Response(
        JSON.stringify({ error: "Username and password are required" }),
        { status: 400, headers: { "Content-Type": "application/json" } },
      );
    }

    // Check if this is a new user
    const isNewUser = !users.has(username);

    if (isNewUser) {
      users.set(username, {
        username,
        createdAt: new Date().toISOString(),
      });
    }

    // Get the PostHog server client
    const posthog = getPostHogServer();

    // Get session ID from client if available (passed via header)
    const sessionId = request.headers.get("X-PostHog-Session-Id");

    // Capture server-side login event
    posthog.capture({
      distinctId: username,
      event: "server_login",
      properties: {
        $session_id: sessionId || undefined,
        isNewUser,
        source: "api",
        timestamp: new Date().toISOString(),
      },
    });

    // Also identify the user server-side
    posthog.identify({
      distinctId: username,
      properties: {
        username,
        createdAt: isNewUser ? new Date().toISOString() : undefined,
      },
    });

    // This endpoint is short-lived; flush so the enqueued events send before it returns
    await posthog.flush();

    return new Response(
      JSON.stringify({
        success: true,
        username,
        isNewUser,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  } catch (error) {
    console.error("Login error:", error);
    return new Response(JSON.stringify({ error: "Internal server error" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
};

src/pages/api/events/burrito.ts

import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  try {
    const body = await request.json();
    const { username, totalConsiderations } = body;

    if (!username) {
      return new Response(JSON.stringify({ error: "Username is required" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    // Get the PostHog server client
    const posthog = getPostHogServer();

    // Get session ID from client if available (passed via header)
    const sessionId = request.headers.get("X-PostHog-Session-Id");

    // Capture server-side burrito consideration event
    posthog.capture({
      distinctId: username,
      event: "burrito_considered",
      properties: {
        $session_id: sessionId || undefined,
        total_considerations: totalConsiderations,
        source: "api",
        timestamp: new Date().toISOString(),
      },
    });

    // This endpoint is short-lived; flush so the enqueued event sends before it returns
    await posthog.flush();

    return new Response(
      JSON.stringify({
        success: true,
        totalConsiderations,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  } catch (error) {
    console.error("Burrito event error:", error);
    return new Response(JSON.stringify({ error: "Internal server error" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
};

src/pages/burrito.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';

// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---
<PostHogLayout title="Burrito Consideration - Astro PostHog Hybrid Example">
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center;">
      <button id="consider-btn" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p id="success-message" class="success" style="display: none;">
        Thank you for your consideration! Count: <span id="consideration-count"></span>
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: <span id="total-considerations">0</span></p>
    </div>

    <p class="note" style="margin-top: 1rem;">
      Events are tracked both client-side and server-side for demonstration.
    </p>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateStats() {
    const count = localStorage.getItem('burritoConsiderations') || '0';
    document.getElementById('total-considerations').textContent = count;
  }

  async function handleConsideration() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) return;

    // Increment the count
    const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
    const newCount = currentCount + 1;
    localStorage.setItem('burritoConsiderations', newCount.toString());

    // Update the UI
    updateStats();

    const successMessage = document.getElementById('success-message');
    const considerationCount = document.getElementById('consideration-count');
    considerationCount.textContent = newCount;
    successMessage.style.display = 'block';

    // Hide success message after 2 seconds
    setTimeout(() => {
      successMessage.style.display = 'none';
    }, 2000);

    // Client-side event tracking
    window.posthog?.capture('burrito_considered', {
      total_considerations: newCount,
      username: currentUser,
      source: 'client'
    });

    // Also send to server-side API for server tracking. The session and distinct
    // ID are added automatically by the tracing_headers option in posthog.init.
    try {
      await fetch('/api/events/burrito', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          username: currentUser,
          totalConsiderations: newCount
        })
      });
    } catch (error) {
      console.error('Failed to send server-side event:', error);
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateStats();
    document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
  });
</script>

src/pages/index.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';

// This page is prerendered (static) by default in hybrid mode
// No need to set prerender = true explicitly
---
<PostHogLayout title="Home - Astro PostHog Hybrid Example">
  <div class="container">
    <div id="logged-in-view" style="display: none;">
      <h1>Welcome back, <span id="welcome-username"></span>!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </div>

    <div id="logged-out-view">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form id="login-form" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            placeholder="Enter any username"
            required
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            placeholder="Enter any password"
            required
          />
        </div>

        <p id="error-message" class="error" style="display: none;"></p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app with server-side tracking. Use any username and password to sign in.
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function updateView() {
    const currentUser = localStorage.getItem('currentUser');
    const loggedInView = document.getElementById('logged-in-view');
    const loggedOutView = document.getElementById('logged-out-view');
    const welcomeUsername = document.getElementById('welcome-username');

    if (currentUser) {
      loggedInView.style.display = 'block';
      loggedOutView.style.display = 'none';
      welcomeUsername.textContent = currentUser;
    } else {
      loggedInView.style.display = 'none';
      loggedOutView.style.display = 'block';
    }
  }

  async function handleLogin(event) {
    event.preventDefault();

    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

    if (!username || !password) {
      errorMessage.textContent = 'Please provide both username and password';
      errorMessage.style.display = 'block';
      return;
    }

    try {
      // Call the server-side login API. The session and distinct ID are added
      // automatically by the tracing_headers option configured in posthog.init.
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ username, password })
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Login failed');
      }

      // Store in localStorage for client-side state
      localStorage.setItem('currentUser', username);
      if (!localStorage.getItem('burritoConsiderations')) {
        localStorage.setItem('burritoConsiderations', '0');
      }

      // Also identify on the client side (for session continuity)
      window.posthog?.identify(username);
      window.posthog?.capture('user_logged_in');

      // Clear form
      document.getElementById('username').value = '';
      document.getElementById('password').value = '';
      errorMessage.style.display = 'none';

      // Update view
      updateView();

      // Trigger header update
      window.dispatchEvent(new Event('storage'));
    } catch (error) {
      errorMessage.textContent = error.message || 'Login failed';
      errorMessage.style.display = 'block';
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateView();
    document.getElementById('login-form')?.addEventListener('submit', handleLogin);
  });

  // Listen for storage changes
  window.addEventListener('storage', updateView);
</script>

src/pages/profile.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';

// This page is prerendered (static) by default in hybrid mode
---
<PostHogLayout title="Profile - Astro PostHog Hybrid Example">
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> <span id="profile-username"></span></p>
      <p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Your Burrito Journey</h3>
      <p id="journey-message"></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Error Tracking Demo</h3>
      <p>Click the button below to trigger a test error and send it to PostHog:</p>
      <button id="error-btn" class="btn-error">
        Trigger Test Error
      </button>
      <p id="error-feedback" class="success" style="display: none;">
        Error captured and sent to PostHog!
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateProfile() {
    const username = localStorage.getItem('currentUser') || '';
    const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);

    document.getElementById('profile-username').textContent = username;
    document.getElementById('profile-considerations').textContent = considerations;

    // Update journey message based on consideration count
    const journeyMessage = document.getElementById('journey-message');
    if (considerations === 0) {
      journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
    } else if (considerations === 1) {
      journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
    } else if (considerations < 5) {
      journeyMessage.textContent = "You're getting the hang of burrito consideration!";
    } else if (considerations < 10) {
      journeyMessage.textContent = "You're becoming a burrito consideration expert!";
    } else {
      journeyMessage.textContent = "You are a true burrito consideration master!";
    }
  }

  function triggerTestError() {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      // Capture the error in PostHog
      window.posthog?.captureException(err);
      console.error('Captured error:', err);

      // Show feedback to user
      const feedback = document.getElementById('error-feedback');
      feedback.style.display = 'block';
      setTimeout(() => {
        feedback.style.display = 'none';
      }, 3000);
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateProfile();
    document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
  });
</script>

references/EXAMPLE-astro-ssr.md

PostHog astro-ssr Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-ssr


README.md

PostHog Astro SSR Example

This is an Astro server-side rendered (SSR) example demonstrating PostHog integration with both client-side and server-side event tracking.

It uses:

  • Client-side: PostHog web snippet for browser analytics
  • Server-side: posthog-node for API route event tracking

This shows how to:

  • Initialize PostHog on both client and server
  • Track events from API routes using posthog-node
  • Link client and server sessions automatically with the tracing_headers option
  • Identify users on both client and server
  • Capture errors via posthog.captureException()
  • Reset PostHog state on logout

Features

  • Server-side rendering: Full SSR with output: 'server'
  • API routes: Server-side endpoints for auth and event tracking
  • Dual tracking: Events captured on both client and server
  • Session continuity: Session and distinct ID forwarded automatically via tracing_headers
  • Product analytics: Track login and burrito consideration events
  • Session replay: Enabled via PostHog snippet configuration
  • Error tracking: Manual error capture sent to PostHog
  • Simple auth flow: Demo login using localStorage + server API

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env file in the project root:

# Client-side (PUBLIC_ prefix exposes to browser)
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# Server-side (no PUBLIC_ prefix, server-only)
POSTHOG_PROJECT_TOKEN=your_posthog_project_token
POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your project settings in PostHog.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:4321 in your browser.

Project structure

src/
  components/
    posthog.astro      # PostHog snippet for client-side tracking
    Header.astro       # Navigation + logout, calls posthog.reset()
  layouts/
    PostHogLayout.astro # Root layout that includes PostHog + Header
  lib/
    auth.ts            # Client-side auth utilities
    posthog-server.ts  # Server-side PostHog client singleton
  pages/
    index.astro        # Login form, calls /api/auth/login
    burrito.astro      # Burrito demo, calls /api/events/burrito
    profile.astro      # Profile + error tracking demo
    api/
      auth/
        login.ts       # Server-side login endpoint with PostHog tracking
      events/
        burrito.ts     # Server-side event capture endpoint
  styles/
    global.css         # Global styles

Key integration points

Server-side PostHog client (src/lib/posthog-server.ts)

A singleton pattern ensures only one PostHog client is created:

import { PostHog } from "posthog-node";

let posthogClient: PostHog | null = null;

export function getPostHogServer(): PostHog {
  if (!posthogClient) {
    posthogClient = new PostHog(import.meta.env.POSTHOG_PROJECT_TOKEN, {
      host: import.meta.env.POSTHOG_HOST,
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return posthogClient;
}
API route with server-side tracking (src/pages/api/auth/login.ts)
import { getPostHogServer } from "../../../lib/posthog-server";

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json();
  const { username } = body;

  // Get session ID from client
  const sessionId = request.headers.get("X-PostHog-Session-Id");

  const posthog = getPostHogServer();

  // Capture server-side event
  posthog.capture({
    distinctId: username,
    event: "server_login",
    properties: {
      $session_id: sessionId || undefined,
      source: "api",
    },
  });

  return new Response(JSON.stringify({ success: true }));
};
Passing session context to the server (src/components/posthog.astro)

The tracing_headers option in posthog.init automatically adds the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to same-origin fetch/XHR requests, so the server route above receives them with no manual wiring:

posthog.init(apiKey, {
  api_host: apiHost,
  defaults: "2026-01-30",
  tracing_headers: [window.location.hostname],
});

// Client fetches then need no PostHog headers of their own:
const response = await fetch("/api/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username, password }),
});
Client-side identification (src/pages/index.astro)

After server login succeeds, also identify on client:

window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");
Logout and session reset (src/components/Header.astro)

On logout, both the local auth state and PostHog state are cleared:

window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();

Scripts

# Run dev server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

Learn more


.env.example

# Client-side environment variables (PUBLIC_ prefix)
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# Server-side environment variables (no PUBLIC_ prefix)
POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

astro.config.mjs

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({
    mode: "standalone",
  }),
  image: {
    service: { entrypoint: "astro/assets/services/noop" },
  },
});

src/components/Header.astro

---
// Header component with navigation and logout functionality
---
<header class="header">
  <div class="header-container">
    <nav>
      <a href="/">Home</a>
      <a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
      <a href="/profile" class="auth-link" style="display: none;">Profile</a>
    </nav>
    <div class="user-section">
      <span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
      <span class="not-logged-in">Not logged in</span>
      <button class="btn-logout" style="display: none;">Logout</button>
    </div>
  </div>
</header>

<script is:inline>
  function updateHeader() {
    const currentUser = localStorage.getItem('currentUser');
    const authLinks = document.querySelectorAll('.auth-link');
    const welcomeText = document.querySelector('.welcome-text');
    const notLoggedIn = document.querySelector('.not-logged-in');
    const logoutBtn = document.querySelector('.btn-logout');
    const usernameSpan = document.querySelector('.username');

    if (currentUser) {
      authLinks.forEach(link => link.style.display = 'inline');
      welcomeText.style.display = 'inline';
      notLoggedIn.style.display = 'none';
      logoutBtn.style.display = 'inline';
      usernameSpan.textContent = currentUser;
    } else {
      authLinks.forEach(link => link.style.display = 'none');
      welcomeText.style.display = 'none';
      notLoggedIn.style.display = 'inline';
      logoutBtn.style.display = 'none';
    }
  }

  function handleLogout() {
    const currentUser = localStorage.getItem('currentUser');
    if (currentUser) {
      window.posthog?.capture('user_logged_out');
    }
    localStorage.removeItem('currentUser');
    localStorage.removeItem('burritoConsiderations');
    // IMPORTANT: Reset the PostHog instance to clear the user session
    window.posthog?.reset();
    window.location.href = '/';
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateHeader();
    document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
  });

  // Listen for storage changes (login/logout in other tabs)
  window.addEventListener('storage', updateHeader);
</script>

<style>
  .header {
    background-color: #333;
    color: white;
    padding: 1rem;
  }

  .header-container {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .header nav {
    display: flex;
    gap: 1rem;
  }

  .header a {
    color: white;
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    transition: background-color 0.2s;
  }

  .header a:hover {
    background-color: #555;
    text-decoration: none;
  }

  .user-section {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .btn-logout {
    background-color: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
  }

  .btn-logout:hover {
    background-color: #c82333;
  }
</style>

src/components/posthog.astro

---
// PostHog analytics snippet for client-side tracking
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
  // POSTHOG_BROWSER_SNIPPET_START
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
  // POSTHOG_BROWSER_SNIPPET_END
  posthog.init(apiKey || '', {
    api_host: apiHost || 'https://us.i.posthog.com',
    defaults: '2026-01-30',
    // Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
    // to same-origin requests so server-side events join the same session.
    tracing_headers: [window.location.hostname]
  })
</script>

src/layouts/PostHogLayout.astro

---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';

interface Props {
  title: string;
}

const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Astro PostHog SSR Integration Example" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
    <PostHog />
  </head>
  <body>
    <Header />
    <main>
      <slot />
    </main>
  </body>
</html>

src/lib/auth.ts

// Client-side auth utilities for localStorage-based authentication

export interface User {
  username: string;
  burritoConsiderations: number;
}

export function getCurrentUser(): User | null {
  if (typeof window === "undefined") return null;

  const username = localStorage.getItem("currentUser");
  if (!username) return null;

  const considerations = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );

  return {
    username,
    burritoConsiderations: considerations,
  };
}

export function login(username: string, password: string): boolean {
  if (!username || !password) return false;

  localStorage.setItem("currentUser", username);
  // Initialize burrito considerations if not set
  if (!localStorage.getItem("burritoConsiderations")) {
    localStorage.setItem("burritoConsiderations", "0");
  }

  return true;
}

export function logout(): void {
  localStorage.removeItem("currentUser");
  localStorage.removeItem("burritoConsiderations");
}

export function incrementBurritoConsiderations(): number {
  const current = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );
  const newCount = current + 1;
  localStorage.setItem("burritoConsiderations", newCount.toString());
  return newCount;
}

src/lib/posthog-server.ts

import { PostHog } from "posthog-node";

let posthogClient: PostHog | null = null;

/**
 * Get the PostHog server-side client.
 * Uses a singleton pattern to avoid creating multiple clients.
 */
export function getPostHogServer(): PostHog {
  if (!posthogClient) {
    posthogClient = new PostHog(import.meta.env.POSTHOG_PROJECT_TOKEN || "", {
      host: import.meta.env.POSTHOG_HOST || "https://us.i.posthog.com",
      // Flush immediately for demo purposes
      // In production, you might want to batch events
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return posthogClient;
}

/**
 * Shutdown the PostHog client gracefully.
 * Call this when your server is shutting down.
 */
export async function shutdownPostHog(): Promise<void> {
  if (posthogClient) {
    await posthogClient.shutdown();
    posthogClient = null;
  }
}

src/pages/api/auth/login.ts

import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";

// In-memory user store for demo purposes
const users = new Map<string, { username: string; createdAt: string }>();

export const POST: APIRoute = async ({ request }) => {
  try {
    const body = await request.json();
    const { username, password } = body;

    if (!username || !password) {
      return new Response(
        JSON.stringify({ error: "Username and password are required" }),
        { status: 400, headers: { "Content-Type": "application/json" } },
      );
    }

    // Check if this is a new user
    const isNewUser = !users.has(username);

    if (isNewUser) {
      users.set(username, {
        username,
        createdAt: new Date().toISOString(),
      });
    }

    // Get the PostHog server client
    const posthog = getPostHogServer();

    // Get session ID from client if available (passed via header)
    const sessionId = request.headers.get("X-PostHog-Session-Id");

    // Capture server-side login event
    posthog.capture({
      distinctId: username,
      event: "server_login",
      properties: {
        $session_id: sessionId || undefined,
        isNewUser,
        source: "api",
        timestamp: new Date().toISOString(),
      },
    });

    // Also identify the user server-side
    posthog.identify({
      distinctId: username,
      properties: {
        username,
        createdAt: isNewUser ? new Date().toISOString() : undefined,
      },
    });

    // This endpoint is short-lived; flush so the enqueued events send before it returns
    await posthog.flush();

    return new Response(
      JSON.stringify({
        success: true,
        username,
        isNewUser,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  } catch (error) {
    console.error("Login error:", error);
    return new Response(JSON.stringify({ error: "Internal server error" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
};

src/pages/api/events/burrito.ts

import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";

export const POST: APIRoute = async ({ request }) => {
  try {
    const body = await request.json();
    const { username, totalConsiderations } = body;

    if (!username) {
      return new Response(JSON.stringify({ error: "Username is required" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    // Get the PostHog server client
    const posthog = getPostHogServer();

    // Get session ID from client if available (passed via header)
    const sessionId = request.headers.get("X-PostHog-Session-Id");

    // Capture server-side burrito consideration event
    posthog.capture({
      distinctId: username,
      event: "burrito_considered",
      properties: {
        $session_id: sessionId || undefined,
        total_considerations: totalConsiderations,
        source: "api",
        timestamp: new Date().toISOString(),
      },
    });

    // This endpoint is short-lived; flush so the enqueued event sends before it returns
    await posthog.flush();

    return new Response(
      JSON.stringify({
        success: true,
        totalConsiderations,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } },
    );
  } catch (error) {
    console.error("Burrito event error:", error);
    return new Response(JSON.stringify({ error: "Internal server error" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
};

src/pages/burrito.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog SSR Example">
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center;">
      <button id="consider-btn" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p id="success-message" class="success" style="display: none;">
        Thank you for your consideration! Count: <span id="consideration-count"></span>
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: <span id="total-considerations">0</span></p>
    </div>

    <p class="note" style="margin-top: 1rem;">
      Events are tracked both client-side and server-side for demonstration.
    </p>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateStats() {
    const count = localStorage.getItem('burritoConsiderations') || '0';
    document.getElementById('total-considerations').textContent = count;
  }

  async function handleConsideration() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) return;

    // Increment the count
    const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
    const newCount = currentCount + 1;
    localStorage.setItem('burritoConsiderations', newCount.toString());

    // Update the UI
    updateStats();

    const successMessage = document.getElementById('success-message');
    const considerationCount = document.getElementById('consideration-count');
    considerationCount.textContent = newCount;
    successMessage.style.display = 'block';

    // Hide success message after 2 seconds
    setTimeout(() => {
      successMessage.style.display = 'none';
    }, 2000);

    // Client-side event tracking
    window.posthog?.capture('burrito_considered', {
      total_considerations: newCount,
      username: currentUser,
      source: 'client'
    });

    // Also send to server-side API for server tracking. The session and distinct
    // ID are added automatically by the tracing_headers option in posthog.init.
    try {
      await fetch('/api/events/burrito', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          username: currentUser,
          totalConsiderations: newCount
        })
      });
    } catch (error) {
      console.error('Failed to send server-side event:', error);
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateStats();
    document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
  });
</script>

src/pages/index.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog SSR Example">
  <div class="container">
    <div id="logged-in-view" style="display: none;">
      <h1>Welcome back, <span id="welcome-username"></span>!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </div>

    <div id="logged-out-view">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form id="login-form" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            placeholder="Enter any username"
            required
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            placeholder="Enter any password"
            required
          />
        </div>

        <p id="error-message" class="error" style="display: none;"></p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app with server-side tracking. Use any username and password to sign in.
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function updateView() {
    const currentUser = localStorage.getItem('currentUser');
    const loggedInView = document.getElementById('logged-in-view');
    const loggedOutView = document.getElementById('logged-out-view');
    const welcomeUsername = document.getElementById('welcome-username');

    if (currentUser) {
      loggedInView.style.display = 'block';
      loggedOutView.style.display = 'none';
      welcomeUsername.textContent = currentUser;
    } else {
      loggedInView.style.display = 'none';
      loggedOutView.style.display = 'block';
    }
  }

  async function handleLogin(event) {
    event.preventDefault();

    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

    if (!username || !password) {
      errorMessage.textContent = 'Please provide both username and password';
      errorMessage.style.display = 'block';
      return;
    }

    try {
      // Call the server-side login API. The session and distinct ID are added
      // automatically by the tracing_headers option configured in posthog.init.
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ username, password })
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Login failed');
      }

      // Store in localStorage for client-side state
      localStorage.setItem('currentUser', username);
      if (!localStorage.getItem('burritoConsiderations')) {
        localStorage.setItem('burritoConsiderations', '0');
      }

      // Also identify on the client side (for session continuity)
      window.posthog?.identify(username);
      window.posthog?.capture('user_logged_in');

      // Clear form
      document.getElementById('username').value = '';
      document.getElementById('password').value = '';
      errorMessage.style.display = 'none';

      // Update view
      updateView();

      // Trigger header update
      window.dispatchEvent(new Event('storage'));
    } catch (error) {
      errorMessage.textContent = error.message || 'Login failed';
      errorMessage.style.display = 'block';
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateView();
    document.getElementById('login-form')?.addEventListener('submit', handleLogin);
  });

  // Listen for storage changes
  window.addEventListener('storage', updateView);
</script>

src/pages/profile.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog SSR Example">
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> <span id="profile-username"></span></p>
      <p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Your Burrito Journey</h3>
      <p id="journey-message"></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Error Tracking Demo</h3>
      <p>Click the button below to trigger a test error and send it to PostHog:</p>
      <button id="error-btn" class="btn-error">
        Trigger Test Error
      </button>
      <p id="error-feedback" class="success" style="display: none;">
        Error captured and sent to PostHog!
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateProfile() {
    const username = localStorage.getItem('currentUser') || '';
    const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);

    document.getElementById('profile-username').textContent = username;
    document.getElementById('profile-considerations').textContent = considerations;

    // Update journey message based on consideration count
    const journeyMessage = document.getElementById('journey-message');
    if (considerations === 0) {
      journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
    } else if (considerations === 1) {
      journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
    } else if (considerations < 5) {
      journeyMessage.textContent = "You're getting the hang of burrito consideration!";
    } else if (considerations < 10) {
      journeyMessage.textContent = "You're becoming a burrito consideration expert!";
    } else {
      journeyMessage.textContent = "You are a true burrito consideration master!";
    }
  }

  function triggerTestError() {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      // Capture the error in PostHog
      window.posthog?.captureException(err);
      console.error('Captured error:', err);

      // Show feedback to user
      const feedback = document.getElementById('error-feedback');
      feedback.style.display = 'block';
      setTimeout(() => {
        feedback.style.display = 'none';
      }, 3000);
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateProfile();
    document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
  });
</script>

references/EXAMPLE-astro-static.md

PostHog astro-static Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-static


README.md

PostHog Astro Static Example

This is an Astro static site (SSG) example demonstrating PostHog integration with product analytics, session replay, and error tracking.

It uses the PostHog web snippet directly and shows how to:

  • Initialize PostHog in a static Astro site using a reusable component
  • Identify users after login
  • Track custom events from pages
  • Capture errors via posthog.captureException()
  • Reset PostHog state on logout

Features

  • Product analytics: Track login and burrito consideration events
  • Session replay: Enabled via PostHog snippet configuration
  • Error tracking: Manual error capture sent to PostHog
  • Simple auth flow: Demo login using localStorage

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env file in the project root:

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your project settings in PostHog.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:4321 in your browser.

Project structure

src/
  components/
    posthog.astro      # PostHog snippet with is:inline directive
    Header.astro       # Navigation + logout, calls posthog.reset()
  layouts/
    PostHogLayout.astro # Root layout that includes PostHog + Header
  lib/
    auth.ts            # Auth utilities (localStorage-based)
  pages/
    index.astro        # Login form, identifies user + captures 'user_logged_in'
    burrito.astro      # Burrito consideration demo, captures 'burrito_considered'
    profile.astro      # Profile + error tracking demo
  styles/
    global.css         # Global styles

Key integration points

PostHog initialization (src/components/posthog.astro)

The PostHog snippet is included as an inline script to prevent Astro from processing it:

<script is:inline>
  !function(t,e){...}(document,window.posthog||[]);
  posthog.init('<ph_project_token>', {
    api_host: 'https://us.i.posthog.com',
    defaults: '2026-01-30'
  })
</script>

The is:inline directive is required to prevent TypeScript errors about window.posthog.

User identification (src/pages/index.astro)

After a successful "login", the app identifies the user and captures a login event:

window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");

Identification happens only on login, all further requests will automatically use the same distinct ID.

Event tracking (src/pages/burrito.astro)

The burrito page tracks a custom event when a user "considers" the burrito:

window.posthog?.capture("burrito_considered", {
  total_considerations: newCount,
  username: currentUser,
});

This shows how to attach useful properties to events (e.g. counts, usernames).

Error tracking (src/pages/profile.astro)

The profile page includes a button to trigger a test error:

try {
  throw new Error("Test error for PostHog error tracking");
} catch (err) {
  window.posthog?.captureException(err);
}
Logout and session reset (src/components/Header.astro)

On logout, both the local auth state and PostHog state are cleared:

window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();

posthog.reset() clears the current distinct ID and session so the next login starts a fresh identity.

Scripts

# Run dev server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

Learn more


.env.example

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

astro.config.mjs

import { defineConfig } from "astro/config";

export default defineConfig({});

src/components/Header.astro

---
// Header component with navigation and logout functionality
---
<header class="header">
  <div class="header-container">
    <nav>
      <a href="/">Home</a>
      <a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
      <a href="/profile" class="auth-link" style="display: none;">Profile</a>
    </nav>
    <div class="user-section">
      <span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
      <span class="not-logged-in">Not logged in</span>
      <button class="btn-logout" style="display: none;">Logout</button>
    </div>
  </div>
</header>

<script is:inline>
  function updateHeader() {
    const currentUser = localStorage.getItem('currentUser');
    const authLinks = document.querySelectorAll('.auth-link');
    const welcomeText = document.querySelector('.welcome-text');
    const notLoggedIn = document.querySelector('.not-logged-in');
    const logoutBtn = document.querySelector('.btn-logout');
    const usernameSpan = document.querySelector('.username');

    if (currentUser) {
      authLinks.forEach(link => link.style.display = 'inline');
      welcomeText.style.display = 'inline';
      notLoggedIn.style.display = 'none';
      logoutBtn.style.display = 'inline';
      usernameSpan.textContent = currentUser;
    } else {
      authLinks.forEach(link => link.style.display = 'none');
      welcomeText.style.display = 'none';
      notLoggedIn.style.display = 'inline';
      logoutBtn.style.display = 'none';
    }
  }

  function handleLogout() {
    const currentUser = localStorage.getItem('currentUser');
    if (currentUser) {
      window.posthog?.capture('user_logged_out');
    }
    localStorage.removeItem('currentUser');
    localStorage.removeItem('burritoConsiderations');
    // IMPORTANT: Reset the PostHog instance to clear the user session
    window.posthog?.reset();
    window.location.href = '/';
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateHeader();
    document.querySelector('.btn-logout')?.addEventListener('click', handleLogout);
  });

  // Listen for storage changes (login/logout in other tabs)
  window.addEventListener('storage', updateHeader);
</script>

<style>
  .header {
    background-color: #333;
    color: white;
    padding: 1rem;
  }

  .header-container {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .header nav {
    display: flex;
    gap: 1rem;
  }

  .header a {
    color: white;
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    transition: background-color 0.2s;
  }

  .header a:hover {
    background-color: #555;
    text-decoration: none;
  }

  .user-section {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .btn-logout {
    background-color: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
  }

  .btn-logout:hover {
    background-color: #c82333;
  }
</style>

src/components/posthog.astro

---
// PostHog analytics snippet
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
  // POSTHOG_BROWSER_SNIPPET_START
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
  // POSTHOG_BROWSER_SNIPPET_END
  posthog.init(apiKey || '', {
    api_host: apiHost || 'https://us.i.posthog.com',
    defaults: '2026-01-30'
  })
</script>

src/layouts/PostHogLayout.astro

---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';

interface Props {
  title: string;
}

const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Astro PostHog Integration Example" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
    <PostHog />
  </head>
  <body>
    <Header />
    <main>
      <slot />
    </main>
  </body>
</html>

src/lib/auth.ts

// Client-side auth utilities for localStorage-based authentication

export interface User {
  username: string;
  burritoConsiderations: number;
}

export function getCurrentUser(): User | null {
  if (typeof window === "undefined") return null;

  const username = localStorage.getItem("currentUser");
  if (!username) return null;

  const considerations = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );

  return {
    username,
    burritoConsiderations: considerations,
  };
}

export function login(username: string, password: string): boolean {
  if (!username || !password) return false;

  localStorage.setItem("currentUser", username);
  // Initialize burrito considerations if not set
  if (!localStorage.getItem("burritoConsiderations")) {
    localStorage.setItem("burritoConsiderations", "0");
  }

  return true;
}

export function logout(): void {
  localStorage.removeItem("currentUser");
  localStorage.removeItem("burritoConsiderations");
}

export function incrementBurritoConsiderations(): number {
  const current = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );
  const newCount = current + 1;
  localStorage.setItem("burritoConsiderations", newCount.toString());
  return newCount;
}

src/pages/burrito.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog Example">
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center;">
      <button id="consider-btn" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p id="success-message" class="success" style="display: none;">
        Thank you for your consideration! Count: <span id="consideration-count"></span>
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: <span id="total-considerations">0</span></p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateStats() {
    const count = localStorage.getItem('burritoConsiderations') || '0';
    document.getElementById('total-considerations').textContent = count;
  }

  function handleConsideration() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) return;

    // Increment the count
    const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
    const newCount = currentCount + 1;
    localStorage.setItem('burritoConsiderations', newCount.toString());

    // Update the UI
    updateStats();

    const successMessage = document.getElementById('success-message');
    const considerationCount = document.getElementById('consideration-count');
    considerationCount.textContent = newCount;
    successMessage.style.display = 'block';

    // Hide success message after 2 seconds
    setTimeout(() => {
      successMessage.style.display = 'none';
    }, 2000);

    // Capture burrito consideration event in PostHog
    window.posthog?.capture('burrito_considered', {
      total_considerations: newCount,
      username: currentUser
    });
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateStats();
    document.getElementById('consider-btn')?.addEventListener('click', handleConsideration);
  });
</script>

src/pages/index.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog Example">
  <div class="container">
    <div id="logged-in-view" style="display: none;">
      <h1>Welcome back, <span id="welcome-username"></span>!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </div>

    <div id="logged-out-view">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form id="login-form" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            placeholder="Enter any username"
            required
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            placeholder="Enter any password"
            required
          />
        </div>

        <p id="error-message" class="error" style="display: none;"></p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function updateView() {
    const currentUser = localStorage.getItem('currentUser');
    const loggedInView = document.getElementById('logged-in-view');
    const loggedOutView = document.getElementById('logged-out-view');
    const welcomeUsername = document.getElementById('welcome-username');

    if (currentUser) {
      loggedInView.style.display = 'block';
      loggedOutView.style.display = 'none';
      welcomeUsername.textContent = currentUser;
    } else {
      loggedInView.style.display = 'none';
      loggedOutView.style.display = 'block';
    }
  }

  function handleLogin(event) {
    event.preventDefault();

    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

    if (!username || !password) {
      errorMessage.textContent = 'Please provide both username and password';
      errorMessage.style.display = 'block';
      return;
    }

    // Client-side only fake auth - store in localStorage
    localStorage.setItem('currentUser', username);
    if (!localStorage.getItem('burritoConsiderations')) {
      localStorage.setItem('burritoConsiderations', '0');
    }

    // Identify the user in PostHog (once on login is enough)
    window.posthog?.identify(username);
    window.posthog?.capture('user_logged_in');

    // Clear form
    document.getElementById('username').value = '';
    document.getElementById('password').value = '';
    errorMessage.style.display = 'none';

    // Update view
    updateView();

    // Trigger header update
    window.dispatchEvent(new Event('storage'));
  }

  document.addEventListener('DOMContentLoaded', () => {
    updateView();
    document.getElementById('login-form')?.addEventListener('submit', handleLogin);
  });

  // Listen for storage changes
  window.addEventListener('storage', updateView);
</script>

src/pages/profile.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog Example">
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> <span id="profile-username"></span></p>
      <p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Your Burrito Journey</h3>
      <p id="journey-message"></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Error Tracking Demo</h3>
      <p>Click the button below to trigger a test error and send it to PostHog:</p>
      <button id="error-btn" class="btn-error">
        Trigger Test Error
      </button>
      <p id="error-feedback" class="success" style="display: none;">
        Error captured and sent to PostHog!
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateProfile() {
    const username = localStorage.getItem('currentUser') || '';
    const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);

    document.getElementById('profile-username').textContent = username;
    document.getElementById('profile-considerations').textContent = considerations;

    // Update journey message based on consideration count
    const journeyMessage = document.getElementById('journey-message');
    if (considerations === 0) {
      journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
    } else if (considerations === 1) {
      journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
    } else if (considerations < 5) {
      journeyMessage.textContent = "You're getting the hang of burrito consideration!";
    } else if (considerations < 10) {
      journeyMessage.textContent = "You're becoming a burrito consideration expert!";
    } else {
      journeyMessage.textContent = "You are a true burrito consideration master!";
    }
  }

  function triggerTestError() {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      // Capture the error in PostHog
      window.posthog?.captureException(err);
      console.error('Captured error:', err);

      // Show feedback to user
      const feedback = document.getElementById('error-feedback');
      feedback.style.display = 'block';
      setTimeout(() => {
        feedback.style.display = 'none';
      }, 3000);
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    if (!checkAuth()) return;

    updateProfile();
    document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
  });
</script>

references/EXAMPLE-astro-view-transitions.md

PostHog astro-view-transitions Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/astro-view-transitions


README.md

PostHog Astro View Transitions Example

This is an Astro example demonstrating PostHog integration with View Transitions (ClientRouter) for SPA-like navigation.

It uses the PostHog web snippet with special handling to prevent stack overflow errors during soft navigation, and shows how to:

  • Initialize PostHog with an initialization guard for View Transitions
  • Track pageviews automatically during soft navigation
  • Identify users after login
  • Track custom events from pages
  • Capture errors via posthog.captureException()
  • Reset PostHog state on logout

Features

  • View Transitions: Smooth client-side navigation with <ClientRouter />
  • Product analytics: Track login and burrito consideration events
  • Automatic pageview tracking: Uses capture_pageview: 'history_change' for soft navigation
  • Session replay: Enabled via PostHog snippet configuration
  • Error tracking: Manual error capture sent to PostHog
  • Simple auth flow: Demo login using localStorage

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env file in the project root:

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your project settings in PostHog.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:4321 in your browser.

Project structure

src/
  components/
    posthog.astro      # PostHog snippet WITH initialization guard
    Header.astro       # Navigation + logout, uses astro:page-load event
  layouts/
    PostHogLayout.astro # Root layout with <ClientRouter /> and PostHog
  lib/
    auth.ts            # Auth utilities (localStorage-based)
  pages/
    index.astro        # Login form, identifies user + captures 'user_logged_in'
    burrito.astro      # Burrito consideration demo, captures 'burrito_considered'
    profile.astro      # Profile + error tracking demo
  styles/
    global.css         # Global styles + view transition animations

Key integration points

PostHog initialization with View Transitions (src/components/posthog.astro)

When using Astro's View Transitions (ClientRouter), you must wrap the PostHog initialization with a guard to prevent stack overflow errors:

<script is:inline>
  // IMPORTANT: Guard against multiple initializations during view transitions
  if (!window.__posthog_initialized) {
    window.__posthog_initialized = true;
    !function(t,e){...}(document,window.posthog||[]);
    posthog.init('<ph_project_token>', {
      api_host: 'https://us.i.posthog.com',
      defaults: '2026-01-30',
      // IMPORTANT: Use 'history_change' for automatic pageview tracking during soft navigation
      capture_pageview: 'history_change'
    })
  }
</script>

Without this guard, ClientRouter's soft navigation can re-execute the inline script during page transitions, causing a stack overflow error.

The capture_pageview: 'history_change' option ensures pageviews are tracked automatically as users navigate between pages.

Layout with ClientRouter (src/layouts/PostHogLayout.astro)

The layout includes Astro's ClientRouter for smooth page transitions:

---
import { ClientRouter } from 'astro:transitions';
import PostHog from '../components/posthog.astro';
---
<html>
  <head>
    <ClientRouter />
    <PostHog />
  </head>
  ...
</html>
Handling View Transitions in scripts

When using View Transitions, you need to set up event listeners after each page navigation:

function setupPage() {
  // Your setup code here
}

// Run on initial page load
document.addEventListener("DOMContentLoaded", setupPage);

// Run after view transitions complete (for soft navigation)
document.addEventListener("astro:page-load", setupPage);
User identification (src/pages/index.astro)

After a successful "login", the app identifies the user and captures a login event:

window.posthog?.identify(username);
window.posthog?.capture("user_logged_in");
Event tracking (src/pages/burrito.astro)

The burrito page tracks a custom event when a user "considers" the burrito:

window.posthog?.capture("burrito_considered", {
  total_considerations: newCount,
  username: currentUser,
});
Logout and session reset (src/components/Header.astro)

On logout, both the local auth state and PostHog state are cleared:

window.posthog?.capture("user_logged_out");
localStorage.removeItem("currentUser");
window.posthog?.reset();

Scripts

# Run dev server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

Learn more


.env.example

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

astro.config.mjs

import { defineConfig } from "astro/config";

export default defineConfig({});

src/components/Header.astro

---
// Header component with navigation and logout functionality
// Works with View Transitions by using data-astro-reload for logout
---
<header class="header">
  <div class="header-container">
    <nav>
      <a href="/">Home</a>
      <a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a>
      <a href="/profile" class="auth-link" style="display: none;">Profile</a>
    </nav>
    <div class="user-section">
      <span class="welcome-text" style="display: none;">Welcome, <span class="username"></span>!</span>
      <span class="not-logged-in">Not logged in</span>
      <button class="btn-logout" style="display: none;">Logout</button>
    </div>
  </div>
</header>

<script is:inline>
  function updateHeader() {
    const currentUser = localStorage.getItem('currentUser');
    const authLinks = document.querySelectorAll('.auth-link');
    const welcomeText = document.querySelector('.welcome-text');
    const notLoggedIn = document.querySelector('.not-logged-in');
    const logoutBtn = document.querySelector('.btn-logout');
    const usernameSpan = document.querySelector('.username');

    if (currentUser) {
      authLinks.forEach(link => link.style.display = 'inline');
      welcomeText.style.display = 'inline';
      notLoggedIn.style.display = 'none';
      logoutBtn.style.display = 'inline';
      usernameSpan.textContent = currentUser;
    } else {
      authLinks.forEach(link => link.style.display = 'none');
      welcomeText.style.display = 'none';
      notLoggedIn.style.display = 'inline';
      logoutBtn.style.display = 'none';
    }
  }

  function handleLogout() {
    const currentUser = localStorage.getItem('currentUser');
    if (currentUser) {
      window.posthog?.capture('user_logged_out');
    }
    localStorage.removeItem('currentUser');
    localStorage.removeItem('burritoConsiderations');
    // IMPORTANT: Reset the PostHog instance to clear the user session
    window.posthog?.reset();
    window.location.href = '/';
  }

  function setupHeader() {
    updateHeader();
    const logoutBtn = document.querySelector('.btn-logout');
    // Remove existing listeners to prevent duplicates during view transitions
    logoutBtn?.removeEventListener('click', handleLogout);
    logoutBtn?.addEventListener('click', handleLogout);
  }

  // Run on initial page load
  document.addEventListener('DOMContentLoaded', setupHeader);

  // Run after view transitions complete (for soft navigation)
  document.addEventListener('astro:page-load', setupHeader);

  // Listen for storage changes (login/logout in other tabs)
  window.addEventListener('storage', updateHeader);
</script>

<style>
  .header {
    background-color: #333;
    color: white;
    padding: 1rem;
  }

  .header-container {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .header nav {
    display: flex;
    gap: 1rem;
  }

  .header a {
    color: white;
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    transition: background-color 0.2s;
  }

  .header a:hover {
    background-color: #555;
    text-decoration: none;
  }

  .user-section {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .btn-logout {
    background-color: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
  }

  .btn-logout:hover {
    background-color: #c82333;
  }
</style>

src/components/posthog.astro

---
// PostHog analytics snippet with View Transitions support
// Uses is:inline to prevent Astro from processing the script
// Includes initialization guard to prevent stack overflow with ClientRouter
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
  // IMPORTANT: Guard against multiple initializations during view transitions
  // Without this guard, ClientRouter's soft navigation can re-execute the inline script
  // during page transitions, causing a stack overflow error.
  if (!window.__posthog_initialized) {
    window.__posthog_initialized = true;
    // POSTHOG_BROWSER_SNIPPET_START
    !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
    // POSTHOG_BROWSER_SNIPPET_END
    posthog.init(apiKey || '', {
      api_host: apiHost || 'https://us.i.posthog.com',
      defaults: '2026-01-30',
      // IMPORTANT: Use 'history_change' to automatically track pageviews during soft navigation
      capture_pageview: 'history_change'
    })
  }
</script>

src/layouts/PostHogLayout.astro

---
import { ClientRouter } from 'astro:transitions';
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';

interface Props {
  title: string;
}

const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Astro PostHog Integration with View Transitions" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
    <ClientRouter />
    <PostHog />
  </head>
  <body>
    <Header />
    <main>
      <slot />
    </main>
  </body>
</html>

src/lib/auth.ts

// Client-side auth utilities for localStorage-based authentication

export interface User {
  username: string;
  burritoConsiderations: number;
}

export function getCurrentUser(): User | null {
  if (typeof window === "undefined") return null;

  const username = localStorage.getItem("currentUser");
  if (!username) return null;

  const considerations = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );

  return {
    username,
    burritoConsiderations: considerations,
  };
}

export function login(username: string, password: string): boolean {
  if (!username || !password) return false;

  localStorage.setItem("currentUser", username);
  // Initialize burrito considerations if not set
  if (!localStorage.getItem("burritoConsiderations")) {
    localStorage.setItem("burritoConsiderations", "0");
  }

  return true;
}

export function logout(): void {
  localStorage.removeItem("currentUser");
  localStorage.removeItem("burritoConsiderations");
}

export function incrementBurritoConsiderations(): number {
  const current = parseInt(
    localStorage.getItem("burritoConsiderations") || "0",
    10,
  );
  const newCount = current + 1;
  localStorage.setItem("burritoConsiderations", newCount.toString());
  return newCount;
}

src/pages/burrito.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Burrito Consideration - Astro PostHog with View Transitions">
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center;">
      <button id="consider-btn" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p id="success-message" class="success" style="display: none;">
        Thank you for your consideration! Count: <span id="consideration-count"></span>
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: <span id="total-considerations">0</span></p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateStats() {
    const count = localStorage.getItem('burritoConsiderations') || '0';
    const totalElement = document.getElementById('total-considerations');
    if (totalElement) {
      totalElement.textContent = count;
    }
  }

  function handleConsideration() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) return;

    // Increment the count
    const currentCount = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
    const newCount = currentCount + 1;
    localStorage.setItem('burritoConsiderations', newCount.toString());

    // Update the UI
    updateStats();

    const successMessage = document.getElementById('success-message');
    const considerationCount = document.getElementById('consideration-count');
    if (considerationCount) {
      considerationCount.textContent = newCount;
    }
    if (successMessage) {
      successMessage.style.display = 'block';

      // Hide success message after 2 seconds
      setTimeout(() => {
        successMessage.style.display = 'none';
      }, 2000);
    }

    // Capture burrito consideration event in PostHog
    window.posthog?.capture('burrito_considered', {
      total_considerations: newCount,
      username: currentUser
    });
  }

  function setupBurritoPage() {
    if (!checkAuth()) return;

    updateStats();
    const btn = document.getElementById('consider-btn');
    // Remove existing listener to prevent duplicates during view transitions
    btn?.removeEventListener('click', handleConsideration);
    btn?.addEventListener('click', handleConsideration);
  }

  // Run on initial page load
  document.addEventListener('DOMContentLoaded', setupBurritoPage);

  // Run after view transitions complete (for soft navigation)
  document.addEventListener('astro:page-load', setupBurritoPage);
</script>

src/pages/index.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Home - Astro PostHog with View Transitions">
  <div class="container">
    <div id="logged-in-view" style="display: none;">
      <h1>Welcome back, <span id="welcome-username"></span>!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </div>

    <div id="logged-out-view">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form id="login-form" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            placeholder="Enter any username"
            required
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            placeholder="Enter any password"
            required
          />
        </div>

        <p id="error-message" class="error" style="display: none;"></p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function updateView() {
    const currentUser = localStorage.getItem('currentUser');
    const loggedInView = document.getElementById('logged-in-view');
    const loggedOutView = document.getElementById('logged-out-view');
    const welcomeUsername = document.getElementById('welcome-username');

    if (currentUser) {
      loggedInView.style.display = 'block';
      loggedOutView.style.display = 'none';
      welcomeUsername.textContent = currentUser;
    } else {
      loggedInView.style.display = 'none';
      loggedOutView.style.display = 'block';
    }
  }

  function handleLogin(event) {
    event.preventDefault();

    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

    if (!username || !password) {
      errorMessage.textContent = 'Please provide both username and password';
      errorMessage.style.display = 'block';
      return;
    }

    // Client-side only fake auth - store in localStorage
    localStorage.setItem('currentUser', username);
    if (!localStorage.getItem('burritoConsiderations')) {
      localStorage.setItem('burritoConsiderations', '0');
    }

    // Identify the user in PostHog (once on login is enough)
    window.posthog?.identify(username);
    window.posthog?.capture('user_logged_in');

    // Clear form
    document.getElementById('username').value = '';
    document.getElementById('password').value = '';
    errorMessage.style.display = 'none';

    // Update view
    updateView();

    // Trigger header update
    window.dispatchEvent(new Event('storage'));
  }

  function setupIndexPage() {
    updateView();
    const form = document.getElementById('login-form');
    // Remove existing listener to prevent duplicates during view transitions
    form?.removeEventListener('submit', handleLogin);
    form?.addEventListener('submit', handleLogin);
  }

  // Run on initial page load
  document.addEventListener('DOMContentLoaded', setupIndexPage);

  // Run after view transitions complete (for soft navigation)
  document.addEventListener('astro:page-load', setupIndexPage);

  // Listen for storage changes
  window.addEventListener('storage', updateView);
</script>

src/pages/profile.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout title="Profile - Astro PostHog with View Transitions">
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> <span id="profile-username"></span></p>
      <p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Your Burrito Journey</h3>
      <p id="journey-message"></p>
    </div>

    <div style="margin-top: 2rem;">
      <h3>Error Tracking Demo</h3>
      <p>Click the button below to trigger a test error and send it to PostHog:</p>
      <button id="error-btn" class="btn-error">
        Trigger Test Error
      </button>
      <p id="error-feedback" class="success" style="display: none;">
        Error captured and sent to PostHog!
      </p>
    </div>
  </div>
</PostHogLayout>

<script is:inline>
  function checkAuth() {
    const currentUser = localStorage.getItem('currentUser');
    if (!currentUser) {
      window.location.href = '/';
      return false;
    }
    return true;
  }

  function updateProfile() {
    const username = localStorage.getItem('currentUser') || '';
    const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);

    const usernameEl = document.getElementById('profile-username');
    const considerationsEl = document.getElementById('profile-considerations');
    const journeyMessage = document.getElementById('journey-message');

    if (usernameEl) usernameEl.textContent = username;
    if (considerationsEl) considerationsEl.textContent = considerations.toString();

    // Update journey message based on consideration count
    if (journeyMessage) {
      if (considerations === 0) {
        journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
      } else if (considerations === 1) {
        journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
      } else if (considerations < 5) {
        journeyMessage.textContent = "You're getting the hang of burrito consideration!";
      } else if (considerations < 10) {
        journeyMessage.textContent = "You're becoming a burrito consideration expert!";
      } else {
        journeyMessage.textContent = "You are a true burrito consideration master!";
      }
    }
  }

  function triggerTestError() {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      // Capture the error in PostHog
      window.posthog?.captureException(err);
      console.error('Captured error:', err);

      // Show feedback to user
      const feedback = document.getElementById('error-feedback');
      if (feedback) {
        feedback.style.display = 'block';
        setTimeout(() => {
          feedback.style.display = 'none';
        }, 3000);
      }
    }
  }

  function setupProfilePage() {
    if (!checkAuth()) return;

    updateProfile();
    const btn = document.getElementById('error-btn');
    // Remove existing listener to prevent duplicates during view transitions
    btn?.removeEventListener('click', triggerTestError);
    btn?.addEventListener('click', triggerTestError);
  }

  // Run on initial page load
  document.addEventListener('DOMContentLoaded', setupProfilePage);

  // Run after view transitions complete (for soft navigation)
  document.addEventListener('astro:page-load', setupProfilePage);
</script>

references/EXAMPLE-django.md

PostHog django Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/django


README.md

PostHog Django example

This is a Django example demonstrating PostHog integration with product analytics, error tracking, feature flags, and user identification.

Features

  • Product analytics: Track user events and behaviors
  • Error tracking: Capture and track exceptions automatically
  • User identification: Associate events with authenticated users via context
  • Feature flags: Control feature rollouts with PostHog feature flags
  • Server-side tracking: All tracking happens server-side with the Python SDK
  • Context middleware: Automatic session and user context extraction

Getting started

1. Install dependencies
pip install posthog
2. Configure environment variables

Create a .env file in the root directory:

POSTHOG_PROJECT_TOKEN=your_posthog_project_token
POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run migrations
python manage.py migrate
4. Run the development server
python manage.py runserver

Open http://localhost:8000 with your browser to see the app.

Project structure

django/
├── manage.py                    # Django management script
├── requirements.txt             # Python dependencies
├── .env.example                 # Environment variable template
├── .gitignore
├── posthog_example/
│   ├── __init__.py
│   ├── settings.py              # Django settings with PostHog config
│   ├── urls.py                  # URL routing
│   ├── wsgi.py                  # WSGI application
│   └── asgi.py                  # ASGI application
└── core/
    ├── __init__.py
    ├── apps.py                  # AppConfig with PostHog initialization
    ├── views.py                 # Views with event tracking examples
    ├── urls.py                  # App URL patterns
    └── templates/
        └── core/
            ├── base.html        # Base template
            ├── home.html        # Home/login page
            ├── burrito.html     # Burrito page with event tracking
            ├── dashboard.html   # Dashboard with feature flag example
            └── profile.html     # Profile page

Key integration points

PostHog initialization (core/apps.py)
import posthog
from django.conf import settings

class CoreConfig(AppConfig):
    name = 'core'

    def ready(self):
        posthog.api_key = settings.POSTHOG_PROJECT_TOKEN
        posthog.host = settings.POSTHOG_HOST
Django settings configuration (settings.py)
import os

# PostHog configuration
POSTHOG_PROJECT_TOKEN = os.environ.get('POSTHOG_PROJECT_TOKEN', '<ph_project_token>')
POSTHOG_HOST = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')

MIDDLEWARE = [
    # ... other middleware
    'posthog.integrations.django.PosthogContextMiddleware',
]
Built-in context middleware

The PostHog SDK includes a Django middleware that automatically wraps all requests with a context. It extracts session and user information from request headers and tags all events captured during the request.

The middleware automatically extracts:

  • Session ID from the X-POSTHOG-SESSION-ID header
  • Distinct ID from the X-POSTHOG-DISTINCT-ID header
  • Current URL as $current_url
  • Request method as $request_method
User identification (core/views.py)
import posthog

def login_view(request):
    # ... authentication logic
    if user:
        with posthog.new_context():
            posthog.identify_context(str(user.id))
            posthog.tag('email', user.email)
            posthog.tag('username', user.username)
            posthog.capture('user_logged_in', properties={
                'login_method': 'email',
            })
Event tracking (core/views.py)
import posthog

def consider_burrito(request):
    user_id = str(request.user.id) if request.user.is_authenticated else 'anonymous'

    with posthog.new_context():
        posthog.identify_context(user_id)
        posthog.capture('burrito_considered', properties={
            'total_considerations': request.session.get('burrito_count', 0),
        })
Feature flags (core/views.py)
import posthog

def dashboard_view(request):
    user_id = str(request.user.id) if request.user.is_authenticated else 'anonymous'

    show_new_feature = posthog.feature_enabled(
        'new-dashboard-feature',
        distinct_id=user_id
    )

    return render(request, 'core/dashboard.html', {
        'show_new_feature': show_new_feature
    })
Error tracking (core/views.py)

Capture exceptions manually using capture_exception():

import posthog

def profile_view(request):
    try:
        risky_operation()
    except Exception as e:
        posthog.capture_exception(e)

Frontend integration (optional)

If you're using PostHog's JavaScript SDK on the frontend, enable tracing headers to connect frontend sessions with backend events:

posthog.init('<ph_project_token>', {
    api_host: 'https://us.i.posthog.com',
    tracing_headers: ['your-backend-domain.com'],
})

This automatically adds X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to requests, which the Django middleware extracts to maintain context.

Learn more


.env.example

POSTHOG_PROJECT_TOKEN=
POSTHOG_HOST=https://us.i.posthog.com
DJANGO_SECRET_KEY=your-secret-key-here
DEBUG=True

core/init.py

# Core app for PostHog Django example

core/apps.py

"""
Django AppConfig that initializes PostHog when the application starts.

This ensures the SDK is configured once when Django starts, making it available throughout the application.
"""

from django.apps import AppConfig
from django.conf import settings


class CoreConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'core'

    def ready(self):
        """
        Initialize PostHog when Django starts.

        This method is called once when Django starts. We configure the
        PostHog SDK here so it's available everywhere in the application.

        Note: Import posthog inside this method to avoid import issues
        during Django's startup sequence.
        """
        import posthog

        # Configure PostHog with settings from Django settings
        posthog.api_key = settings.POSTHOG_PROJECT_TOKEN
        posthog.host = settings.POSTHOG_HOST

        # Honor the POSTHOG_DISABLED setting (useful for testing)
        if settings.POSTHOG_DISABLED:
            posthog.disabled = True

        # Optional: Enable debug mode in development
        if settings.DEBUG:
            posthog.debug = True

        # Register the auth signal that identifies the login request's context.
        from . import signals  # noqa: F401

core/signals.py

"""PostHog identity for the login request.

The middleware reads request.user once, before any view runs. On a login request
the visitor is still anonymous at that point, so the request's context has no
distinct ID, and calling login() inside the view does not change that. This
signal runs inside the login request and identifies the ambient context, so
every capture later in that same request is attributed to the user who just
logged in. Requests made after login don't need this: the middleware sees the
authenticated user from the start.
"""

import posthog
from posthog import identify_context
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver


@receiver(user_logged_in)
def identify_posthog_user(sender, request, user, **kwargs):
    identify_context(str(user.pk))

    # PII belongs in person properties, never in event properties.
    posthog.set(
        distinct_id=str(user.pk),
        properties={
            'email': user.email,
            'username': user.username,
            'name': user.get_full_name() or user.username,
            'is_staff': user.is_staff,
            'date_joined': user.date_joined.isoformat(),
        },
    )

core/templates/core/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}PostHog Django example{% endblock %}</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        nav {
            background: #1d4ed8;
            padding: 15px 20px;
            margin-bottom: 30px;
        }
        nav a {
            color: white;
            text-decoration: none;
            margin-right: 20px;
        }
        nav a:hover {
            text-decoration: underline;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        button.danger {
            background: #dc2626;
        }
        button.danger:hover {
            background: #b91c1c;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        .messages {
            margin-bottom: 20px;
        }
        .message {
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 10px;
        }
        .message.error {
            background: #fee2e2;
            color: #dc2626;
        }
        .message.success {
            background: #d1fae5;
            color: #059669;
        }
        .feature-flag {
            background: #fef3c7;
            border: 2px dashed #f59e0b;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
        }
        .count {
            font-size: 48px;
            font-weight: bold;
            color: #1d4ed8;
            text-align: center;
            padding: 20px;
        }
    </style>
</head>
<body>
    {% if user.is_authenticated %}
    <nav>
        <a href="{% url 'dashboard' %}">Dashboard</a>
        <a href="{% url 'burrito' %}">Burrito</a>
        <a href="{% url 'profile' %}">Profile</a>
        <a href="{% url 'logout' %}" style="float: right;">Logout ({{ user.username }})</a>
    </nav>
    {% endif %}

    <div class="container">
        {% if messages %}
        <div class="messages">
            {% for message in messages %}
            <div class="message {{ message.tags }}">{{ message }}</div>
            {% endfor %}
        </div>
        {% endif %}

        {% block content %}{% endblock %}
    </div>

    {% block scripts %}{% endblock %}
</body>
</html>

core/templates/core/burrito.html

{% extends 'core/base.html' %}

{% block title %}Burrito - PostHog Django example{% endblock %}

{% block content %}
<div class="card">
    <h1>Burrito consideration tracker</h1>
    <p>This page demonstrates custom event tracking with PostHog.</p>
</div>

<div class="card" style="text-align: center;">
    <h2>Times considered</h2>
    <div class="count" id="burrito-count">{{ burrito_count }}</div>
    <button onclick="considerBurrito()" style="font-size: 18px; padding: 15px 30px;">
        Consider a burrito
    </button>
</div>

<div class="card">
    <h3>How event tracking works</h3>
    <p>Each time you click the button, a <code>burrito_considered</code> event is sent to PostHog:</p>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto; margin-top: 15px;"><code>from posthog import new_context, identify_context, capture

with new_context():
    identify_context(user_id)
    capture('burrito_considered', properties={
        'total_considerations': count,
    })</code></pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function considerBurrito() {
    try {
        const response = await fetch('{% url "consider_burrito" %}', {
            method: 'POST',
            headers: {
                'X-CSRFToken': '{{ csrf_token }}',
                'Content-Type': 'application/json',
            },
        });

        const data = await response.json();

        if (data.success) {
            document.getElementById('burrito-count').textContent = data.count;
        }
    } catch (error) {
        console.error('Error:', error);
    }
}
</script>
{% endblock %}

core/templates/core/dashboard.html

{% extends 'core/base.html' %}

{% block title %}Dashboard - PostHog Django example{% endblock %}

{% block content %}
<div class="card">
    <h1>Dashboard</h1>
    <p>Welcome back, <strong>{{ user.username }}</strong>!</p>
</div>

<div class="card">
    <h2>Feature flags</h2>
    <p>Feature flags allow you to control feature rollouts and run A/B tests.</p>

    {% if show_new_feature %}
    <div class="feature-flag">
        <h3>New feature enabled!</h3>
        <p>
            This section is only visible because the <code>new-dashboard-feature</code>
            flag is enabled for your user.
        </p>
        {% if feature_config %}
        <p><strong>Feature config:</strong> {{ feature_config }}</p>
        {% endif %}
    </div>
    {% else %}
    <div style="background: #f3f4f6; padding: 15px; border-radius: 8px; margin-top: 15px;">
        <p>
            The <code>new-dashboard-feature</code> flag is not enabled for your user.
            Create this flag in your PostHog project to see it in action.
        </p>
    </div>
    {% endif %}
</div>

<div class="card">
    <h3>How feature flags work</h3>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># Check if a feature flag is enabled
show_feature = posthog.feature_enabled(
    'new-dashboard-feature',
    distinct_id=user_id,
    person_properties={
        'email': user.email,
        'is_staff': user.is_staff,
    }
)

# Get feature flag payload for configuration
config = posthog.get_feature_flag_payload(
    'new-dashboard-feature',
    distinct_id=user_id,
)</code></pre>
</div>
{% endblock %}

core/templates/core/home.html

{% extends 'core/base.html' %}

{% block title %}Login - PostHog Django example{% endblock %}

{% block content %}
<div class="card">
    <h1>PostHog Django example</h1>
    <p>Welcome! This example demonstrates PostHog integration with Django.</p>
</div>

<div class="card">
    <h2>Login</h2>
    <p>Login to see PostHog analytics in action.</p>

    <form method="post" style="margin-top: 20px;">
        {% csrf_token %}
        <input type="text" name="username" placeholder="Username" required>
        <input type="password" name="password" placeholder="Password" required>
        <button type="submit">Login</button>
    </form>

    <p style="margin-top: 15px; color: #666; font-size: 14px;">
        Tip: Create a user with <code>python manage.py createsuperuser</code>
    </p>
</div>

<div class="card">
    <h3>What this example demonstrates</h3>
    <ul style="padding-left: 20px;">
        <li><strong>User identification</strong> - Users are identified with <code>identify_context()</code> on login</li>
        <li><strong>Pageview tracking</strong> - Middleware extracts session and user context</li>
        <li><strong>Event tracking</strong> - Custom events captured with <code>capture()</code> in context</li>
        <li><strong>Feature flags</strong> - Conditional features with <code>posthog.feature_enabled()</code></li>
        <li><strong>Error tracking</strong> - Exceptions captured with <code>capture_exception()</code></li>
    </ul>
</div>
{% endblock %}

core/templates/core/profile.html

{% extends 'core/base.html' %}

{% block title %}Profile - PostHog Django example{% endblock %}

{% block content %}
<div class="card">
    <h1>Profile</h1>
    <p>This page demonstrates error tracking with PostHog.</p>
</div>

<div class="card">
    <h2>User information</h2>
    <table style="width: 100%; border-collapse: collapse;">
        <tr>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Username:</strong></td>
            <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.username }}</td>
        </tr>
        <tr>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
            <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.email|default:"Not set" }}</td>
        </tr>
        <tr>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Date Joined:</strong></td>
            <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ user.date_joined }}</td>
        </tr>
        <tr>
            <td style="padding: 10px;"><strong>Staff Status:</strong></td>
            <td style="padding: 10px;">{{ user.is_staff|yesno:"Yes,No" }}</td>
        </tr>
    </table>
</div>

<div class="card">
    <h2>Error tracking demo</h2>
    <p>Click the buttons below to trigger different types of errors. These errors are caught and sent to PostHog.</p>

    <div style="margin-top: 20px;">
        <button class="danger" onclick="triggerError('value')">
            Trigger ValueError
        </button>
        <button class="danger" onclick="triggerError('key')" style="margin-left: 10px;">
            Trigger KeyError
        </button>
        <button class="danger" onclick="triggerError('generic')" style="margin-left: 10px;">
            Trigger Generic Error
        </button>
    </div>

    <div id="error-result" style="margin-top: 20px; display: none;"></div>
</div>

<div class="card">
    <h3>How error tracking works</h3>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code>import posthog

try:
    risky_operation()
except Exception as e:
    posthog.capture_exception(e)</code></pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function triggerError(errorType) {
    const resultDiv = document.getElementById('error-result');

    try {
        const response = await fetch('{% url "trigger_error" %}', {
            method: 'POST',
            headers: {
                'X-CSRFToken': '{{ csrf_token }}',
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: 'error_type=' + errorType,
        });

        const data = await response.json();

        resultDiv.style.display = 'block';
        if (data.success) {
            resultDiv.innerHTML = '<div class="message success">No error occurred</div>';
        } else {
            resultDiv.innerHTML = `
                <div class="message error">
                    <strong>Error captured:</strong> ${data.error}<br>
                    <small>${data.message}</small>
                </div>
            `;
        }
    } catch (error) {
        resultDiv.style.display = 'block';
        resultDiv.innerHTML = `<div class="message error">Request failed: ${error}</div>`;
    }
}
</script>
{% endblock %}

core/urls.py

"""
URL configuration for the core app.

This module defines all the URL patterns for the PostHog example views.
"""

from django.urls import path
from . import views

urlpatterns = [
    # Home login page
    path('', views.home_view, name='home'),

    # Authentication
    path('logout/', views.logout_view, name='logout'),

    # Dashboard with feature flags
    path('dashboard/', views.dashboard_view, name='dashboard'),

    # Burrito example for event tracking
    path('burrito/', views.burrito_view, name='burrito'),
    path('api/burrito/consider/', views.consider_burrito_view, name='consider_burrito'),

    # Profile with error tracking
    path('profile/', views.profile_view, name='profile'),
    path('api/trigger-error/', views.trigger_error_view, name='trigger_error'),

    # Group analytics example
    path('api/group-analytics/', views.group_analytics_view, name='group_analytics'),
]

core/views.py

"""Django views demonstrating PostHog integration patterns"""

import posthog
from posthog import capture
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST


def home_view(request):
    """Home page with login functionality"""
    if request.user.is_authenticated:
        return redirect('dashboard')

    if request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')

        user = authenticate(request, username=username, password=password)

        if user is not None:
            login(request, user)

            # PostHog: the user_logged_in signal (core/signals.py) has identified
            # this request's context, so a plain capture is attributed.
            capture('user_logged_in', properties={
                'login_method': 'email',
            })

            return redirect('dashboard')
        else:
            messages.error(request, 'Invalid username or password')

    return render(request, 'core/home.html')


def logout_view(request):
    """Logout the current user"""
    if request.user.is_authenticated:
        # PostHog: the middleware identified this request's context from the
        # still-authenticated user, so capture before calling logout().
        capture('user_logged_out')

        logout(request)

    return redirect('home')


@login_required
def dashboard_view(request):
    """Dashboard page with feature flag example"""
    user_id = str(request.user.id)

    # PostHog: the middleware already identified this request's context from the
    # logged-in user, so a plain capture is attributed to them.
    capture('dashboard_viewed', properties={
        'is_staff': request.user.is_staff,
    })

    # PostHog: Check feature flag
    show_new_feature = posthog.feature_enabled(
        'new-dashboard-feature',
        distinct_id=user_id,
        person_properties={
            'email': request.user.email,
            'is_staff': request.user.is_staff,
        }
    )

    # PostHog: Get feature flag payload
    feature_config = posthog.get_feature_flag_payload(
        'new-dashboard-feature',
        distinct_id=user_id,
    )

    context = {
        'show_new_feature': show_new_feature,
        'feature_config': feature_config,
    }

    return render(request, 'core/dashboard.html', context)


@login_required
def burrito_view(request):
    """Example page demonstrating event tracking"""
    count = request.session.get('burrito_count', 0)

    context = {
        'burrito_count': count,
    }

    return render(request, 'core/burrito.html', context)


@login_required
@require_POST
def consider_burrito_view(request):
    """API endpoint for tracking burrito considerations"""
    count = request.session.get('burrito_count', 0) + 1
    request.session['burrito_count'] = count

    # PostHog: Track custom event
    capture('burrito_considered', properties={
        'total_considerations': count,
    })

    return JsonResponse({
        'success': True,
        'count': count,
    })


@login_required
def profile_view(request):
    """Profile page with error tracking demonstration"""
    user_id = str(request.user.id)

    # PostHog: Track profile view
    capture('profile_viewed')

    context = {
        'user': request.user,
    }

    return render(request, 'core/profile.html', context)


@login_required
@require_POST
def trigger_error_view(request):
    """API endpoint that demonstrates error tracking"""
    try:
        error_type = request.POST.get('error_type', 'generic')

        if error_type == 'value':
            raise ValueError("Invalid value provided by user")
        elif error_type == 'key':
            data = {}
            _ = data['nonexistent_key']
        else:
            raise Exception("Something went wrong!")

    except Exception as e:
        # PostHog: Capture exception
        posthog.capture_exception(e)

        # PostHog: Track error trigger event
        capture('error_triggered', properties={
            'error_type': error_type,
            'error_message': str(e),
        })

        return JsonResponse({
            'success': False,
            'error': str(e),
            'message': 'Error has been captured by PostHog',
        }, status=400)

    return JsonResponse({'success': True})


@login_required
def group_analytics_view(request):
    """Example demonstrating group analytics"""
    user_id = str(request.user.id)

    # PostHog: Identify group
    posthog.group_identify(
        group_type='company',
        group_key='acme-corp',
        properties={
            'name': 'Acme Corporation',
            'plan': 'enterprise',
            'employee_count': 150,
        }
    )

    # PostHog: Capture event with group
    capture(
        'feature_used',
        properties={
            'feature_name': 'group_analytics',
        },
        groups={
            'company': 'acme-corp',
        }
    )

    return JsonResponse({
        'success': True,
        'message': 'Group analytics event captured',
    })

manage.py

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

posthog_example/init.py

# PostHog Django example project

posthog_example/asgi.py

"""
ASGI config for PostHog example project
"""

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')

application = get_asgi_application()

posthog_example/settings.py

"""Django settings for PostHog example project"""

import os
from pathlib import Path

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-example-key-change-in-production')

DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'

ALLOWED_HOSTS = ['localhost', '127.0.0.1']


# PostHog configuration
POSTHOG_PROJECT_TOKEN = os.environ.get('POSTHOG_PROJECT_TOKEN', '<ph_project_token>')
POSTHOG_HOST = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
POSTHOG_DISABLED = os.environ.get('POSTHOG_DISABLED', 'False').lower() == 'true'


INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'core.apps.CoreConfig',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'posthog.integrations.django.PosthogContextMiddleware',
]

ROOT_URLCONF = 'posthog_example.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'posthog_example.wsgi.application'

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

AUTH_PASSWORD_VALIDATORS = [
    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]

LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True

STATIC_URL = 'static/'

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

posthog_example/urls.py

"""
URL configuration for PostHog example project
"""

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    # Include the core app URLs for PostHog examples
    path('', include('core.urls')),
]

posthog_example/wsgi.py

"""
WSGI config for PostHog example project
"""

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'posthog_example.settings')

application = get_wsgi_application()

requirements.txt

Django>=4.2,<5.0
posthog  # Always use latest version
python-dotenv>=1.0.0

references/EXAMPLE-expo.md

PostHog expo Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/expo


README.md

Burrito Consideration App (Expo)

A React Native Expo app demonstrating PostHog product analytics integration with modern React Native best practices.

Features

  • Product Analytics: Full PostHog integration with event tracking
  • Autocapture: Touch events and screen tracking
  • Error Tracking: Manual exception capture with captureException
  • User Authentication: Demo login with PostHog user identification
  • Session Persistence: AsyncStorage for session management
  • Modern React: React 19 with React Compiler for automatic memoization
  • File-based Routing: Expo Router for navigation
  • New Architecture: Enabled by default for better performance

Project Structure

basics/expo/
├── app/                          # Expo Router screens (file-based routing)
│   ├── _layout.tsx               # Root layout with PostHogProvider + AuthProvider
│   ├── index.tsx                 # Home screen (login/welcome)
│   ├── burrito.tsx               # Burrito consideration screen
│   └── profile.tsx               # User profile screen
├── src/
│   ├── config/
│   │   └── posthog.ts            # PostHog client configuration
│   ├── contexts/
│   │   └── AuthContext.tsx       # Authentication context with PostHog
│   ├── services/
│   │   └── storage.ts            # AsyncStorage wrapper
│   └── styles/
│       └── theme.ts              # Shared style constants
├── app.json                      # Expo configuration
├── babel.config.js               # Babel config with React Compiler
├── eslint.config.js              # ESLint flat config
├── package.json                  # Dependencies
├── tsconfig.json                 # TypeScript strict configuration
└── .env.example                  # Environment variables template

Getting Started

Prerequisites
  • Node.js 18+
  • iOS: Xcode (for iOS Simulator)
  • Android: Android Studio with emulator

For Android builds: Set environment variables (required):

Add to ~/.zshrc or ~/.bashrc:

# Java from Android Studio (required for Gradle)
export JAVA_HOME="<path-to-android-studio-jdk>"

# Android SDK location
export ANDROID_HOME="$HOME/Library/Android/sdk"

Examples:

  • export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
  • export ANDROID_HOME="$HOME/Library/Android/sdk"

Then run source ~/.zshrc to apply.

Installation
  1. Install dependencies:

    cd basics/expo
    npm install
  2. Configure PostHog (optional):

    cp .env.example .env
    # Edit .env with your PostHog project token
  3. Start the development server:

    npx expo start
Running the App
# Start development server
npx expo start

# Run on iOS Simulator
npx expo run:ios

# Run on Android Emulator
npx expo run:android

PostHog Integration

Configuration

PostHog is configured in src/config/posthog.ts using environment variables from app.json:

import Constants from 'expo-constants'

const projectToken = Constants.expoConfig?.extra?.posthogProjectToken
Event Tracking

Events are captured with properties:

posthog.capture('burrito_considered', {
  total_considerations: count,
  username: user.username,
})
User Identification

Users are identified on login:

posthog.identify(username, {
  $set: { username },
  $set_once: { first_login_date: new Date().toISOString() },
})
Screen Tracking

Manual screen tracking with Expo Router:

useEffect(() => {
  posthog.screen(pathname, {
    previous_screen: previousPathname.current,
  })
}, [pathname])
Error Tracking

Manual exception capture:

posthog.captureException(error)

Modern React Features

React Compiler

Automatic memoization is enabled via babel-plugin-react-compiler. No need for manual useMemo, useCallback, or React.memo.

React 19 use API

The useAuth hook uses the new use API for context:

export function useAuth() {
  const context = use(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}
New Architecture

Enabled in app.json for better performance:

{
  "expo": {
    "newArchEnabled": true
  }
}

Building for Production

Use EAS Build for production builds:

# Install EAS CLI
npm install -g eas-cli

# Configure EAS
eas build:configure

# Build for iOS
eas build --platform ios

# Build for Android
eas build --platform android

Performance Debugging

  1. Press J in Expo CLI to open Chrome DevTools
  2. Go to: Profiler > [Gear icon] > "Highlight updates when components render"
  3. Interact with your app to see which components re-render

Tech Stack

  • Expo SDK 54 - Managed workflow
  • React 19 - Latest React with Compiler support
  • React Native 0.81 - Latest stable
  • Expo Router 6 - File-based navigation
  • PostHog - Product analytics
  • TypeScript - Strict mode enabled
  • React Native Reanimated - Smooth animations
  • React Native Gesture Handler - Native gestures

License

MIT


.env.example

POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

.npmrc

legacy-peer-deps=true
min-release-age=7

app.config.js

export default {
  expo: {
    name: 'BurritoApp',
    slug: 'burrito-app',
    version: '1.0.0',
    orientation: 'portrait',
    icon: './assets/icon.png',
    userInterfaceStyle: 'light',
    newArchEnabled: true,
    experiments: {
      reactCompiler: true,
    },
    splash: {
      image: './assets/splash-icon.png',
      resizeMode: 'contain',
      backgroundColor: '#333333',
    },
    ios: {
      supportsTablet: true,
      bundleIdentifier: 'com.posthog.burritoapp',
    },
    android: {
      adaptiveIcon: {
        foregroundImage: './assets/adaptive-icon.png',
        backgroundColor: '#333333',
      },
      package: 'com.posthog.burritoapp',
      edgeToEdgeEnabled: true,
    },
    web: {
      favicon: './assets/favicon.png',
    },
    scheme: 'burritoapp',
    extra: {
      posthogProjectToken: process.env.POSTHOG_PROJECT_TOKEN,
      posthogHost: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',
    },
    plugins: ['expo-router', 'expo-localization'],
  },
}

app/_layout.tsx

import { Stack, usePathname, useGlobalSearchParams } from 'expo-router'
import { useEffect, useRef } from 'react'
import { StatusBar } from 'expo-status-bar'
import { PostHogProvider } from 'posthog-react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'

import { AuthProvider } from '../src/contexts/AuthContext'
import { posthog } from '../src/config/posthog'
import { colors } from '../src/styles/theme'

export default function RootLayout() {
  const pathname = usePathname()
  const params = useGlobalSearchParams()
  const previousPathname = useRef<string | undefined>(undefined)

  // Manual screen tracking for Expo Router
  // @see https://docs.expo.dev/router/reference/screen-tracking/
  // React Compiler will auto-optimize this effect
  useEffect(() => {
    if (previousPathname.current !== pathname) {
      posthog.screen(pathname, {
        previous_screen: previousPathname.current ?? null,
        // Include route params for analytics (filter sensitive data if needed)
        ...params,
      })
      previousPathname.current = pathname
    }
  }, [pathname, params])

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <SafeAreaProvider>
        <StatusBar style="light" backgroundColor={colors.headerBackground} />
        <PostHogProvider
          client={posthog}
          autocapture={{
            captureScreens: false, // Manual tracking with Expo Router
            captureTouches: true,
            propsToCapture: ['testID'],
            maxElementsCaptured: 20,
          }}
        >
          <AuthProvider>
            <Stack
              screenOptions={{
                headerStyle: { backgroundColor: colors.headerBackground },
                headerTintColor: colors.headerText,
                headerTitleStyle: { fontWeight: 'bold' },
                animation: 'slide_from_right',
              }}
            >
              <Stack.Screen name="index" options={{ title: 'Burrito App' }} />
              <Stack.Screen name="burrito" options={{ title: 'Burrito Consideration' }} />
              <Stack.Screen name="profile" options={{ title: 'Profile' }} />
            </Stack>
          </AuthProvider>
        </PostHogProvider>
      </SafeAreaProvider>
    </GestureHandlerRootView>
  )
}

app/burrito.tsx

import { useState, useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { useRouter } from 'expo-router'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'

/**
 * Burrito Consideration Screen
 *
 * Demonstrates PostHog event tracking with custom properties.
 * Each time the user considers a burrito, an event is captured.
 *
 * @see https://posthog.com/docs/libraries/react-native#capturing-events
 */
export default function BurritoScreen() {
  const { user, incrementBurritoConsiderations } = useAuth()
  const router = useRouter()
  const posthog = usePostHog()
  const [hasConsidered, setHasConsidered] = useState(false)

  // Redirect to home if not logged in
  useEffect(() => {
    if (!user) {
      router.replace('/')
    }
  }, [user, router])

  if (!user) {
    return null
  }

  const handleConsideration = async () => {
    const newCount = user.burritoConsiderations + 1

    // Update state first for immediate feedback
    await incrementBurritoConsiderations()
    setHasConsidered(true)

    // Hide success message after 2 seconds
    setTimeout(() => setHasConsidered(false), 2000)

    // Capture custom event in PostHog with properties
    // We recommend using a [object] [verb] format for event names
    // @see https://posthog.com/docs/libraries/react-native#capturing-events
    posthog.capture('burrito_considered', {
      total_considerations: newCount,
      username: user.username,
    })
  }

  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Text style={styles.title}>Burrito Consideration Zone</Text>
        <Text style={styles.text}>
          Take a moment to truly consider the potential of burritos.
        </Text>

        {/*
          testID is captured by PostHog autocapture for touch events
          This helps identify the button in analytics
          @see https://posthog.com/docs/libraries/react-native#autocapture
        */}
        <TouchableOpacity
          style={styles.burritoButton}
          onPress={handleConsideration}
          activeOpacity={0.8}
          testID="consider-burrito-button"
        >
          <Text style={styles.burritoButtonText}>Consider Burrito</Text>
        </TouchableOpacity>

        {hasConsidered && (
          <View style={styles.successContainer}>
            <Text style={styles.success}>Thank you for your consideration!</Text>
            <Text style={styles.successCount}>Count: {user.burritoConsiderations}</Text>
          </View>
        )}

        <View style={styles.stats}>
          <Text style={styles.statsTitle}>Consideration Stats</Text>
          <Text style={styles.statsText}>Total considerations: {user.burritoConsiderations}</Text>
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
    padding: spacing.md,
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  text: {
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.lg,
    lineHeight: 24,
  },
  burritoButton: {
    backgroundColor: colors.burrito,
    borderRadius: borderRadius.sm,
    padding: spacing.lg,
    alignItems: 'center',
    marginVertical: spacing.md,
    ...shadows.sm,
  },
  burritoButtonText: {
    color: colors.white,
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.bold,
  },
  successContainer: {
    alignItems: 'center',
    marginVertical: spacing.sm,
  },
  success: {
    color: colors.success,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.medium,
  },
  successCount: {
    color: colors.success,
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.bold,
    marginTop: spacing.xs,
  },
  stats: {
    backgroundColor: colors.statsBackground,
    padding: spacing.md,
    borderRadius: borderRadius.sm,
    marginTop: spacing.lg,
  },
  statsTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.xs,
  },
  statsText: {
    fontSize: typography.sizes.md,
    color: colors.text,
  },
})

app/index.tsx

import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  KeyboardAvoidingView,
  Platform,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'

export default function HomeScreen() {
  const { user, login, logout } = useAuth()
  const router = useRouter()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleSubmit = async () => {
    setError('')

    if (!username.trim() || !password.trim()) {
      setError('Please provide both username and password')
      return
    }

    setIsSubmitting(true)
    try {
      const success = await login(username, password)
      if (success) {
        setUsername('')
        setPassword('')
      } else {
        setError('An error occurred during login')
      }
    } catch {
      setError('An error occurred during login')
    } finally {
      setIsSubmitting(false)
    }
  }

  // Logged in view
  if (user) {
    return (
      <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
        <View style={styles.card}>
          <Text style={styles.title}>Welcome back, {user.username}!</Text>
          <Text style={styles.text}>You are logged in. Feel free to explore:</Text>

          <View style={styles.buttonGroup}>
            <TouchableOpacity
              style={[styles.button, styles.burritoButton]}
              onPress={() => router.push('/burrito')}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>Consider Burritos</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[styles.button, styles.primaryButton]}
              onPress={() => router.push('/profile')}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>View Profile</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[styles.button, styles.logoutButton]}
              onPress={logout}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>Logout</Text>
            </TouchableOpacity>
          </View>
        </View>
      </ScrollView>
    )
  }

  // Login view
  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
      >
        <View style={styles.card}>
          <Text style={styles.title}>Welcome to Burrito Consideration App</Text>
          <Text style={styles.text}>Please sign in to begin your burrito journey</Text>

          <View style={styles.form}>
            <Text style={styles.label}>Username:</Text>
            <TextInput
              style={styles.input}
              value={username}
              onChangeText={setUsername}
              placeholder="Enter any username"
              placeholderTextColor={colors.textLight}
              autoCapitalize="none"
              autoCorrect={false}
              autoComplete="username"
              editable={!isSubmitting}
            />

            <Text style={styles.label}>Password:</Text>
            <TextInput
              style={styles.input}
              value={password}
              onChangeText={setPassword}
              placeholder="Enter any password"
              placeholderTextColor={colors.textLight}
              secureTextEntry
              autoComplete="password"
              editable={!isSubmitting}
            />

            {error ? <Text style={styles.error}>{error}</Text> : null}

            <TouchableOpacity
              style={[styles.button, styles.primaryButton, isSubmitting && styles.buttonDisabled]}
              onPress={handleSubmit}
              disabled={isSubmitting}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>{isSubmitting ? 'Signing In...' : 'Sign In'}</Text>
            </TouchableOpacity>
          </View>

          <Text style={styles.note}>
            Note: This is a demo app. Use any username and password to sign in.
          </Text>
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
  },
  scrollView: {
    flex: 1,
    backgroundColor: colors.background,
  },
  scrollContent: {
    flexGrow: 1,
    padding: spacing.md,
    justifyContent: 'center',
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  text: {
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.md,
    lineHeight: 24,
  },
  form: {
    marginTop: spacing.md,
  },
  label: {
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.medium,
    color: colors.text,
    marginBottom: spacing.xs,
  },
  input: {
    backgroundColor: colors.inputBackground,
    borderWidth: 1,
    borderColor: colors.border,
    borderRadius: borderRadius.sm,
    padding: spacing.sm,
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.md,
  },
  buttonGroup: {
    marginTop: spacing.md,
    gap: spacing.sm,
  },
  button: {
    borderRadius: borderRadius.sm,
    padding: spacing.md,
    alignItems: 'center',
    marginTop: spacing.sm,
  },
  primaryButton: {
    backgroundColor: colors.primary,
  },
  burritoButton: {
    backgroundColor: colors.burrito,
  },
  logoutButton: {
    backgroundColor: colors.danger,
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  buttonText: {
    color: colors.white,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.semibold,
  },
  error: {
    color: colors.danger,
    marginBottom: spacing.sm,
    fontSize: typography.sizes.sm,
  },
  note: {
    marginTop: spacing.lg,
    color: colors.textSecondary,
    fontSize: typography.sizes.sm,
    textAlign: 'center',
    lineHeight: 20,
  },
})

app/profile.tsx

import { useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../src/contexts/AuthContext'
import { colors, spacing, typography, borderRadius, shadows } from '../src/styles/theme'

/**
 * Profile Screen
 *
 * Displays user information and demonstrates PostHog error tracking.
 * The test error button shows how to capture exceptions manually.
 *
 * @see https://posthog.com/docs/libraries/react-native#error-tracking
 */
export default function ProfileScreen() {
  const { user } = useAuth()
  const router = useRouter()
  const posthog = usePostHog()

  // Redirect to home if not logged in
  useEffect(() => {
    if (!user) {
      router.replace('/')
    }
  }, [user, router])

  if (!user) {
    return null
  }

  /**
   * Triggers a test error and captures it in PostHog
   *
   * This demonstrates manual exception capture via captureException.
   * In production, you would typically set up automatic exception capture
   * or use the before_send callback for customization.
   *
   * @see https://posthog.com/docs/libraries/react-native#error-tracking
   */
  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking')
    } catch (err) {
      const error = err as Error

      // @see https://posthog.com/docs/error-tracking
      posthog.captureException(error, {
        username: user.username,
        screen: 'Profile',
      })

      console.error('Captured error:', error)
      Alert.alert('Error Captured', 'The test error has been sent to PostHog!', [{ text: 'OK' }])
    }
  }

  const getJourneyMessage = () => {
    const count = user.burritoConsiderations
    if (count === 0) {
      return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
    } else if (count === 1) {
      return "You've considered the burrito potential once. Keep going!"
    } else if (count < 5) {
      return "You're getting the hang of burrito consideration!"
    } else if (count < 10) {
      return "You're becoming a burrito consideration expert!"
    } else {
      return 'You are a true burrito consideration master!'
    }
  }

  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Text style={styles.title}>User Profile</Text>

        <View style={styles.stats}>
          <Text style={styles.statsTitle}>Your Information</Text>
          <View style={styles.infoRow}>
            <Text style={styles.infoLabel}>Username:</Text>
            <Text style={styles.infoValue}>{user.username}</Text>
          </View>
          <View style={styles.infoRow}>
            <Text style={styles.infoLabel}>Burrito Considerations:</Text>
            <Text style={styles.infoValue}>{user.burritoConsiderations}</Text>
          </View>
        </View>

        {/*
          testID is captured by PostHog autocapture for touch events
          @see https://posthog.com/docs/libraries/react-native#autocapture
        */}
        <TouchableOpacity
          style={styles.errorButton}
          onPress={triggerTestError}
          activeOpacity={0.8}
          testID="trigger-error-button"
        >
          <Text style={styles.buttonText}>Trigger Test Error (for PostHog)</Text>
        </TouchableOpacity>

        <View style={styles.journey}>
          <Text style={styles.journeyTitle}>Your Burrito Journey</Text>
          <Text style={styles.journeyText}>{getJourneyMessage()}</Text>
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
    padding: spacing.md,
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.md,
  },
  stats: {
    backgroundColor: colors.statsBackground,
    padding: spacing.md,
    borderRadius: borderRadius.sm,
  },
  statsTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  infoRow: {
    flexDirection: 'row',
    marginBottom: spacing.xs,
  },
  infoLabel: {
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginRight: spacing.xs,
  },
  infoValue: {
    fontSize: typography.sizes.md,
    color: colors.text,
  },
  errorButton: {
    backgroundColor: colors.danger,
    borderRadius: borderRadius.sm,
    padding: spacing.md,
    alignItems: 'center',
    marginTop: spacing.lg,
  },
  buttonText: {
    color: colors.white,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.semibold,
  },
  journey: {
    marginTop: spacing.lg,
  },
  journeyTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  journeyText: {
    fontSize: typography.sizes.md,
    color: colors.text,
    lineHeight: 24,
  },
})

babel.config.js

module.exports = function (api) {
  api.cache(true)
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      ['babel-plugin-react-compiler'],
      'react-native-reanimated/plugin', // Must be last
    ],
  }
}

src/config/posthog.ts

import PostHog from 'posthog-react-native'
import Constants from 'expo-constants'

// Configuration loaded from app.config.js extras via expo-constants
// Environment variables are read at build time in app.config.js
const projectToken = Constants.expoConfig?.extra?.posthogProjectToken as string | undefined
const host = (Constants.expoConfig?.extra?.posthogHost as string) || 'https://us.i.posthog.com'
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'

if (__DEV__) {
  console.log('PostHog config:', {
    projectToken: projectToken ? `SET` : 'NOT SET',
    host,
    isConfigured: isPostHogConfigured,
  })
}

if (!isPostHogConfigured) {
  console.warn(
    'PostHog project token not configured. Analytics will be disabled. ' +
      'Set POSTHOG_PROJECT_TOKEN in your .env file to enable analytics.'
  )
}

/**
 * PostHog client instance for Expo
 *
 * Configuration loaded from app.config.js extras via expo-constants.
 * Required peer dependencies: expo-file-system, expo-application,
 * expo-device, expo-localization
 *
 * For React Native Web targets, use @react-native-async-storage/async-storage
 * instead of expo-file-system (Web and macOS targets not supported by expo-file-system).
 *
 * @see https://posthog.com/docs/libraries/react-native
 */
export const posthog = new PostHog(projectToken || 'placeholder_key', {
  // PostHog API host
  host,

  // Enable PostHog only when a project token is configured
  disabled: !isPostHogConfigured,

  // Capture app lifecycle events:
  // - Application Installed, Application Updated
  // - Application Opened, Application Became Active, Application Backgrounded
  captureAppLifecycleEvents: true,

  // Enable debug mode in development for verbose logging
  debug: __DEV__,

  // Batching: queue events and flush periodically to optimize battery usage
  flushAt: 20,              // Number of events to queue before sending
  flushInterval: 10000,     // Interval in ms between periodic flushes
  maxBatchSize: 100,        // Maximum events per batch
  maxQueueSize: 1000,       // Maximum queued events (oldest dropped when full)

  // Feature flags
  preloadFeatureFlags: true,        // Load flags on initialization
  sendFeatureFlagEvent: true,       // Track getFeatureFlag calls for experiments
  featureFlagsRequestTimeoutMs: 10000, // Timeout for flag requests (prevents blocking)

  // Network settings
  requestTimeout: 10000,    // General request timeout in ms
  fetchRetryCount: 3,       // Number of retry attempts for failed requests
  fetchRetryDelay: 3000,    // Delay between retries in ms
})

export const isPostHogEnabled = isPostHogConfigured

src/contexts/AuthContext.tsx

import React, { createContext, useState, useEffect, use } from 'react'
import type { ReactNode } from 'react'
import { usePostHog } from 'posthog-react-native'
import { storage } from '../services/storage'
import type { User } from '../services/storage'

interface AuthContextType {
  user: User | null
  isLoading: boolean
  login: (username: string, password: string) => Promise<boolean>
  logout: () => Promise<void>
  incrementBurritoConsiderations: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

interface AuthProviderProps {
  children: ReactNode
}

export function AuthProvider({ children }: AuthProviderProps) {
  const posthog = usePostHog()
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    const restoreSession = async () => {
      try {
        const storedUsername = await storage.getCurrentUser()
        if (storedUsername) {
          const existingUser = await storage.getUser(storedUsername)
          if (existingUser) {
            setUser(existingUser)
            posthog.identify(storedUsername, {
              $set: { username: storedUsername },
            })
          }
        }
      } catch (error) {
        console.error('Failed to restore session:', error)
      } finally {
        setIsLoading(false)
      }
    }
    restoreSession()
  }, [posthog])

  // React Compiler auto-memoizes these callbacks - no useCallback needed!
  const login = async (username: string, password: string): Promise<boolean> => {
    if (!username.trim() || !password.trim()) {
      return false
    }

    try {
      const existingUser = await storage.getUser(username)
      const isNewUser = !existingUser

      const userData: User = existingUser || {
        username,
        burritoConsiderations: 0,
      }

      await storage.saveUser(userData)
      await storage.setCurrentUser(username)
      setUser(userData)

      posthog.identify(username, {
        $set: { username },
        $set_once: { first_login_date: new Date().toISOString() },
      })

      posthog.capture('user_logged_in', {
        username,
        is_new_user: isNewUser,
      })

      return true
    } catch (error) {
      console.error('Login error:', error)
      return false
    }
  }

  const logout = async () => {
    posthog.capture('user_logged_out')
    posthog.reset()
    await storage.removeCurrentUser()
    setUser(null)
  }

  const incrementBurritoConsiderations = async () => {
    if (user) {
      const updatedUser: User = {
        ...user,
        burritoConsiderations: user.burritoConsiderations + 1,
      }
      setUser(updatedUser)
      await storage.saveUser(updatedUser)
    }
  }

  return (
    <AuthContext
      value={{
        user,
        isLoading,
        login,
        logout,
        incrementBurritoConsiderations,
      }}
    >
      {children}
    </AuthContext>
  )
}

/**
 * React 19: Use the `use` API instead of useContext
 * - Can be called conditionally (unlike useContext)
 * - Enables more flexible component composition
 */
export function useAuth() {
  const context = use(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

src/services/storage.ts

import AsyncStorage from '@react-native-async-storage/async-storage'

const CURRENT_USER_KEY = 'currentUser'
const USERS_KEY = 'users'

export interface User {
  username: string
  burritoConsiderations: number
}

/**
 * Storage service for persisting user data
 * Uses AsyncStorage (React Native's async key-value storage)
 */
export const storage = {
  /**
   * Get the currently logged in user's username
   */
  getCurrentUser: async (): Promise<string | null> => {
    try {
      return await AsyncStorage.getItem(CURRENT_USER_KEY)
    } catch (error) {
      console.error('Error getting current user:', error)
      return null
    }
  },

  /**
   * Set the currently logged in user's username
   */
  setCurrentUser: async (username: string): Promise<void> => {
    try {
      await AsyncStorage.setItem(CURRENT_USER_KEY, username)
    } catch (error) {
      console.error('Error setting current user:', error)
    }
  },

  /**
   * Remove the current user (logout)
   */
  removeCurrentUser: async (): Promise<void> => {
    try {
      await AsyncStorage.removeItem(CURRENT_USER_KEY)
    } catch (error) {
      console.error('Error removing current user:', error)
    }
  },

  /**
   * Get all stored users
   */
  getUsers: async (): Promise<Record<string, User>> => {
    try {
      const data = await AsyncStorage.getItem(USERS_KEY)
      return data ? JSON.parse(data) : {}
    } catch (error) {
      console.error('Error getting users:', error)
      return {}
    }
  },

  /**
   * Get a specific user by username
   */
  getUser: async (username: string): Promise<User | null> => {
    try {
      const users = await storage.getUsers()
      return users[username] || null
    } catch (error) {
      console.error('Error getting user:', error)
      return null
    }
  },

  /**
   * Save a user to storage
   */
  saveUser: async (user: User): Promise<void> => {
    try {
      const users = await storage.getUsers()
      users[user.username] = user
      await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users))
    } catch (error) {
      console.error('Error saving user:', error)
    }
  },

  /**
   * Clear all stored data (for testing/debugging)
   */
  clearAll: async (): Promise<void> => {
    try {
      await AsyncStorage.multiRemove([CURRENT_USER_KEY, USERS_KEY])
    } catch (error) {
      console.error('Error clearing storage:', error)
    }
  },
}

src/styles/theme.ts

/**
 * Theme constants for consistent styling across the app
 * Matches the color scheme from the TanStack Start web version
 */

export const colors = {
  // Primary colors
  primary: '#0070f3',
  primaryDark: '#0051cc',

  // Status colors
  success: '#28a745',
  successDark: '#218838',
  danger: '#dc3545',
  dangerDark: '#c82333',

  // Feature colors
  burrito: '#e07c24',
  burritoDark: '#c96a1a',

  // Neutral colors
  background: '#f5f5f5',
  white: '#ffffff',
  text: '#333333',
  textSecondary: '#666666',
  textLight: '#999999',
  border: '#dddddd',
  borderLight: '#eeeeee',

  // Component-specific
  statsBackground: '#f8f9fa',
  headerBackground: '#333333',
  headerText: '#ffffff',
  inputBackground: '#ffffff',
  cardBackground: '#ffffff',
}

export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,
}

export const typography = {
  sizes: {
    xs: 12,
    sm: 14,
    md: 16,
    lg: 18,
    xl: 24,
    xxl: 32,
  },
  weights: {
    normal: '400' as const,
    medium: '500' as const,
    semibold: '600' as const,
    bold: '700' as const,
  },
}

export const borderRadius = {
  sm: 4,
  md: 8,
  lg: 12,
  full: 9999,
}

export const shadows = {
  sm: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.05,
    shadowRadius: 2,
    elevation: 1,
  },
  md: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  lg: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 5,
  },
}

references/EXAMPLE-fastapi.md

PostHog fastapi Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/fastapi


README.md

PostHog FastAPI Example

A FastAPI application demonstrating PostHog integration for analytics, feature flags, and error tracking.

Features

  • User registration and authentication with cookie-based sessions
  • SQLite database persistence with SQLAlchemy
  • User identification and property tracking
  • Custom event tracking
  • Feature flags with payload support
  • Error tracking with manual exception capture

Quick Start

  1. Create and activate a virtual environment:

    python -m venv venv
    source venv/bin/activate  # On Windows: venv\Scripts\activate
  2. Install dependencies:

    pip install -r requirements.txt
  3. Copy the environment file and configure:

    cp .env.example .env
    # Edit .env with your PostHog project key
  4. Run the application:

    python run.py
  5. Open http://localhost:5002 and either:

    • Login with default credentials: admin@example.com / admin
    • Or click "Sign up here" to create a new account

PostHog Integration Points

User Registration

New users are identified and tracked on signup using the context-based API:

with new_context():
    identify_context(user.email)
    tag('email', user.email)
    tag('is_staff', user.is_staff)
    capture('user_signed_up', properties={'signup_method': 'form'})
User Identification

Users are identified on login with their properties:

with new_context():
    identify_context(user.email)
    tag('email', user.email)
    tag('is_staff', user.is_staff)
    capture('user_logged_in', properties={'login_method': 'password'})
Event Tracking

Custom events are captured throughout the app:

with new_context():
    identify_context(current_user.email)
    capture('burrito_considered', properties={'total_considerations': count})
Feature Flags

The dashboard demonstrates feature flag checking:

show_new_feature = posthog.feature_enabled(
    'new-dashboard-feature',
    current_user.email,
    person_properties={'email': current_user.email, 'is_staff': current_user.is_staff}
)
feature_config = posthog.get_feature_flag_payload('new-dashboard-feature', current_user.email)
Error Tracking

The example demonstrates two approaches to error tracking:

Manual capture for specific critical operations** (app/routers/api.py).

try:
    # Critical operation that might fail
    result = process_payment()
except Exception as e:
    # Manually capture this specific exception
    with new_context():
        identify_context(current_user.email)
        event_id = posthog.capture_exception(e)

    return JSONResponse({
        "error": "Operation failed",
        "error_id": event_id,
        "message": f"Error captured in PostHog. Reference ID: {event_id}"
    }, status_code=500)

The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.

Project Structure

basics/fastapi/
├── app/
│   ├── __init__.py              # Package marker
│   ├── config.py                # Pydantic Settings configuration
│   ├── database.py              # SQLAlchemy setup
│   ├── dependencies.py          # FastAPI dependency injection
│   ├── main.py                  # Application factory and lifespan
│   ├── models.py                # User model (SQLAlchemy)
│   ├── routers/
│   │   ├── __init__.py          # Routers package
│   │   ├── main.py              # Page routes (HTML)
│   │   └── api.py               # API endpoints (JSON)
│   └── templates/               # Jinja2 templates
├── .env.example
├── .gitignore
├── requirements.txt
├── README.md
└── run.py                       # Entry point (uvicorn)

.env.example

POSTHOG_PROJECT_TOKEN=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.com
SECRET_KEY=your-secret-key-here
DEBUG=True
POSTHOG_DISABLED=False

app/init.py

"""FastAPI PostHog example application."""

app/config.py

"""FastAPI application configuration using Pydantic Settings."""

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Application settings loaded from environment variables."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # Application
    secret_key: str = "dev-secret-key-change-in-production"
    debug: bool = True

    # Database (SQLite like Flask example)
    database_url: str = "sqlite:///./db.sqlite3"

    # PostHog
    posthog_project_token: str = "<ph_project_token>"
    posthog_host: str = "https://us.i.posthog.com"
    posthog_disabled: bool = False


@lru_cache
def get_settings() -> Settings:
    """Get cached settings instance."""
    return Settings()

app/database.py

"""Database configuration with SQLAlchemy."""

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

from app.config import get_settings

settings = get_settings()

engine = create_engine(
    settings.database_url,
    connect_args={"check_same_thread": False},  # Required for SQLite
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    """Base class for SQLAlchemy models."""

    pass


def get_db():
    """Dependency that provides a database session."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


def init_db():
    """Create all database tables."""
    Base.metadata.create_all(bind=engine)

app/dependencies.py

"""Authentication dependencies for FastAPI."""

from typing import Annotated, Optional

from fastapi import Cookie, Depends, HTTPException, status
from itsdangerous import BadSignature, URLSafeSerializer
from sqlalchemy.orm import Session

from app.config import get_settings
from app.database import get_db
from app.models import User

settings = get_settings()
serializer = URLSafeSerializer(settings.secret_key)


def get_session_user_id(session_token: Annotated[Optional[str], Cookie()] = None) -> Optional[int]:
    """Extract user ID from session cookie."""
    if not session_token:
        return None
    try:
        data = serializer.loads(session_token)
        return data.get("user_id")
    except BadSignature:
        return None


def get_current_user(
    db: Annotated[Session, Depends(get_db)],
    user_id: Annotated[Optional[int], Depends(get_session_user_id)],
) -> Optional[User]:
    """Get the current authenticated user, or None if not authenticated."""
    if user_id is None:
        return None
    return User.get_by_id(db, user_id)


def require_auth(
    current_user: Annotated[Optional[User], Depends(get_current_user)],
) -> User:
    """Require authentication - raises 401 if not authenticated."""
    if current_user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Authentication required",
        )
    return current_user


def create_session_token(user_id: int) -> str:
    """Create a signed session token for the user."""
    return serializer.dumps({"user_id": user_id})


# Type aliases for cleaner dependency injection
CurrentUser = Annotated[Optional[User], Depends(get_current_user)]
RequiredUser = Annotated[User, Depends(require_auth)]
DbSession = Annotated[Session, Depends(get_db)]

app/main.py

"""FastAPI application with PostHog integration."""

from contextlib import asynccontextmanager
from pathlib import Path

import posthog
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from app.config import get_settings
from app.database import SessionLocal, init_db
from app.middleware import PostHogMiddleware
from app.models import User
from app.routers import api, main

settings = get_settings()

# Setup templates
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan events for startup/shutdown."""
    # Startup: Initialize PostHog
    if not settings.posthog_disabled:
        posthog.api_key = settings.posthog_project_token
        posthog.host = settings.posthog_host
        posthog.debug = settings.debug

    # Initialize database and seed default user
    init_db()
    db = SessionLocal()
    try:
        if not User.get_by_email(db, "admin@example.com"):
            User.create_user(
                db,
                email="admin@example.com",
                password="admin",
                is_staff=True,
            )
    finally:
        db.close()

    yield

    # Shutdown: Flush PostHog events
    if not settings.posthog_disabled:
        posthog.flush()


app = FastAPI(
    title="PostHog FastAPI Example",
    description="Example application demonstrating PostHog integration with FastAPI",
    lifespan=lifespan,
)

app.add_middleware(PostHogMiddleware)

# Include routers
app.include_router(main.router)
app.include_router(api.router, prefix="/api")


# Error handlers
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
    """Handle 404 errors."""
    if request.url.path.startswith("/api/"):
        return JSONResponse({"error": "Not found"}, status_code=404)
    return templates.TemplateResponse(
        request, "errors/404.html", status_code=404
    )


@app.exception_handler(500)
async def internal_error_handler(request: Request, exc):
    """Handle 500 errors."""
    if request.url.path.startswith("/api/"):
        return JSONResponse({"error": "Internal server error"}, status_code=500)
    return templates.TemplateResponse(
        request, "errors/500.html", status_code=500
    )

app/middleware.py

"""PostHog middleware for automatic context and user identification.

Uses pure ASGI middleware instead of BaseHTTPMiddleware for better performance/best practices.
"""

from http.cookies import SimpleCookie
from typing import Callable, Optional

from posthog import identify_context, new_context, tag

from app.config import get_settings
from app.database import SessionLocal
from app.dependencies import serializer
from app.models import User


class PostHogMiddleware:
    """Pure ASGI middleware that wraps each request in a PostHog context.

    If the user is authenticated, identifies them in the context so routes
    can just call capture() without needing to set up context each time.

    Uses pure ASGI interface for better performance than BaseHTTPMiddleware.
    """

    def __init__(self, app):
        self.app = app
        self.settings = get_settings()

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http" or self.settings.posthog_disabled:
            await self.app(scope, receive, send)
            return

        user = self._get_user_from_scope(scope)

        with new_context():
            if user:
                identify_context(str(user.id))
                tag("is_staff", user.is_staff)

            await self.app(scope, receive, send)

    def _get_user_from_scope(self, scope) -> Optional[User]:
        """Extract authenticated user from session cookie in ASGI scope."""
        headers = dict(scope.get("headers", []))
        cookie_header = headers.get(b"cookie", b"").decode("utf-8")

        if not cookie_header:
            return None

        cookies = SimpleCookie()
        cookies.load(cookie_header)

        session_cookie = cookies.get("session_token")
        if not session_cookie:
            return None

        session_token = session_cookie.value

        try:
            data = serializer.loads(session_token)
            user_id = data.get("user_id")
        except Exception:
            return None

        if not user_id:
            return None

        db = SessionLocal()
        try:
            return User.get_by_id(db, user_id)
        finally:
            db.close()

app/models.py

"""User model with SQLite persistence (similar to Flask example)."""

from datetime import datetime, timezone
from typing import Optional

from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, Session, mapped_column
from werkzeug.security import check_password_hash, generate_password_hash

from app.database import Base


class User(Base):
    """User model with SQLite persistence."""

    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email: Mapped[str] = mapped_column(String(254), unique=True, nullable=False)
    password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
    name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
    is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    login_count: Mapped[int] = mapped_column(Integer, default=0)
    date_joined: Mapped[datetime] = mapped_column(
        DateTime, default=lambda: datetime.now(timezone.utc)
    )

    def set_password(self, password: str) -> None:
        """Hash and set the user's password."""
        self.password_hash = generate_password_hash(password, method="pbkdf2:sha256")

    def check_password(self, password: str) -> bool:
        """Verify the password against the hash."""
        return check_password_hash(self.password_hash, password)

    @classmethod
    def create_user(
        cls, db: Session, email: str, password: str, is_staff: bool = False
    ) -> "User":
        """Create and save a new user."""
        user = cls(email=email, is_staff=is_staff)
        # nosemgrep: python.django.security.audit.unvalidated-password.unvalidated-password
        user.set_password(password)
        db.add(user)
        db.commit()
        db.refresh(user)
        return user

    @classmethod
    def get_by_id(cls, db: Session, user_id: int) -> Optional["User"]:
        """Get user by ID."""
        return db.query(cls).filter(cls.id == user_id).first()

    @classmethod
    def get_by_email(cls, db: Session, email: str) -> Optional["User"]:
        """Get user by email."""
        return db.query(cls).filter(cls.email == email).first()

    @classmethod
    def authenticate(cls, db: Session, email: str, password: str) -> Optional["User"]:
        """Authenticate user with email and password."""
        user = cls.get_by_email(db, email)
        if user and user.check_password(password):
            return user
        return None

    def record_login(self, db: Session) -> bool:
        """Record a login and return whether this is the user's first login."""
        is_first_login = self.login_count == 0
        self.login_count += 1
        db.commit()
        return is_first_login

    def update_profile(self, db: Session, name: Optional[str] = None) -> list:
        """Update user profile and return list of changed fields."""
        changed_fields = []
        if name is not None and name != self.name:
            self.name = name
            changed_fields.append("name")
        if changed_fields:
            db.commit()
        return changed_fields

    def __repr__(self) -> str:
        return f"<User {self.email}>"

app/routers/init.py

"""FastAPI routers package."""

app/routers/api.py

"""API endpoints demonstrating PostHog integration patterns."""

from typing import Annotated

import posthog
from fastapi import APIRouter, Cookie, Form, Query
from fastapi.responses import JSONResponse
from posthog import capture

from app.dependencies import RequiredUser

router = APIRouter()

MAX_BURRITO_COUNT = 10000


@router.post("/burrito/consider")
async def consider_burrito(
    current_user: RequiredUser,
    burrito_count: Annotated[int, Cookie()] = 0,
):
    """Track burrito consideration event."""
    safe_count = max(0, min(burrito_count, MAX_BURRITO_COUNT))
    new_count = safe_count + 1

    capture("burrito_considered", properties={"total_considerations": new_count})

    response = JSONResponse({"success": True, "count": new_count})
    response.set_cookie(
        key="burrito_count",
        value=str(new_count),
        httponly=True,
        samesite="lax",
    )
    return response


@router.post("/test-error")
async def test_error(
    current_user: RequiredUser,
    capture_param: Annotated[str, Query(alias="capture")] = "true",
):
    """Test endpoint demonstrating manual exception capture in PostHog."""
    should_capture = capture_param.lower() == "true"

    try:
        raise Exception("Test exception from critical operation")
    except Exception as e:
        if should_capture:
            event_id = posthog.capture_exception(e)
            return JSONResponse(
                {
                    "error": "Operation failed",
                    "error_id": event_id,
                    "message": f"Error captured in PostHog. Reference ID: {event_id}",
                },
                status_code=500,
            )
        else:
            return JSONResponse({"error": "Operation failed"}, status_code=500)


@router.post("/trigger-error")
async def trigger_error(
    current_user: RequiredUser,
    error_type: Annotated[str, Form()] = "generic",
):
    """Trigger different error types for testing error tracking."""
    error_messages = {
        "value": "Invalid value provided",
        "key": "Missing required key",
        "generic": "Generic test error",
    }

    safe_error_type = error_type if error_type in error_messages else "generic"
    error_message = error_messages[safe_error_type]

    try:
        if safe_error_type == "value":
            raise ValueError(error_message)
        elif safe_error_type == "key":
            raise KeyError("missing_key")
        else:
            raise Exception(error_message)
    except Exception as e:
        posthog.capture_exception(e)
        capture(
            "error_triggered",
            properties={"error_type": safe_error_type, "error_message": error_message},
        )

        return JSONResponse(
            {
                "success": True,
                "message": "Error captured in PostHog",
                "error": error_message,
            }
        )


@router.post("/reports/activity")
async def generate_activity_report(
    current_user: RequiredUser,
    report_type: Annotated[str, Form()] = "summary",
):
    """Generate user activity report."""
    valid_report_types = {"summary", "detailed", "export"}
    safe_report_type = report_type if report_type in valid_report_types else "summary"

    report_data = {
        "user": current_user.email,
        "name": current_user.name,
        "date_joined": current_user.date_joined.isoformat(),
        "login_count": current_user.login_count,
        "is_staff": current_user.is_staff,
    }

    if safe_report_type == "detailed":
        report_data["account_age_days"] = (
            __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
            - current_user.date_joined
        ).days

    row_count = len(report_data)

    capture(
        "report_generated",
        properties={
            "report_type": safe_report_type,
            "row_count": row_count,
            "username": current_user.email,
        },
    )

    return JSONResponse(
        {
            "success": True,
            "report_type": safe_report_type,
            "row_count": row_count,
            "data": report_data,
        }
    )

app/routers/main.py

"""Main routes demonstrating PostHog integration patterns."""

from pathlib import Path
from typing import Annotated

import posthog
from fastapi import APIRouter, Cookie, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from posthog import capture, identify_context, new_context

from app.dependencies import (
    CurrentUser,
    DbSession,
    RequiredUser,
    create_session_token,
)
from app.models import User

router = APIRouter()

# Setup templates
templates_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))


@router.get("/", response_class=HTMLResponse)
async def home(request: Request, current_user: CurrentUser, db: DbSession):
    """Home/login page."""
    if current_user:
        return RedirectResponse(url="/dashboard", status_code=302)

    return templates.TemplateResponse(
        request, "home.html", {"current_user": current_user}
    )


@router.post("/", response_class=HTMLResponse)
async def login(
    request: Request,
    db: DbSession,
    email: Annotated[str, Form()],
    password: Annotated[str, Form()],
):
    """Handle login form submission."""
    user = User.authenticate(db, email, password)

    if user:
        is_new_user = user.record_login(db)
        with new_context():
            identify_context(str(user.id))
            capture(
                "user_logged_in",
                properties={
                    "$set": {"email": user.email, "is_staff": user.is_staff},
                    "is_new_user": is_new_user,
                },
            )

        # Create session and redirect
        response = RedirectResponse(url="/dashboard", status_code=302)
        response.set_cookie(
            key="session_token",
            value=create_session_token(user.id),
            httponly=True,
            samesite="lax",
        )
        return response

    # Login failed
    return templates.TemplateResponse(
        request,
        "home.html",
        {"current_user": None, "error": "Invalid email or password"},
    )


@router.get("/signup", response_class=HTMLResponse)
async def signup_page(request: Request, current_user: CurrentUser):
    """User registration page."""
    if current_user:
        return RedirectResponse(url="/dashboard", status_code=302)

    return templates.TemplateResponse(
        request, "signup.html", {"current_user": current_user}
    )


@router.post("/signup", response_class=HTMLResponse)
async def signup(
    request: Request,
    db: DbSession,
    email: Annotated[str, Form()],
    password: Annotated[str, Form()],
    password_confirm: Annotated[str, Form()],
):
    """Handle signup form submission."""
    error = None

    if not email or not password:
        error = "Email and password are required"
    elif password != password_confirm:
        error = "Passwords do not match"
    elif User.get_by_email(db, email):
        error = "Email already registered"

    if error:
        return templates.TemplateResponse(
            request, "signup.html", {"current_user": None, "error": error}
        )

    # Create new user
    user = User.create_user(db, email=email, password=password, is_staff=False)

    with new_context():
        identify_context(str(user.id))
        capture(
            "user_signed_up",
            properties={
                "$set": {"email": user.email, "is_staff": user.is_staff},
                "signup_method": "form",
            },
        )

    # Create session and redirect
    response = RedirectResponse(url="/dashboard", status_code=302)
    response.set_cookie(
        key="session_token",
        value=create_session_token(user.id),
        httponly=True,
        samesite="lax",
    )
    return response


@router.get("/logout")
async def logout(current_user: RequiredUser):
    """Logout and capture event."""
    capture("user_logged_out")

    response = RedirectResponse(url="/", status_code=302)
    response.delete_cookie(key="session_token")
    return response


@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
    request: Request,
    current_user: RequiredUser,
):
    """Dashboard with feature flag demonstration."""
    capture("dashboard_viewed", properties={"is_staff": current_user.is_staff})

    # Check feature flag
    show_new_feature = posthog.feature_enabled(
        "new-dashboard-feature",
        current_user.email,
        person_properties={
            "email": current_user.email,
            "is_staff": current_user.is_staff,
        },
    )

    # Get feature flag payload
    feature_config = posthog.get_feature_flag_payload(
        "new-dashboard-feature", current_user.email
    )

    return templates.TemplateResponse(
        request,
        "dashboard.html",
        {
            "current_user": current_user,
            "show_new_feature": show_new_feature,
            "feature_config": feature_config,
        },
    )


@router.get("/burrito", response_class=HTMLResponse)
async def burrito(
    request: Request,
    current_user: RequiredUser,
    burrito_count: Annotated[int, Cookie()] = 0,
):
    """Burrito consideration tracker page."""
    return templates.TemplateResponse(
        request,
        "burrito.html",
        {"current_user": current_user, "burrito_count": burrito_count},
    )


@router.get("/profile", response_class=HTMLResponse)
async def profile(request: Request, current_user: RequiredUser):
    """User profile page."""
    capture("profile_viewed")

    return templates.TemplateResponse(
        request, "profile.html", {"current_user": current_user}
    )


@router.post("/profile", response_class=HTMLResponse)
async def update_profile(
    request: Request,
    db: DbSession,
    current_user: RequiredUser,
    name: Annotated[str, Form()],
):
    """Handle profile update."""
    fields_changed = current_user.update_profile(db, name=name)

    if fields_changed:
        capture(
            "profile_updated",
            properties={
                "username": current_user.email,
                "fields_changed": fields_changed,
            },
        )

    return templates.TemplateResponse(
        request,
        "profile.html",
        {
            "current_user": current_user,
            "success": "Profile updated" if fields_changed else None,
        },
    )

app/templates/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}PostHog FastAPI Example{% endblock %}</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        nav {
            background: #1d4ed8;
            padding: 15px 20px;
            margin-bottom: 30px;
        }
        nav a {
            color: white;
            text-decoration: none;
            margin-right: 20px;
        }
        nav a:hover {
            text-decoration: underline;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        button.danger {
            background: #dc2626;
        }
        button.danger:hover {
            background: #b91c1c;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        .messages {
            margin-bottom: 20px;
        }
        .message {
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 10px;
        }
        .message.error {
            background: #fee2e2;
            color: #dc2626;
        }
        .message.success {
            background: #d1fae5;
            color: #059669;
        }
        .feature-flag {
            background: #fef3c7;
            border: 2px dashed #f59e0b;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
        }
        pre {
            background: #1e293b;
            color: #e2e8f0;
            padding: 16px;
            border-radius: 8px;
            overflow-x: auto;
            font-size: 13px;
        }
        .count {
            font-size: 48px;
            font-weight: bold;
            color: #1d4ed8;
            text-align: center;
            padding: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 16px 0;
        }
        th, td {
            padding: 12px;
            text-align: left;
            border-bottom: 1px solid #eee;
        }
        th {
            background: #f8fafc;
            font-weight: 600;
        }
    </style>
</head>
<body>
    {% if current_user %}
    <nav>
        <a href="/dashboard">Dashboard</a>
        <a href="/burrito">Burrito</a>
        <a href="/profile">Profile</a>
        <a href="/logout" style="float: right;">Logout ({{ current_user.email }})</a>
    </nav>
    {% endif %}

    <div class="container">
        {% if error %}
        <div class="messages">
            <div class="message error">{{ error }}</div>
        </div>
        {% endif %}
        {% if success %}
        <div class="messages">
            <div class="message success">{{ success }}</div>
        </div>
        {% endif %}

        {% block content %}{% endblock %}
    </div>

    {% block scripts %}{% endblock %}
</body>
</html>

app/templates/burrito.html

{% extends "base.html" %}

{% block title %}Burrito - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Burrito Consideration Tracker</h1>
    <p>This page demonstrates custom event tracking with PostHog.</p>

    <div class="count" id="burrito-count">{{ burrito_count }}</div>
    <p style="text-align: center; color: #666;">Times you've considered a burrito</p>

    <div style="text-align: center; margin-top: 20px;">
        <button onclick="considerBurrito()">Consider a Burrito</button>
    </div>
</div>

<div class="card">
    <h3>Code Example</h3>
    <pre>
# API endpoint captures the event
with new_context():
    identify_context(current_user.email)
    capture('burrito_considered', properties={
        'total_considerations': burrito_count
    })</pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function considerBurrito() {
    try {
        const response = await fetch('/api/burrito/consider', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            }
        });
        const data = await response.json();
        if (data.success) {
            document.getElementById('burrito-count').textContent = data.count;
        }
    } catch (error) {
        console.error('Error:', error);
    }
}
</script>
{% endblock %}

app/templates/dashboard.html

{% extends "base.html" %}

{% block title %}Dashboard - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Dashboard</h1>
    <p>Welcome back, {{ current_user.email }}!</p>
</div>

<div class="card">
    <h2>Feature Flags</h2>

    {% if show_new_feature %}
    <div class="feature-flag">
        <strong>New Feature Enabled!</strong>
        <p>You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>
        {% if feature_config %}
        <p><strong>Feature Configuration:</strong></p>
        <pre>{{ feature_config | tojson(indent=2) }}</pre>
        {% endif %}
    </div>
    {% else %}
    <p>The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
    {% endif %}

    <h3 style="margin-top: 20px;">Code Example</h3>
    <pre>
# Check if feature flag is enabled
show_new_feature = posthog.feature_enabled(
    'new-dashboard-feature',
    user_id,
    person_properties={
        'email': current_user.email,
        'is_staff': current_user.is_staff
    }
)

# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
    'new-dashboard-feature',
    user_id
)</pre>
</div>
{% endblock %}

app/templates/errors/404.html

{% extends "base.html" %}

{% block title %}Page Not Found - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>404 - Page Not Found</h1>
    <p>The page you're looking for doesn't exist.</p>
    <a href="/" class="btn">Go Home</a>
</div>
{% endblock %}

app/templates/errors/500.html

{% extends "base.html" %}

{% block title %}Server Error - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>500 - Internal Server Error</h1>
    <p>Something went wrong on our end. Please try again later.</p>
    <a href="/" class="btn">Go Home</a>
</div>
{% endblock %}

app/templates/home.html

{% extends "base.html" %}

{% block title %}Login - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Welcome to PostHog FastAPI Example</h1>
    <p>This example demonstrates how to integrate PostHog with a FastAPI application.</p>

    <form method="POST">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" required>

        <label for="password">Password</label>
        <input type="password" id="password" name="password" required>

        <button type="submit">Login</button>
    </form>

    <p style="margin-top: 16px; font-size: 14px; color: #666;">
        Don't have an account? <a href="/signup">Sign up here</a>
    </p>
    <p style="font-size: 14px; color: #666;">
        <strong>Tip:</strong> Default credentials are admin@example.com/admin
    </p>
</div>

<div class="card">
    <h2>Features Demonstrated</h2>
    <ul style="margin-left: 20px; color: #666;">
        <li>User registration and identification</li>
        <li>Event tracking</li>
        <li>Feature flags</li>
        <li>Error tracking</li>
        <li>Group analytics</li>
    </ul>
</div>
{% endblock %}

app/templates/profile.html

{% extends "base.html" %}

{% block title %}Profile - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Your Profile</h1>
    <p>This page demonstrates profile updates and report generation with PostHog.</p>

    {% if success %}
    <div class="message success">{{ success }}</div>
    {% endif %}

    <form method="POST" action="/profile">
        <table>
            <tr>
                <th>Email</th>
                <td>{{ current_user.email }}</td>
            </tr>
            <tr>
                <th>Name</th>
                <td>
                    <input type="text" name="name" value="{{ current_user.name or '' }}" placeholder="Enter your name">
                </td>
            </tr>
            <tr>
                <th>Date Joined</th>
                <td>{{ current_user.date_joined.strftime('%Y-%m-%d %H:%M') }}</td>
            </tr>
            <tr>
                <th>Login Count</th>
                <td>{{ current_user.login_count }}</td>
            </tr>
            <tr>
                <th>Staff Status</th>
                <td>{{ 'Yes' if current_user.is_staff else 'No' }}</td>
            </tr>
        </table>
        <button type="submit">Update Profile</button>
    </form>
</div>

<div class="card">
    <h2>Activity Reports</h2>
    <p>Generate a report of your account activity:</p>

    <div style="margin: 20px 0;">
        <button onclick="generateReport('summary')">Summary Report</button>
        <button onclick="generateReport('detailed')">Detailed Report</button>
    </div>

    <div id="report-result" style="display: none;" class="message"></div>
</div>

<div class="card">
    <h2>Error Tracking Demo</h2>
    <p>Click a button to trigger an error and see it captured in PostHog:</p>

    <div style="margin: 20px 0;">
        <button class="danger" onclick="triggerError('value')">
            Trigger ValueError
        </button>
        <button class="danger" onclick="triggerError('key')">
            Trigger KeyError
        </button>
        <button class="danger" onclick="triggerError('generic')">
            Trigger Generic Error
        </button>
    </div>

    <div id="error-result" style="display: none;" class="message"></div>
</div>

<div class="card">
    <h3>Code Example</h3>
    <pre>
try:
    raise ValueError('Invalid value provided')
except Exception as e:
    # Capture exception and event with user context
    with new_context():
        identify_context(current_user.email)
        posthog.capture_exception(e)
        capture('error_triggered', properties={
            'error_type': 'value',
            'error_message': str(e)
        })</pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function triggerError(errorType) {
    const resultDiv = document.getElementById('error-result');
    try {
        const formData = new FormData();
        formData.append('error_type', errorType);

        const response = await fetch('/api/trigger-error', {
            method: 'POST',
            body: formData
        });
        const data = await response.json();

        resultDiv.style.display = 'block';
        resultDiv.className = 'message ' + (data.success ? 'success' : 'error');
        resultDiv.textContent = data.message + ': ' + data.error;
    } catch (error) {
        console.error('Error:', error);
        resultDiv.style.display = 'block';
        resultDiv.className = 'message error';
        resultDiv.textContent = 'Request failed: ' + error.message;
    }
}

async function generateReport(reportType) {
    const resultDiv = document.getElementById('report-result');
    try {
        const formData = new FormData();
        formData.append('report_type', reportType);

        const response = await fetch('/api/reports/activity', {
            method: 'POST',
            body: formData
        });
        const data = await response.json();

        resultDiv.style.display = 'block';
        resultDiv.className = 'message success';
        resultDiv.innerHTML = '<strong>' + data.report_type + ' report generated</strong> (' + data.row_count + ' rows)<br><pre>' + JSON.stringify(data.data, null, 2) + '</pre>';
    } catch (error) {
        console.error('Error:', error);
        resultDiv.style.display = 'block';
        resultDiv.className = 'message error';
        resultDiv.textContent = 'Request failed: ' + error.message;
    }
}
</script>
{% endblock %}

app/templates/signup.html

{% extends "base.html" %}

{% block title %}Sign Up - PostHog FastAPI Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Create an Account</h1>
    <p>Sign up to explore the PostHog FastAPI integration example.</p>

    <form method="POST">
        <label for="email">Email *</label>
        <input type="email" id="email" name="email" required>

        <label for="password">Password *</label>
        <input type="password" id="password" name="password" required>

        <label for="password_confirm">Confirm Password *</label>
        <input type="password" id="password_confirm" name="password_confirm" required>

        <button type="submit">Sign Up</button>
    </form>

    <p style="margin-top: 16px; font-size: 14px; color: #666;">
        Already have an account? <a href="/">Login here</a>
    </p>
</div>

<div class="card">
    <h2>PostHog Integration</h2>
    <p>When you sign up, the following PostHog events are captured:</p>
    <ul style="margin-left: 20px; color: #666;">
        <li><code>identify_context()</code> - Associates your email with the context</li>
        <li><code>tag()</code> - Sets person properties (email, etc.)</li>
        <li><code>user_signed_up</code> event - Tracks the signup action</li>
    </ul>

    <h3 style="margin-top: 20px;">Code Example</h3>
    <pre>
# After creating the user
with new_context():
    identify_context(user.email)

    tag('email', user.email)
    tag('is_staff', user.is_staff)
    tag('date_joined', user.date_joined.isoformat())

    capture('user_signed_up', properties={'signup_method': 'form'})</pre>
</div>
{% endblock %}

requirements.txt

fastapi>=0.109.0
uvicorn>=0.27.0
sqlalchemy>=2.0.0
python-dotenv>=1.0.0
posthog>=3.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
jinja2>=3.0.0
python-multipart>=0.0.9
werkzeug>=3.0.0
itsdangerous>=2.0.0

run.py

"""Development server entry point."""

import uvicorn

if __name__ == "__main__":
    uvicorn.run("app.main:app", host="0.0.0.0", port=5002, reload=True)

references/EXAMPLE-flask.md

PostHog flask Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/flask


README.md

PostHog Flask Example

A Flask application demonstrating PostHog integration for analytics, feature flags, and error tracking.

Features

  • User registration and authentication with Flask-Login
  • SQLite database persistence with Flask-SQLAlchemy
  • User identification and property tracking
  • Custom event tracking
  • Feature flags with payload support
  • Error tracking with manual exception capture

Quick Start

  1. Create and activate a virtual environment:

    python -m venv venv
    source venv/bin/activate  # On Windows: venv\Scripts\activate
  2. Install dependencies:

    pip install -r requirements.txt
  3. Copy the environment file and configure:

    cp .env.example .env
    # Edit .env with your PostHog project key
  4. Run the application:

    python run.py
  5. Open http://localhost:5001 and either:

    • Login with default credentials: admin@example.com / admin
    • Or click "Sign up here" to create a new account

PostHog Integration Points

User Registration

New users are identified and tracked on signup using the context-based API:

with new_context():
    identify_context(user.email)
    tag('email', user.email)
    tag('is_staff', user.is_staff)
    capture('user_signed_up', properties={'signup_method': 'form'})
User Identification

Users are identified on login with their properties:

with new_context():
    identify_context(user.email)
    tag('email', user.email)
    tag('is_staff', user.is_staff)
    capture('user_logged_in', properties={'login_method': 'password'})
Event Tracking

Custom events are captured throughout the app:

with new_context():
    identify_context(current_user.email)
    capture('burrito_considered', properties={'total_considerations': count})
Feature Flags

The dashboard demonstrates feature flag checking:

show_new_feature = posthog.feature_enabled(
    'new-dashboard-feature',
    current_user.email,
    person_properties={'email': current_user.email, 'is_staff': current_user.is_staff}
)
feature_config = posthog.get_feature_flag_payload('new-dashboard-feature', current_user.email)
Error Tracking

The example demonstrates two approaches to error tracking:

Manual capture for specific critical operations** (app/api/routes.py).

try:
    # Critical operation that might fail
    result = process_payment()
except Exception as e:
    # Manually capture this specific exception
    with new_context():
        identify_context(current_user.email)
        event_id = posthog.capture_exception(e)

    return jsonify({
        "error": "Operation failed",
        "error_id": event_id,
        "message": f"Error captured in PostHog. Reference ID: {event_id}"
    }), 500

The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.

Project Structure

basics/flask/
├── app/
│   ├── __init__.py              # Application factory
│   ├── config.py                # Configuration classes
│   ├── extensions.py            # Extension instances
│   ├── models.py                # User model (SQLAlchemy)
│   ├── main/
│   │   ├── __init__.py          # Main blueprint
│   │   └── routes.py            # View functions
│   ├── templates/               # HTML templates
│   └── api/
│       ├── __init__.py          # API blueprint
│       └── routes.py            # API endpoints
├── .env.example
├── .gitignore
├── requirements.txt
├── README.md
└── run.py                       # Entry point

.env.example

POSTHOG_PROJECT_TOKEN=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.com
FLASK_SECRET_KEY=your-secret-key-here
FLASK_DEBUG=True
POSTHOG_DISABLED=False

app/init.py

"""Flask application factory."""

import posthog
from flask import Flask, g, jsonify, render_template, request
from flask_login import current_user
from posthog import identify_context, new_context
from werkzeug.exceptions import HTTPException

from app.config import config
from app.extensions import db, login_manager


def create_app(config_name="default"):
    """Application factory."""
    app = Flask(__name__)
    app.config.from_object(config[config_name])

    # Initialize extensions
    db.init_app(app)
    login_manager.init_app(app)

    # Initialize PostHog
    if not app.config["POSTHOG_DISABLED"]:
        posthog.api_key = app.config["POSTHOG_PROJECT_TOKEN"]
        posthog.host = app.config["POSTHOG_HOST"]
        posthog.debug = app.config["DEBUG"]

    # Import models after db is initialized
    from app.models import User

    # User loader for Flask-Login
    @login_manager.user_loader
    def load_user(user_id):
        return User.get_by_id(user_id)

    # Simple error handlers - no automatic PostHog capture
    # Capture exceptions manually only where it makes sense (e.g., test endpoints)
    @app.errorhandler(404)
    def page_not_found(e):
        if request.path.startswith('/api/'):
            return jsonify({"error": "Not found"}), 404
        return render_template('errors/404.html'), 404

    @app.errorhandler(500)
    def internal_server_error(e):
        if request.path.startswith('/api/'):
            return jsonify({"error": "Internal server error"}), 500
        return render_template('errors/500.html'), 500

    # Register blueprints
    from app.api import api_bp
    from app.main import main_bp

    app.register_blueprint(main_bp)
    app.register_blueprint(api_bp, url_prefix="/api")

    # Create database tables and seed default admin user
    with app.app_context():
        db.create_all()
        if not User.get_by_email("admin@example.com"):
            User.create_user(
                email="admin@example.com",
                password="admin",
                is_staff=True,
            )

    return app

app/api/init.py

"""API blueprint registration."""

from flask import Blueprint

api_bp = Blueprint("api", __name__)

from app.api import routes  # noqa: E402, F401

app/api/routes.py

"""API endpoints demonstrating PostHog integration patterns."""

import posthog
from flask import jsonify, request, session
from flask_login import current_user, login_required
from posthog import capture, identify_context, new_context

from app.api import api_bp


@api_bp.route("/burrito/consider", methods=["POST"])
@login_required
def consider_burrito():
    """Track burrito consideration event."""
    # Increment session counter
    burrito_count = session.get("burrito_count", 0) + 1
    session["burrito_count"] = burrito_count

    # PostHog: Capture custom event
    with new_context():
        identify_context(str(current_user.id))
        capture("burrito_considered", properties={"total_considerations": burrito_count})

    return jsonify({"success": True, "count": burrito_count})


@api_bp.route("/test-error", methods=["POST"])
@login_required
def test_error():
    """Test endpoint demonstrating manual exception capture in PostHog.

    Shows how to intentionally capture specific errors in PostHog.
    Use this pattern for critical operations where you want error tracking.

    Query params:
    - capture: "true" to capture the exception in PostHog, "false" to just raise it
    """
    should_capture = request.args.get("capture", "true").lower() == "true"

    try:
        # Simulate a critical operation failure
        raise Exception("Test exception from critical operation")
    except Exception as e:
        if should_capture:
            # Manually capture this specific exception in PostHog
            with new_context():
                identify_context(str(current_user.id))
                event_id = posthog.capture_exception(e)

            return jsonify({
                "error": "Operation failed",
                "error_id": event_id,
                "message": f"Error captured in PostHog. Reference ID: {event_id}"
            }), 500
        else:
            # Just return error without PostHog capture
            return jsonify({"error": str(e)}), 500



app/config.py

"""Flask application configuration."""

import os
from dotenv import load_dotenv

load_dotenv()


class Config:
    """Base configuration."""

    SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-secret-key-change-in-production")

    # Database configuration (SQLite like Django example)
    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///db.sqlite3")
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # PostHog configuration
    POSTHOG_PROJECT_TOKEN = os.environ.get("POSTHOG_PROJECT_TOKEN", "<ph_project_token>")
    POSTHOG_HOST = os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com")
    POSTHOG_DISABLED = os.environ.get("POSTHOG_DISABLED", "False").lower() == "true"


class DevelopmentConfig(Config):
    """Development configuration."""

    DEBUG = True


class ProductionConfig(Config):
    """Production configuration."""

    DEBUG = False


config = {
    "development": DevelopmentConfig,
    "production": ProductionConfig,
    "default": DevelopmentConfig,
}

app/extensions.py

"""Flask extensions initialized without binding to app."""

from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

login_manager = LoginManager()
login_manager.login_view = "main.home"
login_manager.login_message = "Please log in to access this page."

app/main/init.py

"""Main blueprint registration."""

from flask import Blueprint

main_bp = Blueprint("main", __name__, template_folder="../templates")

from app.main import routes  # noqa: E402, F401

app/main/routes.py

"""Core view functions demonstrating PostHog integration patterns."""

import posthog
from flask import flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
from posthog import capture, identify_context, new_context

from app.main import main_bp
from app.models import User


@main_bp.route("/", methods=["GET", "POST"])
def home():
    """Home/login page."""
    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    if request.method == "POST":
        email = request.form.get("email")
        password = request.form.get("password")

        user = User.authenticate(email, password)
        if user:
            login_user(user)

            # PostHog: Identify user and capture login event
            with new_context():
                identify_context(str(user.id))

                # PII belongs in person properties, never in event properties
                posthog.set(
                    distinct_id=str(user.id),
                    properties={
                        "email": user.email,
                        "is_staff": user.is_staff,
                        "date_joined": user.date_joined.isoformat(),
                    },
                )

                capture("user_logged_in", properties={"login_method": "password"})

            return redirect(url_for("main.dashboard"))
        else:
            flash("Invalid email or password", "error")

    return render_template("home.html")


@main_bp.route("/signup", methods=["GET", "POST"])
def signup():
    """User registration page."""
    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    if request.method == "POST":
        email = request.form.get("email")
        password = request.form.get("password")
        password_confirm = request.form.get("password_confirm")

        # Validation
        if not email or not password:
            flash("Email and password are required", "error")
        elif password != password_confirm:
            flash("Passwords do not match", "error")
        elif User.get_by_email(email):
            flash("Email already registered", "error")
        else:
            # Create new user
            user = User.create_user(
                email=email,
                password=password,
                is_staff=False,
            )

            # PostHog: Identify new user and capture signup event
            with new_context():
                identify_context(str(user.id))

                posthog.set(
                    distinct_id=str(user.id),
                    properties={
                        "email": user.email,
                        "is_staff": user.is_staff,
                        "date_joined": user.date_joined.isoformat(),
                    },
                )

                capture("user_signed_up", properties={"signup_method": "form"})

            # Log the user in
            login_user(user)
            flash("Account created successfully!", "success")
            return redirect(url_for("main.dashboard"))

    return render_template("signup.html")


@main_bp.route("/logout")
@login_required
def logout():
    """Logout and capture event."""
    # PostHog: Capture logout event before session ends
    with new_context():
        identify_context(str(current_user.id))
        capture("user_logged_out")

    logout_user()
    return redirect(url_for("main.home"))


@main_bp.route("/dashboard")
@login_required
def dashboard():
    """Dashboard with feature flag demonstration."""
    # PostHog: Capture dashboard view
    with new_context():
        identify_context(str(current_user.id))
        capture("dashboard_viewed", properties={"is_staff": current_user.is_staff})

    # Check feature flag
    show_new_feature = posthog.feature_enabled(
        "new-dashboard-feature",
        current_user.email,
        person_properties={
            "email": current_user.email,
            "is_staff": current_user.is_staff,
        },
    )

    # Get feature flag payload
    feature_config = posthog.get_feature_flag_payload(
        "new-dashboard-feature", current_user.email
    )

    return render_template(
        "dashboard.html",
        show_new_feature=show_new_feature,
        feature_config=feature_config,
    )


@main_bp.route("/burrito")
@login_required
def burrito():
    """Burrito consideration tracker page."""
    burrito_count = session.get("burrito_count", 0)
    return render_template("burrito.html", burrito_count=burrito_count)


@main_bp.route("/profile")
@login_required
def profile():
    """User profile page."""
    # PostHog: Capture profile view
    with new_context():
        identify_context(str(current_user.id))
        capture("profile_viewed")

    return render_template("profile.html")

app/models.py

"""User model with SQLite persistence (similar to Django's auth.User)."""

from datetime import datetime, timezone

from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash

from app.extensions import db


class User(UserMixin, db.Model):
    """User model with SQLite persistence."""

    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(254), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    is_staff = db.Column(db.Boolean, default=False)
    is_active = db.Column(db.Boolean, default=True)
    date_joined = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))

    def set_password(self, password):
        """Hash and set the user's password."""
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        """Verify the password against the hash."""
        return check_password_hash(self.password_hash, password)

    @classmethod
    def create_user(cls, email, password, is_staff=False):
        """Create and save a new user."""
        user = cls(email=email, is_staff=is_staff)
        # nosemgrep: python.django.security.audit.unvalidated-password.unvalidated-password
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        return user

    @classmethod
    def get_by_id(cls, user_id):
        """Get user by ID."""
        return cls.query.get(int(user_id))

    @classmethod
    def get_by_email(cls, email):
        """Get user by email."""
        return cls.query.filter_by(email=email).first()

    @classmethod
    def authenticate(cls, email, password):
        """Authenticate user with email and password."""
        user = cls.get_by_email(email)
        if user and user.check_password(password):
            return user
        return None

    def __repr__(self):
        return f"<User {self.email}>"

app/templates/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}PostHog Flask Example{% endblock %}</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        nav {
            background: #1d4ed8;
            padding: 15px 20px;
            margin-bottom: 30px;
        }
        nav a {
            color: white;
            text-decoration: none;
            margin-right: 20px;
        }
        nav a:hover {
            text-decoration: underline;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        button.danger {
            background: #dc2626;
        }
        button.danger:hover {
            background: #b91c1c;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        .messages {
            margin-bottom: 20px;
        }
        .message {
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 10px;
        }
        .message.error {
            background: #fee2e2;
            color: #dc2626;
        }
        .message.success {
            background: #d1fae5;
            color: #059669;
        }
        .feature-flag {
            background: #fef3c7;
            border: 2px dashed #f59e0b;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
        }
        pre {
            background: #1e293b;
            color: #e2e8f0;
            padding: 16px;
            border-radius: 8px;
            overflow-x: auto;
            font-size: 13px;
        }
        .count {
            font-size: 48px;
            font-weight: bold;
            color: #1d4ed8;
            text-align: center;
            padding: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 16px 0;
        }
        th, td {
            padding: 12px;
            text-align: left;
            border-bottom: 1px solid #eee;
        }
        th {
            background: #f8fafc;
            font-weight: 600;
        }
    </style>
</head>
<body>
    {% if current_user.is_authenticated %}
    <nav>
        <a href="{{ url_for('main.dashboard') }}">Dashboard</a>
        <a href="{{ url_for('main.burrito') }}">Burrito</a>
        <a href="{{ url_for('main.profile') }}">Profile</a>
        <a href="{{ url_for('main.logout') }}" style="float: right;">Logout ({{ current_user.email }})</a>
    </nav>
    {% endif %}

    <div class="container">
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
            <div class="messages">
                {% for category, message in messages %}
                <div class="message {{ category }}">{{ message }}</div>
                {% endfor %}
            </div>
            {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}
    </div>

    {% block scripts %}{% endblock %}
</body>
</html>

app/templates/burrito.html

{% extends "base.html" %}

{% block title %}Burrito - PostHog Flask Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Burrito Consideration Tracker</h1>
    <p>This page demonstrates custom event tracking with PostHog.</p>

    <div class="count" id="burrito-count">{{ burrito_count }}</div>
    <p style="text-align: center; color: #666;">Times you've considered a burrito</p>

    <div style="text-align: center; margin-top: 20px;">
        <button onclick="considerBurrito()">Consider a Burrito</button>
    </div>
</div>

<div class="card">
    <h3>Code Example</h3>
    <pre>
# API endpoint captures the event
with new_context():
    identify_context(current_user.email)
    capture('burrito_considered', properties={
        'total_considerations': burrito_count
    })</pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function considerBurrito() {
    try {
        const response = await fetch('/api/burrito/consider', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            }
        });
        const data = await response.json();
        if (data.success) {
            document.getElementById('burrito-count').textContent = data.count;
        }
    } catch (error) {
        console.error('Error:', error);
    }
}
</script>
{% endblock %}

app/templates/dashboard.html

{% extends "base.html" %}

{% block title %}Dashboard - PostHog Flask Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Dashboard</h1>
    <p>Welcome back, {{ current_user.username }}!</p>
</div>

<div class="card">
    <h2>Feature Flags</h2>

    {% if show_new_feature %}
    <div class="feature-flag">
        <strong>New Feature Enabled!</strong>
        <p>You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>
        {% if feature_config %}
        <p><strong>Feature Configuration:</strong></p>
        <pre>{{ feature_config | tojson(indent=2) }}</pre>
        {% endif %}
    </div>
    {% else %}
    <p>The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
    {% endif %}

    <h3 style="margin-top: 20px;">Code Example</h3>
    <pre>
# Check if feature flag is enabled
show_new_feature = posthog.feature_enabled(
    'new-dashboard-feature',
    user_id,
    person_properties={
        'email': current_user.email,
        'is_staff': current_user.is_staff
    }
)

# Get feature flag payload
feature_config = posthog.get_feature_flag_payload(
    'new-dashboard-feature',
    user_id
)</pre>
</div>
{% endblock %}

app/templates/errors/404.html

{% extends "base.html" %}

{% block title %}404 - Page Not Found{% endblock %}

{% block content %}
<div class="card" style="text-align: center; padding: 60px 20px;">
    <h1 style="font-size: 72px; color: #dc2626; margin-bottom: 10px;">404</h1>
    <h2 style="color: #333; margin-bottom: 20px;">Page Not Found</h2>
    <p style="font-size: 18px; color: #666; margin-bottom: 30px;">
        The page you're looking for doesn't exist or has been moved.
    </p>

    {% if error_id %}
    <div style="background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 15px; margin: 30px 0;">
        <p style="color: #92400e; margin-bottom: 5px; font-weight: 600;">Error Reference ID:</p>
        <code style="background: #fff; padding: 5px 10px; border-radius: 4px; font-family: monospace; color: #1e40af;">{{ error_id }}</code>
        <p style="color: #92400e; margin-top: 10px; font-size: 14px;">
            Share this ID with support if you need assistance.
        </p>
    </div>
    {% endif %}

    <div style="margin-top: 40px;">
        <a href="{{ url_for('main.home') }}" class="btn" style="margin-right: 10px;">Go to Home</a>
        {% if current_user.is_authenticated %}
        <a href="{{ url_for('main.dashboard') }}" class="btn">Go to Dashboard</a>
        {% endif %}
    </div>
</div>
{% endblock %}

app/templates/errors/500.html

{% extends "base.html" %}

{% block title %}500 - Internal Server Error{% endblock %}

{% block content %}
<div class="card" style="text-align: center; padding: 60px 20px;">
    <h1 style="font-size: 72px; color: #dc2626; margin-bottom: 10px;">500</h1>
    <h2 style="color: #333; margin-bottom: 20px;">Internal Server Error</h2>
    <p style="font-size: 18px; color: #666; margin-bottom: 30px;">
        Something went wrong on our end. We've been notified and are looking into it.
    </p>

    {% if error_id %}
    <div style="background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 15px; margin: 30px 0;">
        <p style="color: #92400e; margin-bottom: 5px; font-weight: 600;">Error Reference ID:</p>
        <code style="background: #fff; padding: 5px 10px; border-radius: 4px; font-family: monospace; color: #1e40af;">{{ error_id }}</code>
        <p style="color: #92400e; margin-top: 10px; font-size: 14px;">
            Share this ID with support if you need assistance. This error has been logged in PostHog.
        </p>
    </div>
    {% endif %}

    {% if error and config.DEBUG %}
    <div style="background: #fee2e2; border: 1px solid #dc2626; border-radius: 8px; padding: 15px; margin: 30px 0; text-align: left;">
        <p style="color: #7f1d1d; margin-bottom: 5px; font-weight: 600;">Debug Information:</p>
        <code style="background: #fff; padding: 10px; border-radius: 4px; font-family: monospace; color: #dc2626; display: block; overflow-x: auto;">{{ error }}</code>
    </div>
    {% endif %}

    <div style="margin-top: 40px;">
        <a href="{{ url_for('main.home') }}" class="btn" style="margin-right: 10px;">Go to Home</a>
        {% if current_user.is_authenticated %}
        <a href="{{ url_for('main.dashboard') }}" class="btn">Go to Dashboard</a>
        {% endif %}
    </div>
</div>
{% endblock %}

app/templates/home.html

{% extends "base.html" %}

{% block title %}Login - PostHog Flask Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Welcome to PostHog Flask Example</h1>
    <p>This example demonstrates how to integrate PostHog with a Flask application.</p>

    <form method="POST">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" required>

        <label for="password">Password</label>
        <input type="password" id="password" name="password" required>

        <button type="submit">Login</button>
    </form>

    <p style="margin-top: 16px; font-size: 14px; color: #666;">
        Don't have an account? <a href="{{ url_for('main.signup') }}">Sign up here</a>
    </p>
    <p style="font-size: 14px; color: #666;">
        <strong>Tip:</strong> Default credentials are admin@example.com/admin
    </p>
</div>

<div class="card">
    <h2>Features Demonstrated</h2>
    <ul style="margin-left: 20px; color: #666;">
        <li>User registration and identification</li>
        <li>Event tracking</li>
        <li>Feature flags</li>
        <li>Error tracking</li>
        <li>Group analytics</li>
    </ul>
</div>
{% endblock %}

app/templates/profile.html

{% extends "base.html" %}

{% block title %}Profile - PostHog Flask Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Your Profile</h1>
    <p>This page demonstrates error tracking with PostHog.</p>

    <table>
        <tr>
            <th>Email</th>
            <td>{{ current_user.email }}</td>
        </tr>
        <tr>
            <th>Date Joined</th>
            <td>{{ current_user.date_joined.strftime('%Y-%m-%d %H:%M') }}</td>
        </tr>
        <tr>
            <th>Staff Status</th>
            <td>{{ 'Yes' if current_user.is_staff else 'No' }}</td>
        </tr>
    </table>
</div>

<div class="card">
    <h2>Error Tracking Demo</h2>
    <p>Click a button to trigger an error and see it captured in PostHog:</p>

    <div style="margin: 20px 0;">
        <button class="danger" onclick="triggerError('value')">
            Trigger ValueError
        </button>
        <button class="danger" onclick="triggerError('key')">
            Trigger KeyError
        </button>
        <button class="danger" onclick="triggerError('generic')">
            Trigger Generic Error
        </button>
    </div>

    <div id="error-result" style="display: none;" class="message"></div>
</div>

<div class="card">
    <h3>Code Example</h3>
    <pre>
try:
    raise ValueError('Invalid value provided')
except Exception as e:
    # Capture exception and event with user context
    with new_context():
        identify_context(current_user.email)
        posthog.capture_exception(e)
        capture('error_triggered', properties={
            'error_type': 'value',
            'error_message': str(e)
        })</pre>
</div>
{% endblock %}

{% block scripts %}
<script>
async function triggerError(errorType) {
    const resultDiv = document.getElementById('error-result');
    try {
        const formData = new FormData();
        formData.append('error_type', errorType);

        const response = await fetch('/api/trigger-error', {
            method: 'POST',
            body: formData
        });
        const data = await response.json();

        resultDiv.style.display = 'block';
        resultDiv.className = 'message ' + (data.success ? 'success' : 'error');
        resultDiv.textContent = data.message + ': ' + data.error;
    } catch (error) {
        console.error('Error:', error);
        resultDiv.style.display = 'block';
        resultDiv.className = 'message error';
        resultDiv.textContent = 'Request failed: ' + error.message;
    }
}
</script>
{% endblock %}

app/templates/signup.html

{% extends "base.html" %}

{% block title %}Sign Up - PostHog Flask Example{% endblock %}

{% block content %}
<div class="card">
    <h1>Create an Account</h1>
    <p>Sign up to explore the PostHog Flask integration example.</p>

    <form method="POST">
        <label for="email">Email *</label>
        <input type="email" id="email" name="email" required>

        <label for="password">Password *</label>
        <input type="password" id="password" name="password" required>

        <label for="password_confirm">Confirm Password *</label>
        <input type="password" id="password_confirm" name="password_confirm" required>

        <button type="submit">Sign Up</button>
    </form>

    <p style="margin-top: 16px; font-size: 14px; color: #666;">
        Already have an account? <a href="{{ url_for('main.home') }}">Login here</a>
    </p>
</div>

<div class="card">
    <h2>PostHog Integration</h2>
    <p>When you sign up, the following PostHog events are captured:</p>
    <ul style="margin-left: 20px; color: #666;">
        <li><code>identify_context()</code> - Associates your email with the context</li>
        <li><code>tag()</code> - Sets person properties (email, etc.)</li>
        <li><code>user_signed_up</code> event - Tracks the signup action</li>
    </ul>

    <h3 style="margin-top: 20px;">Code Example</h3>
    <pre>
# After creating the user
with new_context():
    identify_context(user.email)

    tag('email', user.email)
    tag('is_staff', user.is_staff)
    tag('date_joined', user.date_joined.isoformat())

    capture('user_signed_up', properties={'signup_method': 'form'})</pre>
</div>
{% endblock %}

requirements.txt

Flask>=3.1.0
Flask-Login>=0.6.3
Flask-SQLAlchemy>=3.1.0
python-dotenv>=1.0.0
posthog>=3.0.0
Werkzeug>=3.0.0

run.py

"""Development server entry point."""

from app import create_app

app = create_app()

if __name__ == "__main__":
    app.run(port=5001)

references/EXAMPLE-laravel.md

PostHog laravel Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/laravel


README.md

PostHog Laravel Example

A Laravel application demonstrating PostHog integration for analytics, feature flags, and error tracking using Livewire for reactive UI components.

Features

  • User registration and authentication with Livewire
  • SQLite database persistence with Eloquent ORM
  • User identification and property tracking
  • Custom event tracking (burrito consideration tracker)
  • Page view tracking (dashboard, profile)
  • Feature flags with payload support
  • Error tracking with manual exception capture
  • Reactive UI components with Livewire

Tech Stack

  • Framework: Laravel 11.x
  • Reactive Components: Livewire 3.x
  • Database: SQLite
  • Analytics: PostHog PHP SDK

Quick Start

Note: This is a minimal implementation demonstrating PostHog integration. For a production application, you would need to install Laravel via Composer and set up additional dependencies.

Manual Setup (Demonstration)
  1. Install dependencies:

    composer install
  2. Set up environment:

    cp .env.example .env
    # Edit .env with your PostHog project token
  3. Configure PostHog in .env:

    POSTHOG_PROJECT_TOKEN=your_posthog_project_token
    POSTHOG_HOST=https://us.i.posthog.com
    POSTHOG_DISABLED=false
  4. Generate application key:

    php artisan key:generate
  5. Create database and run migrations:

    touch database/database.sqlite
    php artisan migrate --seed
  6. Start the development server:

    php artisan serve
  7. Open http://localhost:8000 and either:

    • Login with default credentials: admin@example.com / admin
    • Or click "Sign up here" to create a new account

PostHog Service

The PostHogService class (app/Services/PostHogService.php) wraps the PostHog PHP SDK and provides:

Method Description
identify($distinctId, $properties) Identify a user with properties
capture($distinctId, $event, $properties) Capture custom events
captureException($exception, $distinctId) Capture exceptions with stack traces
isFeatureEnabled($key, $distinctId, $properties) Check feature flag status
getFeatureFlagPayload($key, $distinctId) Get feature flag payload

All methods check config('posthog.disabled') and return early if PostHog is disabled.

PostHog Integration Points

User Registration (app/Http/Livewire/Auth/Register.php)

New users are identified and tracked on signup:

$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_signed_up', [
    'signup_method' => 'form',
]);
User Login (app/Http/Livewire/Auth/Login.php)

Users are identified on login with their properties:

$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_logged_in', [
    'login_method' => 'password',
]);
User Logout (routes/web.php)

Logout events are tracked:

$posthog->capture($user->email, 'user_logged_out');
Page View Tracking

Dashboard and profile views are tracked (app/Http/Livewire/Dashboard.php, app/Http/Livewire/Profile.php):

$posthog->capture($user->email, 'dashboard_viewed', [
    'is_staff' => $user->is_staff,
]);

$posthog->capture($user->email, 'profile_viewed');
Custom Event Tracking (app/Http/Livewire/BurritoTracker.php)

The burrito tracker demonstrates custom event capture:

$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'burrito_considered', [
    'total_considerations' => $this->burritoCount,
]);
Feature Flags (app/Http/Livewire/Dashboard.php)

The dashboard demonstrates feature flag checking:

$this->showNewFeature = $posthog->isFeatureEnabled(
    'new-dashboard-feature',
    $user->email,
    $user->getPostHogProperties()
) ?? false;

$this->featureConfig = $posthog->getFeatureFlagPayload(
    'new-dashboard-feature',
    $user->email
);
Error Tracking

Manual exception capture is demonstrated in multiple places:

Livewire Components (app/Http/Livewire/Dashboard.php, app/Http/Livewire/Profile.php):

try {
    throw new \Exception('This is a test error for PostHog tracking');
} catch (\Exception $e) {
    $errorId = $posthog->captureException($e, $user->email);
    $this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
}

API Endpoint (app/Http/Controllers/Api/ErrorTestController.php):

try {
    throw new \Exception('Test exception from critical operation');
} catch (\Throwable $e) {
    if ($shouldCapture) {
        $posthog->identify($user->email, $user->getPostHogProperties());
        $eventId = $posthog->captureException($e, $user->email);

        return response()->json([
            'error' => 'Operation failed',
            'error_id' => $eventId,
            'message' => "Error captured in PostHog. Reference ID: {$eventId}",
        ], 500);
    }
}

The /api/test-error endpoint demonstrates manual exception capture. Use ?capture=true to capture in PostHog, or ?capture=false to skip tracking.

Pages

Route Component PostHog Events
/ Login user_logged_in
/register Register user_signed_up
/dashboard Dashboard dashboard_viewed, feature flag checks
/burrito BurritoTracker burrito_considered
/profile Profile profile_viewed
/logout (route) user_logged_out

Project Structure

basics/laravel/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── Api/
│   │   │       ├── BurritoController.php   # Burrito API endpoint
│   │   │       └── ErrorTestController.php # Error testing endpoint
│   │   └── Livewire/
│   │       ├── Auth/
│   │       │   ├── Login.php               # Login component
│   │       │   └── Register.php            # Registration component
│   │       ├── BurritoTracker.php          # Burrito tracker component
│   │       ├── Dashboard.php               # Dashboard with feature flags
│   │       └── Profile.php                 # User profile component
│   ├── Models/
│   │   └── User.php                        # User model with PostHog properties
│   └── Services/
│       └── PostHogService.php              # PostHog wrapper service
├── database/
│   ├── migrations/                         # Database migrations
│   └── seeders/
│       └── DatabaseSeeder.php              # Seeds admin user
├── resources/
│   └── views/
│       ├── components/
│       │   └── layouts/
│       │       ├── app.blade.php           # Authenticated layout
│       │       └── guest.blade.php         # Guest layout
│       ├── errors/
│       │   ├── 404.blade.php               # Not found page
│       │   └── 500.blade.php               # Server error page
│       └── livewire/
│           ├── auth/
│           │   ├── login.blade.php         # Login form
│           │   └── register.blade.php      # Registration form
│           ├── burrito-tracker.blade.php   # Burrito tracker UI
│           ├── dashboard.blade.php         # Dashboard UI
│           └── profile.blade.php           # Profile UI
├── routes/
│   ├── web.php                             # Web routes (auth, pages)
│   └── api.php                             # API routes
└── config/
    └── posthog.php                         # PostHog configuration

Development Commands

# Start development server
php artisan serve

# Run migrations
php artisan migrate

# Seed database
php artisan migrate:fresh --seed

# Clear caches
php artisan optimize:clear

.env.example

APP_NAME="PostHog Laravel Example"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000

DB_CONNECTION=sqlite
# DB_DATABASE will use default database/database.sqlite

CACHE_DRIVER=file
CACHE_STORE=file

SESSION_DRIVER=file
SESSION_LIFETIME=120

POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_DISABLED=false

app/Http/Controllers/Api/BurritoController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\PostHogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class BurritoController extends Controller
{
    public function consider(Request $request, PostHogService $posthog): JsonResponse
    {
        $user = Auth::user();

        // Increment session counter
        $burritoCount = session('burrito_count', 0) + 1;
        session(['burrito_count' => $burritoCount]);

        // PostHog: Track event
        $posthog->identify($user->email, $user->getPostHogProperties());
        $posthog->capture($user->email, 'burrito_considered', [
            'total_considerations' => $burritoCount,
        ]);

        return response()->json([
            'success' => true,
            'count' => $burritoCount,
        ]);
    }
}

app/Http/Controllers/Api/ErrorTestController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\PostHogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class ErrorTestController extends Controller
{
    public function test(Request $request, PostHogService $posthog): JsonResponse
    {
        $shouldCapture = $request->query('capture', 'true') === 'true';
        $user = Auth::user();

        try {
            throw new \Exception('Test exception from critical operation');
        } catch (\Throwable $e) {
            if ($shouldCapture) {
                // Capture in PostHog
                $posthog->identify($user->email, $user->getPostHogProperties());
                $eventId = $posthog->captureException($e, $user->email);

                return response()->json([
                    'error' => 'Operation failed',
                    'error_id' => $eventId,
                    'message' => "Error captured in PostHog. Reference ID: {$eventId}",
                ], 500);
            }

            return response()->json([
                'error' => $e->getMessage(),
            ], 500);
        }
    }
}

app/Http/Controllers/Controller.php

<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Livewire/Auth/Login.php

<?php

namespace App\Http\Livewire\Auth;

use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Login extends Component
{
    public string $email = '';
    public string $password = '';
    public bool $remember = false;

    protected $rules = [
        'email' => 'required|email',
        'password' => 'required',
    ];

    public function login(PostHogService $posthog)
    {
        $this->validate();

        if (Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
            $user = Auth::user();

            // PostHog: Identify and track login
            $posthog->identify($user->email, $user->getPostHogProperties());
            $posthog->capture($user->email, 'user_logged_in', [
                'login_method' => 'password',
            ]);

            session()->regenerate();

            return redirect()->intended(route('dashboard'));
        }

        $this->addError('email', 'Invalid credentials');
    }

    public function render()
    {
        return view('livewire.auth.login')
            ->layout('components.layouts.guest');
    }
}

app/Http/Livewire/Auth/Register.php

<?php

namespace App\Http\Livewire\Auth;

use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Register extends Component
{
    public string $email = '';
    public string $password = '';
    public string $password_confirmation = '';

    protected $rules = [
        'email' => 'required|email|unique:users,email',
        'password' => 'required|min:6|confirmed',
    ];

    public function register(PostHogService $posthog)
    {
        $validated = $this->validate();

        $user = User::create([
            'email' => $validated['email'],
            'password' => bcrypt($validated['password']),
            'is_staff' => false,
        ]);

        // PostHog: Identify new user and track signup
        $posthog->identify($user->email, $user->getPostHogProperties());
        $posthog->capture($user->email, 'user_signed_up', [
            'signup_method' => 'form',
        ]);

        Auth::login($user);

        session()->flash('success', 'Account created successfully!');

        return redirect()->route('dashboard');
    }

    public function render()
    {
        return view('livewire.auth.register')
            ->layout('components.layouts.guest');
    }
}

app/Http/Livewire/BurritoTracker.php

<?php

namespace App\Http\Livewire;

use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class BurritoTracker extends Component
{
    public int $burritoCount = 0;

    public function mount()
    {
        $this->burritoCount = session('burrito_count', 0);
    }

    public function considerBurrito(PostHogService $posthog)
    {
        $this->burritoCount++;
        session(['burrito_count' => $this->burritoCount]);

        // PostHog: Track burrito consideration
        $user = Auth::user();
        $posthog->identify($user->email, $user->getPostHogProperties());
        $posthog->capture($user->email, 'burrito_considered', [
            'total_considerations' => $this->burritoCount,
        ]);

        $this->dispatch('burrito-considered');
    }

    public function render()
    {
        return view('livewire.burrito-tracker')
            ->layout('components.layouts.app');
    }
}

app/Http/Livewire/Dashboard.php

<?php

namespace App\Http\Livewire;

use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Dashboard extends Component
{
    public bool $showNewFeature = false;
    public $featureConfig = null;
    public ?string $errorMessage = null;
    public ?string $successMessage = null;

    public function mount(PostHogService $posthog)
    {
        $user = Auth::user();

        // PostHog: Track dashboard view
        $posthog->capture($user->email, 'dashboard_viewed', [
            'is_staff' => $user->is_staff,
        ]);

        // Check feature flag
        $this->showNewFeature = $posthog->isFeatureEnabled(
            'new-dashboard-feature',
            $user->email,
            $user->getPostHogProperties()
        ) ?? false;

        // Get feature flag payload
        $this->featureConfig = $posthog->getFeatureFlagPayload(
            'new-dashboard-feature',
            $user->email
        );
    }

    public function testErrorWithCapture(PostHogService $posthog)
    {
        $user = Auth::user();

        try {
            // Simulate an error
            throw new \Exception('This is a test error for PostHog tracking');
        } catch (\Exception $e) {
            // Capture the exception in PostHog
            $errorId = $posthog->captureException($e, $user->email);

            $this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
            $this->errorMessage = null;
        }
    }

    public function testErrorWithoutCapture()
    {
        try {
            // Simulate an error without capturing
            throw new \Exception('This error was NOT sent to PostHog');
        } catch (\Exception $e) {
            $this->errorMessage = "Error occurred but NOT captured in PostHog: " . $e->getMessage();
            $this->successMessage = null;
        }
    }

    public function render()
    {
        return view('livewire.dashboard')
            ->layout('components.layouts.app');
    }
}

app/Http/Livewire/Profile.php

<?php

namespace App\Http\Livewire;

use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class Profile extends Component
{
    public ?string $errorMessage = null;
    public ?string $successMessage = null;

    public function mount(PostHogService $posthog)
    {
        $user = Auth::user();

        // PostHog: Track profile view
        $posthog->capture($user->email, 'profile_viewed');
    }

    public function testErrorWithCapture(PostHogService $posthog)
    {
        $user = Auth::user();

        try {
            // Simulate an error
            throw new \Exception('This is a test error for PostHog tracking');
        } catch (\Exception $e) {
            // Capture the exception in PostHog
            $errorId = $posthog->captureException($e, $user->email);

            $this->successMessage = "Error captured in PostHog! Error ID: {$errorId}";
            $this->errorMessage = null;
        }
    }

    public function testErrorWithoutCapture()
    {
        try {
            // Simulate an error without capturing
            throw new \Exception('This error was NOT sent to PostHog');
        } catch (\Exception $e) {
            $this->errorMessage = "Error occurred but NOT captured in PostHog: " . $e->getMessage();
            $this->successMessage = null;
        }
    }

    public function render()
    {
        return view('livewire.profile')
            ->layout('components.layouts.app');
    }
}

app/Models/User.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'email',
        'password',
        'is_staff',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'password' => 'hashed',
            'is_staff' => 'boolean',
        ];
    }

    /**
     * Get PostHog person properties for this user.
     */
    public function getPostHogProperties(): array
    {
        return [
            'email' => $this->email,
            'is_staff' => $this->is_staff,
            'date_joined' => $this->created_at->toISOString(),
        ];
    }
}

app/Services/PostHogService.php

<?php

namespace App\Services;

use PostHog\PostHog;
use Illuminate\Support\Facades\Auth;

class PostHogService
{
    protected static $initialized = false;

    public function __construct()
    {
        if (config('posthog.disabled')) {
            return;
        }

        // Initialize PostHog once
        if (!self::$initialized) {
            PostHog::init(
                config('posthog.api_key'),
                [
                    'host' => config('posthog.host'),
                    'debug' => config('posthog.debug'),
                ]
            );
            self::$initialized = true;
        }
    }

    public function identify(string $distinctId, array $properties = []): void
    {
        if (config('posthog.disabled')) {
            return;
        }

        PostHog::identify([
            'distinctId' => $distinctId,
            'properties' => $properties,
        ]);
    }

    public function capture(string $distinctId, string $event, array $properties = []): void
    {
        if (config('posthog.disabled')) {
            return;
        }

        PostHog::capture([
            'distinctId' => $distinctId,
            'event' => $event,
            'properties' => $properties,
        ]);
    }

    public function captureException(\Throwable $exception, ?string $distinctId = null): ?string
    {
        if (config('posthog.disabled')) {
            return null;
        }

        $distinctId = $distinctId ?? Auth::user()?->email ?? 'anonymous';

        $eventId = uniqid('error_', true);

        PostHog::captureException($exception, $distinctId, [
            'error_id' => $eventId,
        ]);

        return $eventId;
    }

    public function isFeatureEnabled(string $key, string $distinctId, array $properties = []): ?bool
    {
        if (config('posthog.disabled')) {
            return false;
        }

        return PostHog::isFeatureEnabled($key, $distinctId, $properties);
    }

    public function getFeatureFlagPayload(string $key, string $distinctId)
    {
        if (config('posthog.disabled')) {
            return null;
        }

        return PostHog::getFeatureFlagPayload($key, $distinctId);
    }
}

artisan

#!/usr/bin/env php
<?php

define('LARAVEL_START', microtime(true));

/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
*/

require __DIR__.'/vendor/autoload.php';

$app = require_once __DIR__.'/bootstrap/app.php';

/*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
*/

$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);

$status = $kernel->handle(
    $input = new Symfony\Component\Console\Input\ArgvInput,
    new Symfony\Component\Console\Output\ConsoleOutput
);

$kernel->terminate($input, $status);

exit($status);

bootstrap/app.php

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        //
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

config/app.php

<?php

return [
    'name' => env('APP_NAME', 'PostHog Laravel Example'),
    'env' => env('APP_ENV', 'production'),
    'debug' => (bool) env('APP_DEBUG', false),
    'url' => env('APP_URL', 'http://localhost'),
    'timezone' => 'UTC',
    'locale' => 'en',
    'fallback_locale' => 'en',
    'key' => env('APP_KEY'),
    'cipher' => 'AES-256-CBC',

    'providers' => [
        // Laravel Framework Service Providers
        Illuminate\Auth\AuthServiceProvider::class,
        Illuminate\Broadcasting\BroadcastServiceProvider::class,
        Illuminate\Bus\BusServiceProvider::class,
        Illuminate\Cache\CacheServiceProvider::class,
        Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
        Illuminate\Cookie\CookieServiceProvider::class,
        Illuminate\Database\DatabaseServiceProvider::class,
        Illuminate\Encryption\EncryptionServiceProvider::class,
        Illuminate\Filesystem\FilesystemServiceProvider::class,
        Illuminate\Foundation\Providers\FoundationServiceProvider::class,
        Illuminate\Hashing\HashServiceProvider::class,
        Illuminate\Mail\MailServiceProvider::class,
        Illuminate\Notifications\NotificationServiceProvider::class,
        Illuminate\Pagination\PaginationServiceProvider::class,
        Illuminate\Pipeline\PipelineServiceProvider::class,
        Illuminate\Queue\QueueServiceProvider::class,
        Illuminate\Redis\RedisServiceProvider::class,
        Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
        Illuminate\Session\SessionServiceProvider::class,
        Illuminate\Translation\TranslationServiceProvider::class,
        Illuminate\Validation\ValidationServiceProvider::class,
        Illuminate\View\ViewServiceProvider::class,
    ],

    'aliases' => [
        'App' => Illuminate\Support\Facades\App::class,
        'Auth' => Illuminate\Support\Facades\Auth::class,
        'Blade' => Illuminate\Support\Facades\Blade::class,
        'Cache' => Illuminate\Support\Facades\Cache::class,
        'Config' => Illuminate\Support\Facades\Config::class,
        'DB' => Illuminate\Support\Facades\DB::class,
        'Hash' => Illuminate\Support\Facades\Hash::class,
        'Request' => Illuminate\Support\Facades\Request::class,
        'Route' => Illuminate\Support\Facades\Route::class,
        'Schema' => Illuminate\Support\Facades\Schema::class,
        'Session' => Illuminate\Support\Facades\Session::class,
        'View' => Illuminate\Support\Facades\View::class,
    ],
];

config/auth.php

<?php

return [
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    'password_timeout' => 10800,
];

config/database.php

<?php

return [
    'default' => env('DB_CONNECTION', 'sqlite'),

    'connections' => [
        'sqlite' => [
            'driver' => 'sqlite',
            'url' => env('DATABASE_URL'),
            'database' => env('DB_DATABASE', database_path('database.sqlite')),
            'prefix' => '',
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
        ],

        'mysql' => [
            'driver' => 'mysql',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'strict' => true,
            'engine' => null,
        ],
    ],

    'migrations' => 'migrations',
];

config/posthog.php

<?php

return [
    'api_key' => env('POSTHOG_PROJECT_TOKEN', ''),
    'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
    'disabled' => env('POSTHOG_DISABLED', false),
    'debug' => env('APP_DEBUG', false),
];

config/session.php

<?php

return [
    'driver' => env('SESSION_DRIVER', 'file'),
    'lifetime' => env('SESSION_LIFETIME', 120),
    'expire_on_close' => false,
    'encrypt' => false,
    'files' => storage_path('framework/sessions'),
    'connection' => null,
    'table' => 'sessions',
    'store' => null,
    'lottery' => [2, 100],
    'cookie' => env('SESSION_COOKIE', 'laravel_session'),
    'path' => '/',
    'domain' => env('SESSION_DOMAIN'),
    'secure' => env('SESSION_SECURE_COOKIE'),
    'http_only' => true,
    'same_site' => 'lax',
];

database/migrations/2024_01_01_000000_create_users_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('email')->unique();
            $table->string('password');
            $table->boolean('is_staff')->default(false);
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        User::firstOrCreate(
            ['email' => 'admin@example.com'],
            [
                'password' => bcrypt('admin'),
                'is_staff' => true,
            ]
        );
    }
}

IMPLEMENTATION.md

Laravel PostHog Example - Implementation Summary

This document summarizes the implementation of the Laravel PostHog example application, ported from the Flask version.

✅ Completed Implementation

Core Application Structure

Models & Database

  • ✅ User model with PostHog properties helper method
  • ✅ User migration with is_staff field
  • ✅ Database seeder for default admin user
  • ✅ SQLite database configuration

PostHog Integration

  • ✅ PostHog configuration file (config/posthog.php)
  • ✅ PostHogService class with all core methods:
    • identify() - User identification
    • capture() - Event tracking
    • captureException() - Error tracking
    • isFeatureEnabled() - Feature flag checking
    • getFeatureFlagPayload() - Feature flag payload retrieval

Authentication (Livewire Components)

  • ✅ Login component with PostHog tracking
  • ✅ Register component with PostHog tracking
  • ✅ Logout route with PostHog tracking

Core Features (Livewire Components)

  • ✅ Dashboard - Feature flag demonstration
  • ✅ Burrito Tracker - Custom event tracking
  • ✅ Profile - Error tracking demonstration

API Controllers

  • ✅ BurritoController - API endpoint for burrito tracking
  • ✅ ErrorTestController - Manual error capture demonstration

Views & Layouts

  • ✅ App layout (authenticated users)
  • ✅ Guest layout (unauthenticated users)
  • ✅ All Livewire view files with inline styling
  • ✅ Error pages (404, 500)

Routes

  • ✅ Web routes (authentication, dashboard, burrito, profile, logout)
  • ✅ API routes (burrito tracking, error testing)

Configuration

  • ✅ Environment example file
  • ✅ Composer.json with dependencies
  • ✅ Laravel config files (app, auth, database, session)
  • ✅ .gitignore

Documentation

  • ✅ Comprehensive README
  • ✅ Implementation plan (php-plan.md)

📋 Features Implemented

1. User Authentication
  • Login with PostHog identification
  • Registration with PostHog tracking
  • Logout with event capture
  • Session management
2. PostHog Analytics
  • User identification on login/signup
  • Person properties (email, is_staff, date_joined)
  • Custom event tracking (burrito considerations)
  • Dashboard views tracking
3. Feature Flags
  • Feature flag checking (new-dashboard-feature)
  • Feature flag payload retrieval
  • Conditional UI rendering based on flags
4. Error Tracking
  • Manual exception capture
  • Error ID generation
  • Test endpoint with optional capture (?capture=true/false)
5. UI/UX
  • Responsive layouts
  • Flash messages for user feedback
  • Livewire reactivity for burrito counter
  • Loading states on buttons

🎯 PostHog Integration Points

Feature Location PostHog Method
User Login Login.php:23-27 identify() + capture()
User Signup Register.php:29-32 identify() + capture()
User Logout web.php:25 capture()
Dashboard View Dashboard.php:18 capture()
Feature Flag Check Dashboard.php:21-25 isFeatureEnabled()
Feature Flag Payload Dashboard.php:28-31 getFeatureFlagPayload()
Burrito Tracking BurritoTracker.php:22-24 identify() + capture()
Profile View Profile.php:14 capture()
Error Capture ErrorTestController.php:22-24 identify() + captureException()

📁 File Structure

basics/laravel/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   ├── Controller.php
│   │   │   └── Api/
│   │   │       ├── BurritoController.php
│   │   │       └── ErrorTestController.php
│   │   └── Livewire/
│   │       ├── Auth/
│   │       │   ├── Login.php
│   │       │   └── Register.php
│   │       ├── Dashboard.php
│   │       ├── BurritoTracker.php
│   │       └── Profile.php
│   ├── Models/
│   │   └── User.php
│   └── Services/
│       └── PostHogService.php
├── config/
│   ├── app.php
│   ├── auth.php
│   ├── database.php
│   ├── posthog.php
│   └── session.php
├── database/
│   ├── migrations/
│   │   └── 2024_01_01_000000_create_users_table.php
│   └── seeders/
│       └── DatabaseSeeder.php
├── resources/
│   └── views/
│       ├── components/
│       │   └── layouts/
│       │       ├── app.blade.php
│       │       └── guest.blade.php
│       ├── livewire/
│       │   ├── auth/
│       │   │   ├── login.blade.php
│       │   │   └── register.blade.php
│       │   ├── dashboard.blade.php
│       │   ├── burrito-tracker.blade.php
│       │   └── profile.blade.php
│       └── errors/
│           ├── 404.blade.php
│           └── 500.blade.php
├── routes/
│   ├── api.php
│   └── web.php
├── .env.example
├── .gitignore
├── composer.json
├── IMPLEMENTATION.md
└── README.md

🔄 Flask to Laravel Mapping

Flask Component Laravel Equivalent
Flask-Login Laravel Auth + Livewire
Flask-SQLAlchemy Eloquent ORM
Jinja2 Templates Blade Templates + Livewire
Blueprint routes Route definitions
@app.route decorators Route::get/post
session session() helper
flash() session()->flash()
@login_required Route::middleware('auth')
request.form Livewire properties
render_template() view() or Livewire render()
jsonify() response()->json()
SQLAlchemy models Eloquent models

🚀 Next Steps for Production

To make this a production-ready application:

  1. Install via Composer: Run full Laravel installation
  2. Environment: Generate APP_KEY with php artisan key:generate
  3. Database: Run migrations with php artisan migrate --seed
  4. Assets: Set up Vite for asset compilation
  5. Middleware: Add CSRF protection middleware
  6. Validation: Add form request classes
  7. Testing: Implement PHPUnit tests
  8. Caching: Configure Redis/Memcached
  9. Queue: Set up queue workers for PostHog events
  10. Deployment: Configure for production server

📝 Notes

  • This implementation uses inline CSS (matching Flask example) instead of Tailwind compilation
  • Livewire provides reactivity without separate JavaScript files
  • PostHog service is dependency-injected into components/controllers
  • Manual error capture pattern matches Flask implementation
  • Session-based burrito counter (same as Flask)
  • Default admin account: admin@example.com (mailto:admin@example.com) / admin

🎓 Learning Resources


Implementation Date: January 2026 Laravel Version: 11.x Livewire Version: 3.x PostHog PHP SDK: 3.x


public/index.php

<?php

use Illuminate\Http\Request;

define('LARAVEL_START', microtime(true));

// Suppress PHP 8.5 deprecation warnings for development
error_reporting(E_ALL & ~E_DEPRECATED);

// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
    require $maintenance;
}

// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';

// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
    ->handleRequest(Request::capture());

resources/views/components/layouts/app.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ $title ?? 'PostHog Laravel Example' }}</title>

    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        nav {
            background: #1d4ed8;
            padding: 15px 20px;
            margin-bottom: 30px;
        }
        nav a {
            color: white;
            text-decoration: none;
            margin-right: 20px;
        }
        nav a:hover {
            text-decoration: underline;
        }
        .nav-right {
            float: right;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        button:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
        button.danger, .btn-danger {
            background: #dc2626;
        }
        button.danger:hover, .btn-danger:hover {
            background: #b91c1c;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        .message-error {
            background: #fee2e2;
            color: #dc2626;
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 15px;
        }
        .message-success {
            background: #d1fae5;
            color: #059669;
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 15px;
        }
        .feature-flag {
            background: #fef3c7;
            border: 2px dashed #f59e0b;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
        }
        pre {
            background: #1e293b;
            color: #e2e8f0;
            padding: 16px;
            border-radius: 8px;
            overflow-x: auto;
            font-size: 13px;
        }
        .count {
            font-size: 48px;
            font-weight: bold;
            color: #1d4ed8;
            text-align: center;
            padding: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 16px 0;
        }
        th, td {
            padding: 12px;
            text-align: left;
            border-bottom: 1px solid #eee;
        }
        th {
            background: #f8fafc;
            font-weight: 600;
        }
        label {
            display: block;
            margin-bottom: 5px;
            font-weight: 500;
        }
        .text-sm {
            font-size: 14px;
        }
        .text-gray {
            color: #666;
        }
        .mb-4 {
            margin-bottom: 16px;
        }
    </style>
    @livewireStyles
</head>
<body>
    @auth
    <nav>
        <a href="{{ route('dashboard') }}">Dashboard</a>
        <a href="{{ route('burrito') }}">Burrito</a>
        <a href="{{ route('profile') }}">Profile</a>
        <span class="nav-right">
            <span style="margin-right: 15px;">{{ auth()->user()->email }}</span>
            <form method="POST" action="{{ route('logout') }}" style="display: inline;">
                @csrf
                <button type="submit" style="background: none; padding: 0; color: white; text-decoration: underline;">Logout</button>
            </form>
        </span>
    </nav>
    @endauth

    <div class="container">
        @if (session('success'))
            <div class="message-success">
                {{ session('success') }}
            </div>
        @endif

        @if (session('error'))
            <div class="message-error">
                {{ session('error') }}
            </div>
        @endif

        {{ $slot }}
    </div>

    @livewireScripts
</body>
</html>

resources/views/components/layouts/guest.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ $title ?? 'PostHog Laravel Example' }}</title>

    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
            width: 100%;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            font-weight: 500;
        }
        .error {
            color: #dc2626;
            font-size: 13px;
            margin-top: -10px;
            margin-bottom: 10px;
        }
        .text-sm {
            font-size: 14px;
        }
        .text-gray {
            color: #666;
        }
        a {
            color: #1d4ed8;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
            font-size: 13px;
        }
        pre {
            background: #1e293b;
            color: #e2e8f0;
            padding: 16px;
            border-radius: 8px;
            overflow-x: auto;
            font-size: 13px;
            margin-top: 10px;
        }
        ul {
            margin-left: 20px;
        }
    </style>
    @livewireStyles
</head>
<body>
    <div class="container">
        {{ $slot }}
    </div>

    @livewireScripts
</body>
</html>

resources/views/errors/404.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Page Not Found - PostHog Laravel Example</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 40px;
            margin-top: 50px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            text-align: center;
        }
        h1 {
            font-size: 72px;
            color: #1d4ed8;
            margin-bottom: 15px;
        }
        h2 {
            font-size: 24px;
            color: #333;
            margin-bottom: 15px;
        }
        p {
            color: #666;
            margin-bottom: 25px;
        }
        .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            text-decoration: none;
            display: inline-block;
        }
        .btn:hover {
            background: #1e40af;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="card">
            <h1>404</h1>
            <h2>Page Not Found</h2>
            <p>The page you're looking for doesn't exist.</p>
            <a href="/" class="btn">Go Home</a>
        </div>
    </div>
</body>
</html>

resources/views/errors/500.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Server Error - PostHog Laravel Example</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 40px;
            margin-top: 50px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            text-align: center;
        }
        h1 {
            font-size: 72px;
            color: #dc2626;
            margin-bottom: 15px;
        }
        h2 {
            font-size: 24px;
            color: #333;
            margin-bottom: 15px;
        }
        p {
            color: #666;
            margin-bottom: 25px;
        }
        .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            text-decoration: none;
            display: inline-block;
        }
        .btn:hover {
            background: #1e40af;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="card">
            <h1>500</h1>
            <h2>Internal Server Error</h2>
            <p>Something went wrong on our end.</p>
            <a href="/" class="btn">Go Home</a>
        </div>
    </div>
</body>
</html>

resources/views/livewire/auth/login.blade.php

<div>
    <div class="card">
        <h1>Welcome to PostHog Laravel Example</h1>
        <p class="text-gray mb-4">This example demonstrates how to integrate PostHog with a Laravel application.</p>

        <form wire:submit="login">
            <label for="email">Email</label>
            <input
                type="email"
                id="email"
                wire:model="email"
                required
            >
            @error('email') <div class="error">{{ $message }}</div> @enderror

            <label for="password">Password</label>
            <input
                type="password"
                id="password"
                wire:model="password"
                required
            >
            @error('password') <div class="error">{{ $message }}</div> @enderror

            <div style="margin-bottom: 15px;">
                <label style="display: inline; font-weight: normal;">
                    <input type="checkbox" wire:model="remember" style="width: auto; margin-right: 5px;">
                    Remember me
                </label>
            </div>

            <button type="submit">Login</button>
        </form>

        <p style="margin-top: 16px;" class="text-sm text-gray">
            Don't have an account? <a href="{{ route('register') }}">Sign up here</a>
        </p>
        <p class="text-sm text-gray">
            <strong>Tip:</strong> Default credentials are admin@example.com/admin
        </p>
    </div>

    <div class="card">
        <h2>Features Demonstrated</h2>
        <ul class="text-gray">
            <li>User registration and identification</li>
            <li>Event tracking</li>
            <li>Feature flags</li>
            <li>Error tracking</li>
        </ul>
    </div>
</div>

resources/views/livewire/auth/register.blade.php

<div>
    <div class="card">
        <h1>Create an Account</h1>
        <p class="text-gray mb-4">Sign up to explore the PostHog Laravel integration example.</p>

        <form wire:submit="register">
            <label for="email">Email *</label>
            <input
                type="email"
                id="email"
                wire:model="email"
                required
            >
            @error('email') <div class="error">{{ $message }}</div> @enderror

            <label for="password">Password *</label>
            <input
                type="password"
                id="password"
                wire:model="password"
                required
            >
            @error('password') <div class="error">{{ $message }}</div> @enderror

            <label for="password_confirmation">Confirm Password *</label>
            <input
                type="password"
                id="password_confirmation"
                wire:model="password_confirmation"
                required
            >

            <button type="submit">Sign Up</button>
        </form>

        <p style="margin-top: 16px;" class="text-sm text-gray">
            Already have an account? <a href="{{ route('login') }}">Login here</a>
        </p>
    </div>

    <div class="card">
        <h2>PostHog Integration</h2>
        <p class="text-gray">When you sign up, the following PostHog events are captured:</p>
        <ul class="text-gray" style="margin-top: 10px;">
            <li><code>identify()</code> - Associates your email with the user</li>
            <li><code>capture()</code> - Sets person properties (email, etc.)</li>
            <li><code>user_signed_up</code> event - Tracks the signup action</li>
        </ul>

        <h3 style="margin-top: 20px;">Code Example</h3>
        <pre>// After creating the user
$posthog->identify($user->email, $user->getPostHogProperties());
$posthog->capture($user->email, 'user_signed_up', [
    'signup_method' => 'form'
]);</pre>
    </div>
</div>

resources/views/livewire/burrito-tracker.blade.php

<div>
    <div class="card">
        <h1>Burrito Consideration Tracker</h1>
        <p class="text-gray mb-4">This page demonstrates custom event tracking with PostHog.</p>

        <div class="count">{{ $burritoCount }}</div>
        <p style="text-align: center; color: #666; margin-bottom: 20px;">Times you've considered a burrito</p>

        <div style="text-align: center;">
            <button
                wire:click="considerBurrito"
                wire:loading.attr="disabled"
            >
                <span wire:loading.remove>Consider a Burrito</span>
                <span wire:loading>Considering...</span>
            </button>
        </div>
    </div>

    <div class="card">
        <h3>Code Example</h3>
        <pre>// Livewire component method
public function considerBurrito(PostHogService $posthog)
{
    $this->burritoCount++;
    session(['burrito_count' => $this->burritoCount]);

    $user = Auth::user();
    $posthog->identify($user->email, $user->getPostHogProperties());
    $posthog->capture($user->email, 'burrito_considered', [
        'total_considerations' => $this->burritoCount,
    ]);
}</pre>
    </div>
</div>

resources/views/livewire/dashboard.blade.php

<div>
    <div class="card">
        <h1>Dashboard</h1>
        <p class="text-gray">Welcome back, {{ auth()->user()->email }}!</p>
    </div>

    <div class="card">
        <h2>Error Tracking Demo</h2>
        <p class="text-gray">Test manual exception capture in PostHog. These buttons trigger errors in the context of your logged-in user.</p>

        @if($successMessage)
            <div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 12px; border-radius: 4px; margin: 15px 0;">
                {{ $successMessage }}
            </div>
        @endif

        @if($errorMessage)
            <div style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 12px; border-radius: 4px; margin: 15px 0;">
                {{ $errorMessage }}
            </div>
        @endif

        <div style="display: flex; gap: 10px; margin-top: 15px;">
            <button wire:click="testErrorWithCapture" class="btn" style="background: #dc3545; color: white;">
                Capture Error in PostHog
            </button>
            <button wire:click="testErrorWithoutCapture" class="btn" style="background: #c82333; color: white;">
                Skip Capture in PostHog
            </button>
        </div>

        <h3 style="margin-top: 20px;">Code Example</h3>
        <pre>try {
    // Critical operation that might fail
    processPayment();
} catch (\Throwable $e) {
    // Manually capture this specific exception
    $errorId = $posthog->captureException($e, $user->email);

    return response()->json([
        'error' => 'Operation failed',
        'error_id' => $errorId
    ], 500);
}</pre>
        <p class="text-gray" style="margin-top: 10px;">This demonstrates manual exception capture where you have control over whether errors are sent to PostHog.</p>
    </div>

    <div class="card">
        <h2>Feature Flags</h2>

        @if($showNewFeature)
            <div class="feature-flag">
                <strong>New Feature Enabled!</strong>
                <p style="margin-top: 10px;">You're seeing this because the <code>new-dashboard-feature</code> flag is enabled for you.</p>

                @if($featureConfig)
                    <p style="margin-top: 15px;"><strong>Feature Configuration:</strong></p>
                    <pre>{{ json_encode($featureConfig, JSON_PRETTY_PRINT) }}</pre>
                @endif
            </div>
        @else
            <p class="text-gray">The <code>new-dashboard-feature</code> flag is not enabled for your account.</p>
        @endif

        <h3 style="margin-top: 20px;">Code Example</h3>
        <pre>// Check if feature flag is enabled
$showNewFeature = $posthog->isFeatureEnabled(
    'new-dashboard-feature',
    $user->email,
    $user->getPostHogProperties()
);

// Get feature flag payload
$featureConfig = $posthog->getFeatureFlagPayload(
    'new-dashboard-feature',
    $user->email
);</pre>
    </div>

</div>

resources/views/livewire/profile.blade.php

<div>
    <div class="card">
        <h1>Your Profile</h1>
        <p class="text-gray mb-4">This page demonstrates error tracking with PostHog.</p>

        <table>
            <tr>
                <th>Email</th>
                <td>{{ auth()->user()->email }}</td>
            </tr>
            <tr>
                <th>Date Joined</th>
                <td>{{ auth()->user()->created_at->format('Y-m-d H:i') }}</td>
            </tr>
            <tr>
                <th>Staff Status</th>
                <td>{{ auth()->user()->is_staff ? 'Yes' : 'No' }}</td>
            </tr>
        </table>
    </div>

    <div class="card">
        <h2>Error Tracking Demo</h2>
        <p class="text-gray">Test manual exception capture in PostHog. These buttons trigger errors in the context of your logged-in user.</p>

        @if($successMessage)
            <div style="background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 12px; border-radius: 4px; margin: 15px 0;">
                {{ $successMessage }}
            </div>
        @endif

        @if($errorMessage)
            <div style="background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 12px; border-radius: 4px; margin: 15px 0;">
                {{ $errorMessage }}
            </div>
        @endif

        <div style="display: flex; gap: 10px; margin-top: 15px;">
            <button wire:click="testErrorWithCapture" class="btn" style="background: #dc3545; color: white;">
                Capture Error in PostHog
            </button>
            <button wire:click="testErrorWithoutCapture" class="btn" style="background: #c82333; color: white;">
                Skip Capture in PostHog
            </button>
        </div>

        <p class="text-gray" style="margin-top: 15px;">
            This demonstrates manual exception capture where you have control over whether errors are sent to PostHog.
        </p>
    </div>

    <div class="card">
        <h3>Code Example</h3>
        <pre>try {
    throw new \Exception('Test exception from critical operation');
} catch (\Throwable $e) {
    // Capture exception with user context
    $posthog->identify($user->email, $user->getPostHogProperties());
    $eventId = $posthog->captureException($e, $user->email);

    return response()->json([
        'error' => 'Operation failed',
        'error_id' => $eventId,
        'message' => "Error captured in PostHog. Reference ID: {$eventId}"
    ], 500);
}</pre>
    </div>
</div>

routes/api.php

<?php

use App\Http\Controllers\Api\BurritoController;
use App\Http\Controllers\Api\ErrorTestController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/burrito/consider', [BurritoController::class, 'consider']);
    Route::post('/test-error', [ErrorTestController::class, 'test']);
});

routes/web.php

<?php

use App\Http\Livewire\Auth\Login;
use App\Http\Livewire\Auth\Register;
use App\Http\Livewire\BurritoTracker;
use App\Http\Livewire\Dashboard;
use App\Http\Livewire\Profile;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

// Guest routes
Route::middleware('guest')->group(function () {
    Route::get('/', Login::class)->name('login');
    Route::get('/register', Register::class)->name('register');
});

// Authenticated routes
Route::middleware('auth')->group(function () {
    Route::get('/dashboard', Dashboard::class)->name('dashboard');
    Route::get('/burrito', BurritoTracker::class)->name('burrito');
    Route::get('/profile', Profile::class)->name('profile');

    Route::post('/logout', function (PostHogService $posthog) {
        $user = Auth::user();

        // PostHog: Track logout
        $posthog->capture($user->email, 'user_logged_out');

        Auth::logout();
        request()->session()->invalidate();
        request()->session()->regenerateToken();

        return redirect('/');
    })->name('logout');
});

references/EXAMPLE-next-app-router.md

PostHog next-app-router Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/next-app-router


README.md

PostHog Next.js app router example

This is a Next.js App Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product analytics: Track user events and behaviors
  • Session replay: Record and replay user sessions
  • Error tracking: Capture and track errors
  • User authentication: Demo login system with PostHog user identification
  • Server-side & Client-side tracking: Examples of both tracking methods
  • Reverse proxy: PostHog ingestion through Next.js rewrites

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env.local file in the root directory:

NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:3000 with your browser to see the app.

Project structure

src/
├── app/
│   ├── api/
│   │   └── auth/
│   │       └── login/
│   │           └── route.ts   # Login API with server-side tracking
│   ├── burrito/
│   │   └── page.tsx           # Demo feature page with event tracking
│   ├── profile/
│   │   └── page.tsx           # User profile with error tracking demo
│   ├── layout.tsx             # Root layout with providers
│   ├── page.tsx               # Home/Login page
│   └── globals.css            # Global styles
├── components/
│   └── Header.tsx             # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx        # Authentication context with PostHog integration
└── lib/
    └── posthog-server.ts      # Server-side PostHog client

instrumentation-client.ts      # Client-side PostHog initialization

Key integration points

Client-side initialization (instrumentation-client.ts)
import posthog from "posthog-js"

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
  api_host: "/ingest",
  ui_host: "https://us.posthog.com",
  defaults: '2026-01-30',
  capture_exceptions: true,
  debug: process.env.NODE_ENV === "development",
});
User identification (AuthContext.tsx)
posthog.identify(username, {
  username: username,
});
Event tracking (burrito/page.tsx)
posthog.capture('burrito_considered', {
  total_considerations: count,
  username: username,
});
Error tracking (profile/page.tsx)
posthog.captureException(error);
Server-side tracking (app/api/auth/login/route.ts)
const posthog = getPostHogClient();
posthog.capture({
  distinctId: username,
  event: 'server_login',
  properties: { ... }
});

App router differences from pages router

This example uses Next.js App Router instead of Pages Router. Key differences:

  1. File-based routing: Pages in src/app/ instead of src/pages/
  2. layout.tsx: Root layout component wraps all pages
  3. API Routes: Located in src/app/api/ with route.ts files
  4. 'use client': Client components need explicit directive
  5. useRouter: From next/navigation instead of next/router
  6. Metadata: Exported from layout/page instead of Head component
  7. Server Components: Components are server-side by default

Learn more

Deploy on Vercel

The easiest way to deploy your Next.js app is to use the Vercel Platform.

Check out the Next.js deployment documentation for more details.


.env.example

# PostHog Configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

instrumentation-client.ts

import posthog from "posthog-js"

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
  api_host: "/ingest",
  ui_host: "https://us.posthog.com",
  // Include the defaults option as required by PostHog
  defaults: '2026-01-30',
  // Enables capturing unhandled exceptions via Error Tracking
  capture_exceptions: true,
  // Turn on debug in development mode
  debug: process.env.NODE_ENV === "development",
});

//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  async rewrites() {
    return [
      {
        source: "/ingest/static/:path*",
        destination: "https://us-assets.i.posthog.com/static/:path*",
      },
      {
        source: "/ingest/array/:path*",
        destination: "https://us-assets.i.posthog.com/array/:path*",
      },
      {
        source: "/ingest/:path*",
        destination: "https://us.i.posthog.com/:path*",
      },
    ];
  },
  // This is required to support PostHog trailing slash API requests
  skipTrailingSlashRedirect: true,
};

export default nextConfig;

src/app/api/auth/login/route.ts

import { NextResponse } from 'next/server';
import { getPostHogClient } from '@/lib/posthog-server';

const users = new Map<string, { username: string; burritoConsiderations: number }>();

export async function POST(request: Request) {
  const { username, password } = await request.json();

  if (!username || !password) {
    return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
  }

  let user = users.get(username);
  const isNewUser = !user;
  
  if (!user) {
    user = { username, burritoConsiderations: 0 };
    users.set(username, user);
  }

  // Capture server-side login event
  const posthog = getPostHogClient();
  posthog.capture({
    distinctId: username,
    event: 'server_login',
    properties: {
      isNewUser: isNewUser,
      source: 'api'
    }
  });

  // Identify user on server side
  posthog.identify({
    distinctId: username,
    properties: {
      username: username,
      createdAt: isNewUser ? new Date().toISOString() : undefined
    }
  });

  // This handler is short-lived; flush so the enqueued events send before it returns
  await posthog.flush();

  return NextResponse.json({ success: true, user });
}

src/app/burrito/page.tsx

'use client';

import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';

export default function BurritoPage() {
  const { user, incrementBurritoConsiderations } = useAuth();
  const router = useRouter();
  const [hasConsidered, setHasConsidered] = useState(false);

  // Redirect to home if not logged in
  if (!user) {
    router.push('/');
    return null;
  }

  const handleConsideration = () => {
    incrementBurritoConsiderations();
    setHasConsidered(true);
    setTimeout(() => setHasConsidered(false), 2000);
    
    // Capture burrito consideration event
    posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    });
  };

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>
      
      <div style={{ textAlign: 'center' }}>
        <button 
          onClick={handleConsideration}
          className="btn-burrito"
        >
          I have considered the burrito potential
        </button>
        
        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>
      
      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  );
}

src/app/layout.tsx

import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import Header from "@/components/Header";

export const metadata: Metadata = {
  title: "Burrito Consideration App",
  description: "Consider the potential of burritos",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>
          <Header />
          <main>{children}</main>
        </AuthProvider>
      </body>
    </html>
  );
}

src/app/page.tsx

'use client';

import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';

export default function Home() {
  const { user, login } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    
    try {
      const success = await login(username, password);
      if (success) {
        setUsername('');
        setPassword('');
      } else {
        setError('Please provide both username and password');
      }
    } catch (err) {
      console.error('Login failed:', err);
      setError('An error occurred during login');
    }
  };

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>
      
      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>
        
        {error && <p className="error">{error}</p>}
        
        <button type="submit" className="btn-primary">Sign In</button>
      </form>
      
      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  );
}

src/app/profile/page.tsx

'use client';

import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';

export default function ProfilePage() {
  const { user } = useAuth();
  const router = useRouter();

  // Redirect to home if not logged in
  if (!user) {
    router.push('/');
    return null;
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      posthog.captureException(err);
      console.error('Captured error:', err);
      alert('Error captured and sent to PostHog!');
    }
  };

  return (
    <div className="container">
      <h1>User Profile</h1>
      
      <div className="stats">
        <h2>Your Information</h2>
        <p><strong>Username:</strong> {user.username}</p>
        <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
      </div>
      
      <div style={{ marginTop: '2rem' }}>
        <button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
          Trigger Test Error (for PostHog)
        </button>
      </div>
      
      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You&apos;ve considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You&apos;re getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You&apos;re becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master! 🌯</p>
        )}
      </div>
    </div>
  );
}

src/components/Header.tsx

'use client';

import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';

export default function Header() {
  const { user, logout } = useAuth();

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link href="/">Home</Link>
          {user && (
            <>
              <Link href="/burrito">Burrito Consideration</Link>
              <Link href="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={logout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}

src/contexts/AuthContext.tsx

'use client';

import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  incrementBurritoConsiderations: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  // Use lazy initializer to read from localStorage only once on mount
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username: string, password: string): Promise<boolean> => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      });

      if (response.ok) {
        const { user: userData } = await response.json();

        let localUser = users.get(username);
        if (!localUser) {
          localUser = userData as User;
          users.set(username, localUser);
        }

        setUser(localUser);
        localStorage.setItem('currentUser', username);
        
        // Identify user in PostHog using username as distinct ID
        posthog.identify(username, {
          username: username,
        });
        
        // Capture login event
        posthog.capture('user_logged_in', {
          username: username,
        });
        
        return true;
      }
      return false;
    } catch (error) {
      console.error('Login error:', error);
      return false;
    }
  };

  const logout = () => {
    // Capture logout event before resetting
    posthog.capture('user_logged_out');
    posthog.reset();
    
    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++;
      users.set(user.username, user);
      setUser({ ...user });
    }
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

src/lib/posthog-server.ts

import { PostHog } from 'posthog-node';

let posthogClient: PostHog | null = null;

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(
      process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
      { 
        host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
        flushAt: 1,
        flushInterval: 0
      }
    );
    posthogClient.debug(true);
  }
  return posthogClient;
}

export async function shutdownPostHog() {
  if (posthogClient) {
    await posthogClient.shutdown();
  }
}

references/EXAMPLE-next-pages-router.md

PostHog next-pages-router Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/next-pages-router


README.md

PostHog Next.js pages router example

This is a Next.js Pages Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Server-side & Client-side Tracking: Examples of both tracking methods
  • Reverse Proxy: PostHog ingestion through Next.js rewrites

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env.local file in the root directory:

NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:3000 with your browser to see the app.

Project Structure

src/
├── components/
│   └── Header.tsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx      # Authentication context with PostHog integration
├── lib/
│   └── posthog-server.ts    # Server-side PostHog client
├── pages/
│   ├── _app.tsx             # App wrapper with Auth provider
│   ├── _document.tsx        # Document wrapper
│   ├── index.tsx            # Home/Login page
│   ├── burrito.tsx          # Demo feature page with event tracking
│   ├── profile.tsx          # User profile with error tracking demo
│   └── api/
│       └── auth/
│           └── login.ts     # Login API with server-side tracking
└── styles/
    └── globals.css          # Global styles

instrumentation-client.ts    # Client-side PostHog initialization

Key Integration Points

Client-side initialization (instrumentation-client.ts)
import posthog from "posthog-js"

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
  api_host: "/ingest",
  ui_host: "https://us.posthog.com",
  defaults: '2026-01-30',
  capture_exceptions: true,
  debug: process.env.NODE_ENV === "development",
});
User identification (AuthContext.tsx)
posthog.identify(username, {
  username: username,
});
Event tracking (burrito.tsx)
posthog.capture('burrito_considered', {
  total_considerations: count,
  username: username,
});
Error tracking (profile.tsx)
posthog.captureException(error);
Server-side tracking (api/auth/login.ts)
const posthog = getPostHogClient();
posthog.capture({
  distinctId: username,
  event: 'server_login',
  properties: { ... }
});

Pages router differences from app router

This example uses Next.js Pages Router instead of App Router. Key differences:

  1. File-based routing: Pages in src/pages/ instead of src/app/
  2. _app.tsx: Custom App component wraps all pages
  3. API Routes: Located in src/pages/api/
  4. No 'use client': All pages are client-side by default
  5. useRouter: From next/router instead of next/navigation
  6. Head component: Using next/head for metadata instead of metadata export

Learn More

Deploy on Vercel

The easiest way to deploy your Next.js app is to use the Vercel Platform.

Check out the Next.js deployment documentation for more details.


instrumentation-client.ts

import posthog from "posthog-js"

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
  api_host: "/ingest",
  ui_host: "https://us.posthog.com",
  // Include the defaults option as required by PostHog
  defaults: '2026-01-30',
  // Enables capturing unhandled exceptions via Error Tracking
  capture_exceptions: true,
  // Turn on debug in development mode
  debug: process.env.NODE_ENV === "development",
});

//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: true,
  async rewrites() {
    return [
      {
        source: "/ingest/static/:path*",
        destination: "https://us-assets.i.posthog.com/static/:path*",
      },
      {
        source: "/ingest/array/:path*",
        destination: "https://us-assets.i.posthog.com/array/:path*",
      },
      {
        source: "/ingest/:path*",
        destination: "https://us.i.posthog.com/:path*",
      },
    ];
  },
  // This is required to support PostHog trailing slash API requests
  skipTrailingSlashRedirect: true,
};

export default nextConfig;

src/components/Header.tsx

import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';

export default function Header() {
  const { user, logout } = useAuth();

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link href="/">Home</Link>
          {user && (
            <>
              <Link href="/burrito">Burrito Consideration</Link>
              <Link href="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={logout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}

src/contexts/AuthContext.tsx

import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  incrementBurritoConsiderations: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  // Use lazy initializer to read from localStorage only once on mount
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username: string, password: string): Promise<boolean> => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      });

      if (response.ok) {
        const { user: userData } = await response.json();

        // Get or create user in local map
        let localUser = users.get(username);
        if (!localUser) {
          localUser = userData as User;
          users.set(username, localUser);
        }

        setUser(localUser);
        localStorage.setItem('currentUser', username);

        // Identify user in PostHog using username as distinct ID
        posthog.identify(username, {
          username: username,
        });

        // Capture login event
        posthog.capture('user_logged_in', {
          username: username,
        });

        return true;
      }
      return false;
    } catch (error) {
      console.error('Login error:', error);
      return false;
    }
  };

  const logout = () => {
    // Capture logout event before resetting
    posthog.capture('user_logged_out');
    posthog.reset();

    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++;
      users.set(user.username, user);
      setUser({ ...user });
    }
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

src/lib/posthog-server.ts

import { PostHog } from 'posthog-node';

let posthogClient: PostHog | null = null;

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(
      process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
      {
        host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
        flushAt: 1,
        flushInterval: 0
      }
    );
  }
  return posthogClient;
}

export async function shutdownPostHog() {
  if (posthogClient) {
    await posthogClient.shutdown();
  }
}

src/pages/_app.tsx

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { AuthProvider } from "@/contexts/AuthContext";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  );
}

src/pages/_document.tsx

import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

src/pages/api/auth/login.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import { getPostHogClient } from '@/lib/posthog-server';

const users = new Map<string, { username: string; burritoConsiderations: number }>();

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const { username, password } = req.body;

  if (!username || !password) {
    return res.status(400).json({ error: 'Username and password required' });
  }

  let user = users.get(username);
  const isNewUser = !user;

  if (!user) {
    user = { username, burritoConsiderations: 0 };
    users.set(username, user);
  }

  // Capture server-side login event
  const posthog = getPostHogClient();
  posthog.capture({
    distinctId: username,
    event: 'server_login',
    properties: {
      isNewUser: isNewUser,
      source: 'api'
    }
  });

  // Identify user on server side
  posthog.identify({
    distinctId: username,
    properties: {
      username: username,
      createdAt: isNewUser ? new Date().toISOString() : undefined
    }
  });

  // This handler is short-lived; flush so the enqueued events send before it returns
  await posthog.flush();

  return res.status(200).json({ success: true, user });
}

src/pages/api/hello.ts

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";

type Data = {
  name: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>,
) {
  res.status(200).json({ name: "John Doe" });
}

src/pages/burrito.tsx

import { useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';

export default function BurritoPage() {
  const { user, incrementBurritoConsiderations } = useAuth();
  const router = useRouter();
  const [hasConsidered, setHasConsidered] = useState(false);

  // Redirect to home if not logged in
  if (!user) {
    router.push('/');
    return null;
  }

  const handleConsideration = () => {
    incrementBurritoConsiderations();
    setHasConsidered(true);
    setTimeout(() => setHasConsidered(false), 2000);

    // Capture burrito consideration event
    posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    });
  };

  return (
    <>
      <Head>
        <title>Burrito Consideration - Burrito Consideration App</title>
        <meta name="description" content="Consider the potential of burritos" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Header />
      <main>
        <div className="container">
          <h1>Burrito consideration zone</h1>
          <p>Take a moment to truly consider the potential of burritos.</p>

          <div style={{ textAlign: 'center' }}>
            <button
              onClick={handleConsideration}
              className="btn-burrito"
            >
              I have considered the burrito potential
            </button>

            {hasConsidered && (
              <p className="success">
                Thank you for your consideration! Count: {user.burritoConsiderations}
              </p>
            )}
          </div>

          <div className="stats">
            <h3>Consideration stats</h3>
            <p>Total considerations: {user.burritoConsiderations}</p>
          </div>
        </div>
      </main>
    </>
  );
}

src/pages/index.tsx

import { useState } from 'react';
import Head from 'next/head';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';

export default function Home() {
  const { user, login } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      const success = await login(username, password);
      if (success) {
        setUsername('');
        setPassword('');
      } else {
        setError('Please provide both username and password');
      }
    } catch (err) {
      console.error('Login failed:', err);
      setError('An error occurred during login');
    }
  };

  return (
    <>
      <Head>
        <title>Burrito Consideration App</title>
        <meta name="description" content="Consider the potential of burritos" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Header />
      <main>
        {user ? (
          <div className="container">
            <h1>Welcome back, {user.username}!</h1>
            <p>You are logged in. Feel free to explore:</p>
            <ul>
              <li>Consider the potential of burritos</li>
              <li>View your profile and statistics</li>
            </ul>
          </div>
        ) : (
          <div className="container">
            <h1>Welcome to Burrito Consideration App</h1>
            <p>Please sign in to begin your burrito journey</p>

            <form onSubmit={handleSubmit} className="form">
              <div className="form-group">
                <label htmlFor="username">Username:</label>
                <input
                  type="text"
                  id="username"
                  value={username}
                  onChange={(e) => setUsername(e.target.value)}
                  placeholder="Enter any username"
                />
              </div>

              <div className="form-group">
                <label htmlFor="password">Password:</label>
                <input
                  type="password"
                  id="password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                  placeholder="Enter any password"
                />
              </div>

              {error && <p className="error">{error}</p>}

              <button type="submit" className="btn-primary">Sign In</button>
            </form>

            <p className="note">
              Note: This is a demo app. Use any username and password to sign in.
            </p>
          </div>
        )}
      </main>
    </>
  );
}

src/pages/profile.tsx

import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';

export default function ProfilePage() {
  const { user } = useAuth();
  const router = useRouter();

  // Redirect to home if not logged in
  if (!user) {
    router.push('/');
    return null;
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      posthog.captureException(err);
      console.error('Captured error:', err);
      alert('Error captured and sent to PostHog!');
    }
  };

  return (
    <>
      <Head>
        <title>Profile - Burrito Consideration App</title>
        <meta name="description" content="Your burrito consideration profile" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Header />
      <main>
        <div className="container">
          <h1>User Profile</h1>

          <div className="stats">
            <h2>Your Information</h2>
            <p><strong>Username:</strong> {user.username}</p>
            <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
          </div>

          <div style={{ marginTop: '2rem' }}>
            <button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
              Trigger Test Error (for PostHog)
            </button>
          </div>

          <div style={{ marginTop: '2rem' }}>
            <h3>Your Burrito Journey</h3>
            {user.burritoConsiderations === 0 ? (
              <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
            ) : user.burritoConsiderations === 1 ? (
              <p>You&apos;ve considered the burrito potential once. Keep going!</p>
            ) : user.burritoConsiderations < 5 ? (
              <p>You&apos;re getting the hang of burrito consideration!</p>
            ) : user.burritoConsiderations < 10 ? (
              <p>You&apos;re becoming a burrito consideration expert!</p>
            ) : (
              <p>You are a true burrito consideration master! 🌯</p>
            )}
          </div>
        </div>
      </main>
    </>
  );
}

references/EXAMPLE-nuxt-3-6.md

PostHog nuxt-3-6 Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/nuxt-3-6


README.md

PostHog Nuxt 3.6 example

This is a Nuxt 3.6 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Nuxt 3.0 - 3.6 does not support the @posthog/nuxt package. You must use the posthog-js and posthog-node packages directly instead. This example also does not cover automatic source map uploads, only available through the @posthog/nuxt package.

Nuxt 2.x is also distinctly different, follow this guide instead.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Server-side & Client-side Tracking: Examples of both tracking methods
  • SSR Support: Server-side rendering with Nuxt 3.6

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:3000 with your browser to see the app.

Project Structure

├── assets/
│   └── css/
│       └── main.css          # Global styles
├── components/
│   └── Header.vue            # Navigation header with auth state
├── composables/
│   └── useAuth.ts            # Authentication composable
├── pages/
│   ├── index.vue             # Home/Login page
│   ├── burrito.vue           # Demo feature page with event tracking
│   └── profile.vue           # User profile with error tracking demo
├── plugins/
│   └── posthog.client.ts     # Client-side PostHog plugin
├── server/
│   ├── api/
│   │   ├── auth/
│   │   │   └── login.post.ts # Login API with server-side tracking
│   │   └── burrito/
│   │       └── consider.post.ts # Burrito API with server-side tracking
│   └── utils/
│       └── users.ts          # In-memory user storage utilities
├── types/
│   └── nuxt-app.d.ts          # TypeScript declarations for PostHog
├── app.vue                    # Root component with error handling
└── nuxt.config.ts             # Nuxt configuration

Key Integration Points

Client-side initialization (plugins/posthog.client.ts)
import posthog from 'posthog-js'
import type { PostHog, PostHogInterface } from 'posthog-js'

export default defineNuxtPlugin((nuxtApp) => {
  const runtimeConfig = useRuntimeConfig()
  const posthogClient = posthog.init(runtimeConfig.public.posthog.publicKey, {
    api_host: runtimeConfig.public.posthog.host,
    defaults: runtimeConfig.public.posthog.posthogDefaults as any,
    loaded: (posthog: PostHogInterface) => {
      if (import.meta.env.MODE === 'development') posthog.debug()
    },
  })

  nuxtApp.hook('vue:error', (error) => {
    posthogClient.captureException(error)
  })

  return {
    provide: {
      posthog: posthogClient as PostHog,
    },
  }
})

The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers when tracing_headers is configured in the PostHog initialization.

Important: do not identify users on the server-side.

User identification (pages/index.vue)

The user is identified when the user logs in on the client-side.

const { $posthog: posthog } = useNuxtApp()

const handleSubmit = async () => {
  const success = await auth.login(username.value, password.value)
  if (success) {
    // Identifying the user once on login/sign up is enough.
    posthog?.identify(username.value)
    
    // Capture login event
    posthog?.capture('user_logged_in')
  }
}

The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog initialization.

Important: do not identify users on the server-side.

Server-side API routes (server/api/auth/login.post.ts, server/api/burrito/consider.post.ts)

Server-side API routes create a PostHog Node client for each request and extract session and user context from request headers:

import { PostHog } from 'posthog-node'
import { getHeader } from 'h3'

export default defineEventHandler(async (event) => {
  const runtimeConfig = useRuntimeConfig()

  // Relies on tracing_headers being set in the client-side SDK
  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  const posthog = new PostHog(
    runtimeConfig.public.posthog.publicKey,
    { 
      host: runtimeConfig.public.posthog.host, 
    }
  )

  await posthog.withContext(
    { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
    async () => {
      posthog.capture({
        event: 'server_login',
        distinctId: distinctId ?? username,
      })
    }
  )

  // Always shutdown to ensure all events are flushed
  await posthog.shutdown()
})

Key Points:

  • Creates a new PostHog Node client for each request
  • Extracts sessionId and distinctId from request headers using getHeader() from h3
  • Uses withContext() to associate server-side events with the correct session/user
  • Properly shuts down the client after each request to ensure events are flushed
Event tracking (pages/burrito.vue)
const { $posthog: posthog } = useNuxtApp()

const handleConsideration = () => {
  if (user.value) {
    auth.incrementBurritoConsiderations()
    
    posthog?.capture('burrito_considered', {
      total_considerations: user.value?.burritoConsiderations + 1,
      username: user.value?.username,
    })
  }
}
Error tracking (app.vue, plugins/posthog.client.ts, pages/profile.vue)

Errors are captured in three ways:

  1. Vue error hook - The vue:error hook in plugins/posthog.client.ts automatically captures Vue errors:
nuxtApp.hook('vue:error', (error) => {
  posthogClient.captureException(error)
})
  1. Error boundary - The onErrorCaptured in app.vue captures component errors:
onErrorCaptured((error) => {
  posthog?.captureException(error)
  return false // Let the error propagate
})
  1. Manual error capture in components (pages/profile.vue):
const triggerTestError = () => {
  try {
    throw new Error('Test error for PostHog error tracking')
  } catch (err) {
    posthog?.captureException(err as Error)
  }
}
Server-side tracking (server/api/auth/login.post.ts, server/api/burrito/consider.post.ts)

Server-side events use a PostHog Node client created per request:

const posthog = new PostHog(
  runtimeConfig.public.posthog.publicKey,
  { 
    host: runtimeConfig.public.posthog.host, 
  }
)

await posthog.withContext(
  { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
  async () => {
    posthog.capture({
      event: 'server_login',
      distinctId: distinctId ?? username,
    })
  }
)

await posthog.shutdown()

Key Points:

  • The PostHog Node client is created per request in each API route
  • Events are automatically associated with the correct user/session via withContext()
  • The distinctId and sessionId are extracted from request headers and used to maintain context between client and server
  • Always call shutdown() to ensure events are flushed
Accessing PostHog in components

PostHog is accessed via useNuxtApp():

const { $posthog: posthog } = useNuxtApp()
posthog?.capture('event_name', { property: 'value' })

TypeScript types are provided via types/nuxt-app.d.ts:

import type { PostHog } from 'posthog-js'

declare module '#app' {
  interface NuxtApp {
    $posthog: PostHog
  }
}

Learn More


.env.example


NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NUXT_PUBLIC_POSTHOG_HOST=

app.vue

<template>
  <div>
    <NuxtRouteAnnouncer />
    <Header />
    <main>
      <NuxtPage />
    </main>
  </div>
</template>

components/Header.vue

<template>
  <header class="header">
    <div class="header-container">
      <nav>
        <NuxtLink to="/">Home</NuxtLink>
        <template v-if="user">
          <NuxtLink to="/burrito">Burrito Consideration</NuxtLink>
          <NuxtLink to="/profile">Profile</NuxtLink>
        </template>
      </nav>
      <div class="user-section">
        <template v-if="user">
          <span>Welcome, {{ user.username }}!</span>
          <button @click="handleLogout" class="btn-logout">
            Logout
          </button>
        </template>
        <template v-else>
          <span>Not logged in</span>
        </template>
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const { $posthog: posthog } = useNuxtApp()

const handleLogout = () => {
  posthog?.capture('user_logged_out')
  posthog?.reset()
  auth.logout()
}
</script>

composables/useAuth.ts

interface User {
  username: string
  burritoConsiderations: number
}

const users = new Map<string, User>()

export const useAuth = () => {
  const user = useState<User | null>('auth-user', () => {
    if (typeof window !== 'undefined') {
      const storedUsername = localStorage.getItem('currentUser')
      if (storedUsername) {
        const existingUser = users.get(storedUsername)
        if (existingUser) {
          return existingUser
        }
      }
    }
    return null
  })

  const login = async (username: string, password: string): Promise<boolean> => {
    if (!username || !password) {
      return false
    }

    try {
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: { username, password },
      })

      if (response.success && response.user) {
        // Update client-side state
        user.value = response.user
        users.set(username, response.user)
        
        if (typeof window !== 'undefined') {
          localStorage.setItem('currentUser', username)
        }

        return true
      }
      return false
    } catch (err) {
      console.error('Login error:', err)
      return false
    }
  }

  const logout = () => {
    user.value = null
    if (typeof window !== 'undefined') {
      localStorage.removeItem('currentUser')
    }
  }

  const setUser = (newUser: User) => {
    user.value = newUser
    users.set(newUser.username, newUser)
  }

  const incrementBurritoConsiderations = () => {
    if (user.value) {
      user.value.burritoConsiderations++
      users.set(user.value.username, user.value)
      // Trigger reactivity by creating a new object
      user.value = { ...user.value }
    }
  }

  return {
    user,
    login,
    logout,
    setUser,
    incrementBurritoConsiderations
  }
}

nuxt.config.ts

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',
  devtools: { enabled: true },
  css: ['~/assets/css/main.css'],
  runtimeConfig: {
    public: {
      posthog: {
        publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN,
        host: process.env.NUXT_PUBLIC_POSTHOG_HOST,
        posthogDefaults: '2026-01-30',
      },
    },
  },
})


pages/burrito.vue

<template>
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center">
      <button
        @click="handleConsideration"
        class="btn-burrito"
      >
        I have considered the burrito potential
      </button>

      <p v-if="hasConsidered" class="success">
        Thank you for your consideration! Count: {{ user?.burritoConsiderations }}
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: {{ user?.burritoConsiderations }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const router = useRouter()
const hasConsidered = ref(false)
const { $posthog } = useNuxtApp()

// Redirect to home if not logged in
watchEffect(() => {
  if (!user.value) {
    router.push('/')
  }
})

const handleConsideration = async () => {
  if (!user.value) return

  try {
    const response = await $fetch('/api/burrito/consider', {
      method: 'POST',
      body: { username: user.value.username },
    })

    if (response.success && response.user) {
      auth.setUser(response.user)
      hasConsidered.value = true

      // Client-side tracking (in addition to server-side tracking)
      $posthog?.capture('burrito_considered', {
        total_considerations: response.user.burritoConsiderations,
        username: response.user.username,
      })

      setTimeout(() => {
        hasConsidered.value = false
      }, 2000)
    }
  } catch (err) {
    console.error('Error considering burrito:', err)
  }
}
</script>

pages/index.vue

<template>
  <div class="container">
    <template v-if="user">
      <h1>Welcome back, {{ user.username }}!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </template>
    <template v-else>
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form @submit.prevent="handleSubmit" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            v-model="username"
            placeholder="Enter any username"
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            v-model="password"
            placeholder="Enter any password"
          />
        </div>

        <p v-if="error" class="error">{{ error }}</p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </template>
  </div>
</template>

<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const username = ref('')
const password = ref('')
const error = ref('')
const { $posthog: posthog } = useNuxtApp()

const handleSubmit = async () => {
  error.value = ''

  const success = await auth.login(username.value, password.value)
  if (success) {
    // Identifying the user once on login/sign up is enough.
    posthog?.identify(username.value)
    
    // Capture login event
    posthog?.capture('user_logged_in')
    
    username.value = ''
    password.value = ''
  } else {
    error.value = 'Please provide both username and password'
  }
}
</script>

pages/profile.vue

<template>
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> {{ user?.username }}</p>
      <p><strong>Burrito Considerations:</strong> {{ user?.burritoConsiderations }}</p>
    </div>

    <div style="margin-top: 2rem">
      <button @click="triggerTestError" class="btn-primary" style="background-color: #dc3545">
        Trigger Test Error (for PostHog)
      </button>
    </div>

    <div style="margin-top: 2rem">
      <h3>Your Burrito Journey</h3>
      <template v-if="user">
        <p v-if="user.burritoConsiderations === 0">
          You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
        </p>
        <p v-else-if="user.burritoConsiderations === 1">
          You've considered the burrito potential once. Keep going!
        </p>
        <p v-else-if="user.burritoConsiderations < 5">
          You're getting the hang of burrito consideration!
        </p>
        <p v-else-if="user.burritoConsiderations < 10">
          You're becoming a burrito consideration expert!
        </p>
        <p v-else>
          You are a true burrito consideration master! 🌯
        </p>
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
const auth = useAuth()
const user = computed(() => auth.user.value)
const router = useRouter()
const { $posthog: posthog } = useNuxtApp()

// Redirect to home if not logged in
watchEffect(() => {
  if (!user.value) {
    router.push('/')
  }
})

const triggerTestError = () => {
  try {
    throw new Error('Test error for PostHog error tracking')
  } catch (err) {
    console.error('Captured error:', err)
    posthog?.captureException(err as Error)
  }
}
</script>

plugins/posthog.client.ts

import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
import posthog from 'posthog-js'
import type { PostHog, PostHogInterface } from 'posthog-js'

export default defineNuxtPlugin((nuxtApp) => {
  const runtimeConfig = useRuntimeConfig()
  const posthogClient = posthog.init(runtimeConfig.public.posthog.publicKey, {
    api_host: runtimeConfig.public.posthog.host,
    defaults: runtimeConfig.public.posthog.posthogDefaults as any,
    // Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers
    // to same-origin requests so server-side events join the same session.
    tracing_headers: [window.location.hostname],
    loaded: (posthog: PostHogInterface) => {
      if (import.meta.env.MODE === 'development') posthog.debug()
    },
  })

  nuxtApp.hook('vue:error', (error) => {
    posthogClient.captureException(error)
  })

  return {
    provide: {
      posthog: posthogClient as PostHog,
    },
  }
})

public/robots.txt

User-Agent: *
Disallow:

server/api/auth/login.post.ts

import { getOrCreateUser } from '~/server/utils/users'
import { PostHog } from 'posthog-node'
import { useRuntimeConfig } from '#imports'
import { getHeader } from 'h3'

export default defineEventHandler(async (event) => {
  if (event.node.req.method !== 'POST') {
    throw createError({
      statusCode: 405,
      statusMessage: 'Method Not Allowed'
    })
  }

  const body = await readBody(event)
  const { username, password } = body

  if (!username || !password) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Username and password required'
    })
  }

  // Fake auth - just get or create user
  const user = getOrCreateUser(username)

  const runtimeConfig = useRuntimeConfig()

    // Relies on tracing_headers being set in the client-side SDK
  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  const posthog = new PostHog(
    runtimeConfig.public.posthog.publicKey,
    { 
      host: runtimeConfig.public.posthog.host, 
    }
  )

  await posthog.withContext(
    { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
    async () => {
      posthog.capture({
        event: 'server_login',
        distinctId: distinctId ?? username,
      })
    }
  )

  // Always shutdown to ensure all events are flushed
  await posthog.shutdown()

  return {
    success: true,
    user: { ...user }
  }
})

server/api/burrito/consider.post.ts

import { users, incrementBurritoConsiderations } from '~/server/utils/users'
import { PostHog } from 'posthog-node'
import { useRuntimeConfig } from '#imports'
import { getHeader } from 'h3'

export default defineEventHandler(async (event) => {
  if (event.node.req.method !== 'POST') {
    throw createError({
      statusCode: 405,
      statusMessage: 'Method Not Allowed'
    })
  }

  const body = await readBody(event)
  const { username } = body

  if (!username) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Username required'
    })
  }

  if (!users.has(username)) {
    throw createError({
      statusCode: 404,
      statusMessage: 'User not found'
    })
  }

  // Increment burrito considerations (fake, in-memory)
  const user = incrementBurritoConsiderations(username)

  const runtimeConfig = useRuntimeConfig()

  // Relies on tracing_headers being set in the client-side SDK
  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  const posthog = new PostHog(
    runtimeConfig.public.posthog.publicKey,
    { 
      host: runtimeConfig.public.posthog.host, 
    }
  )

  await posthog.withContext(
    { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
    async () => {
      posthog.capture({
        event: 'burrito_considered',
        distinctId: distinctId ?? username,
      })
    }
  )

  // Always shutdown to ensure all events are flushed
  await posthog.shutdown()

  return {
    success: true,
    user: { ...user }
  }
})

server/utils/users.ts

interface User {
  username: string
  burritoConsiderations: number
}

// Shared in-memory storage for users (fake, no database)
export const users = new Map<string, User>()

export function getOrCreateUser(username: string): User {
  let user = users.get(username)
  
  if (!user) {
    user = { 
      username, 
      burritoConsiderations: 0 
    }
    users.set(username, user)
  }
  
  return user
}

export function incrementBurritoConsiderations(username: string): User {
  const user = users.get(username)
  
  if (!user) {
    throw new Error('User not found')
  }
  
  user.burritoConsiderations++
  users.set(username, user)
  
  return { ...user }
}

types/nuxt-app.d.ts

import type { PostHog } from 'posthog-js'

declare module '#app' {
  interface NuxtApp {
    $posthog: PostHog
  }
}

export {}

references/EXAMPLE-nuxt-4.md

PostHog nuxt-4 Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/nuxt-4


README.md

PostHog Nuxt 4 example

This is a Nuxt 4 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Nuxt 4 supports the @posthog/nuxt package, which provides automatic PostHog integration with built-in error tracking, source map uploads, and simplified configuration. This is the recommended approach for Nuxt 4+.

For Nuxt 3.0 - 3.6, you must use the posthog-js and posthog-node packages directly instead. See the Nuxt 3.6 example (../nuxt-3-6) for that approach.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Automatic error capture on both client and server
  • Source Maps: Automatic source map uploads when building for production
  • User Authentication: Demo login system with PostHog user identification
  • Server-side & Client-side Tracking: Examples of both tracking methods
  • SSR Support: Server-side rendering with Nuxt 4

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# Optional: For source map uploads
PROJECT_ID=your_project_id
PERSONAL_API_KEY=your_personal_api_key

Get your PostHog project token from your PostHog project settings.

For source map uploads, get your project ID from PostHog environment variables and your personal API key from PostHog user API keys (requires organization:read and error_tracking:write scopes).

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:3000 with your browser to see the app.

Project Structure

├── app/
│   ├── components/
│   │   └── AppHeader.vue        # Navigation header with auth state
│   ├── composables/
│   │   └── useAuth.ts           # Authentication composable
│   ├── middleware/
│   │   └── auth.ts              # Authentication middleware
│   ├── pages/
│   │   ├── index.vue            # Home/Login page
│   │   ├── burrito.vue          # Demo feature page with event tracking
│   │   └── profile.vue           # User profile with error tracking demo
│   ├── utils/
│   │   └── formValidation.ts    # Form validation utilities
│   └── app.vue                  # Root component
├── assets/
│   └── css/
│       └── main.css              # Global styles
├── server/
│   ├── api/
│   │   ├── auth/
│   │   │   └── login.post.ts     # Login API with server-side tracking
│   │   └── burrito/
│   │       └── consider.post.ts  # Burrito consideration API with server-side tracking
│   └── utils/
│       ├── posthog.ts            # Server-side PostHog utility
│       └── users.ts              # In-memory user storage utilities
├── nuxt.config.ts               # Nuxt configuration with PostHog module
└── package.json

Key Integration Points

Module Configuration (nuxt.config.ts)

Nuxt 4 uses the @posthog/nuxt module for automatic PostHog integration:

export default defineNuxtConfig({
  modules: ['@posthog/nuxt'],
  runtimeConfig: {
    public: {
      posthog: {
        publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
        host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
      },
    },
  },
  posthogConfig: {
    publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
    host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
    clientConfig: {
      capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
      tracing_headers: ['localhost', 'yourdomain.com'], // Add your domain here
    },
    serverConfig: {
      enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
    },
    sourcemaps: {
      enabled: true,
      envId: process.env.PROJECT_ID || '',
      personalApiKey: process.env.PERSONAL_API_KEY || '',
      project: 'my-application',
      version: '1.0.0',
    },
  },
})

Key Points:

  • The @posthog/nuxt module handles PostHog initialization automatically
  • Client-side error tracking is enabled via capture_exceptions: true
  • Server-side error tracking is enabled via enableExceptionAutocapture: true
  • Source map uploads are configured for better error tracking
  • The tracing_headers option automatically adds X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to requests

Important: do not identify users on the server-side.

User identification (app/pages/index.vue)

The user is identified when the user logs in on the client-side.

const posthog = usePostHog()

const handleSubmit = async () => {
  const success = await auth.login(formData.username, formData.password)
  if (success) {
    // Identifying the user once on login/sign up is enough.
    posthog?.identify(formData.username)
    
    // Capture login event
    posthog?.capture('user_logged_in')
  }
}

The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog configuration.

Important: do not identify users on the server-side.

Server-side API routes (server/api/auth/login.post.ts)

Server-side API routes use the useServerPostHog() utility to get a PostHog Node client and extract session and user context from request headers:

import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'

export default defineEventHandler(async (event) => {
  const body = await readBody<{ username: string; password: string }>(event)
  const { username, password } = body || {}

  if (!username || !password) {
    throw createError({
      statusCode: 400,
      message: 'Username and password required',
    })
  }

  const user = getOrCreateUser(username)
  const isNewUser = !users.has(username)

  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  // Capture server-side login event
  const posthog = useServerPostHog()
  
  posthog.capture({
    distinctId: distinctId,
    event: 'server_login',
    properties: {
      $session_id: sessionId,
      username: username,
      isNewUser: isNewUser,
      source: 'api',
    },
  })

  return {
    success: true,
    user,
  }
})

Key Points:

  • Uses useServerPostHog() utility to get a shared PostHog Node client instance
  • Extracts sessionId and distinctId from request headers using getHeader() (auto-imported from h3)
  • The PostHog client is reused across requests (singleton pattern)
  • h3 functions like defineEventHandler, readBody, createError, getHeader are auto-imported in server routes
Event tracking (app/pages/burrito.vue)

The burrito consideration page demonstrates both client-side and server-side event tracking:

const posthog = usePostHog()

const handleConsideration = async () => {
  if (!user.value) return

  try {
    // Call server-side API route
    const response = await $fetch('/api/burrito/consider', {
      method: 'POST',
      body: { username: user.value.username },
    })

    if (response.success && response.user) {
      auth.setUser(response.user)
      hasConsidered.value = true

      // Client-side tracking (in addition to server-side tracking)
      posthog?.capture('burrito_considered', {
        total_considerations: response.user.burritoConsiderations,
        username: response.user.username,
      })

      setTimeout(() => {
        hasConsidered.value = false
      }, 2000)
    }
  } catch (err) {
    console.error('Error considering burrito:', err)
  }
}

The server-side route (server/api/burrito/consider.post.ts) also captures the event, demonstrating dual tracking.

Error tracking

Errors are captured automatically in multiple ways:

  1. Automatic client-side capture - The @posthog/nuxt module automatically captures Vue errors when capture_exceptions: true is set in posthogConfig.clientConfig.

  2. Automatic server-side capture - The module automatically captures Nitro errors when enableExceptionAutocapture: true is set in posthogConfig.serverConfig.

  3. Manual error capture in components (app/pages/profile.vue):

const posthog = usePostHog()

const triggerTestError = () => {
  try {
    throw new Error('Test error for PostHog error tracking')
  } catch (err) {
    posthog?.captureException(err)
  }
}
Server-side tracking (server/api/auth/login.post.ts)

Server-side events use the shared PostHog Node client. Note that h3 functions are auto-imported in Nuxt server routes:

import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'

export default defineEventHandler(async (event) => {
  const body = await readBody<{ username: string; password: string }>(event)
  const { username, password } = body || {}

  // ... validation logic ...

  // Extract headers using getHeader (auto-imported from h3)
  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  // Capture server-side event
  const posthog = useServerPostHog()
  
  posthog.capture({
    distinctId: distinctId,
    event: 'server_login',
    properties: {
      $session_id: sessionId,
      username: username,
      isNewUser: isNewUser,
      source: 'api',
    },
  })

  return { success: true, user }
})

Key Points:

  • The PostHog Node client is shared across requests via useServerPostHog() utility
  • getHeader() is auto-imported from h3 in Nuxt server routes (no need to import from 'h3')
  • h3 functions like defineEventHandler, readBody, createError are also auto-imported
  • The distinctId and sessionId are extracted from request headers and used to maintain context between client and server
  • No need to manually shutdown the client (it's managed by the module)
Accessing PostHog in components

PostHog is accessed via the usePostHog() composable provided by @posthog/nuxt:

const posthog = usePostHog()
posthog?.capture('event_name', { property: 'value' })

The composable is automatically typed and available throughout your Nuxt application.

Server-side PostHog utility (server/utils/posthog.ts)

The server utility provides a shared PostHog Node client instance:

import { PostHog } from 'posthog-node'

let client: PostHog | null = null

export function useServerPostHog(): PostHog {
  if (!client) {
    const config = useRuntimeConfig()
    const posthogConfig = config.public.posthog
    client = new PostHog(posthogConfig.publicKey, {
      host: posthogConfig.host,
    })
  }
  return client
}

This ensures a single PostHog client instance is reused across all server requests, improving performance.

Differences from Nuxt 3.6

  • Module-based: Uses @posthog/nuxt module instead of manual plugin setup
  • Automatic error tracking: Built-in error capture on both client and server
  • Source map uploads: Automatic source map uploads for better error tracking
  • Simplified API: Uses usePostHog() composable instead of useNuxtApp().$posthog
  • Shared server client: Reuses PostHog Node client across requests instead of creating per-request
  • Automatic imports: In Nuxt 4 server routes, h3 functions (defineEventHandler, readBody, createError, getHeader, etc.) are auto-imported - no need to import them explicitly

Learn More


.env.example

NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NUXT_PUBLIC_POSTHOG_HOST=
PROJECT_ID=
PERSONAL_API_KEY=

app/app.vue

<template>
  <div style="min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; width: 100%;">
    <AppHeader />
    <main style="flex: 1;">
      <NuxtPage />
    </main>
  </div>
</template>

app/components/AppHeader.vue

<template>
  <header class="header">
    <div class="header-container">
      <nav>
        <NuxtLink to="/">Home</NuxtLink>
        <template v-if="user">
          <NuxtLink to="/burrito">Burrito Consideration</NuxtLink>
          <NuxtLink to="/profile">Profile</NuxtLink>
        </template>
      </nav>
      <div class="user-section">
        <span v-if="user">Welcome, {{ user.username }}!</span>
        <span v-else>Not logged in</span>
        <button v-if="user" @click="handleLogout" class="btn-logout">Logout</button>
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
const posthog = usePostHog()
const auth = useAuth()
const user = computed(() => auth.user.value)

const handleLogout = async () => {
  auth.logout()
  posthog?.capture('user_logged_out')
  posthog?.reset()
  await navigateTo('/')
}
</script>

app/composables/useAuth.ts

interface User {
  username: string
  burritoConsiderations: number
}

const users: Map<string, User> = new Map()

export function useAuth() {
  const user = useState<User | null>('auth-user', () => {
    if (process.client) {
      const storedUsername = localStorage.getItem('currentUser')
      if (storedUsername) {
        const existingUser = users.get(storedUsername)
        if (existingUser) {
          return existingUser
        }
      }
    }
    return null
  })

  const login = async (username: string, password: string): Promise<boolean> => {
    try {
      const response = await $fetch<{ success: boolean; user: User }>('/api/auth/login', {
        method: 'POST',
        body: { username, password },
      })

      if (response.success) {
        let localUser = users.get(username)
        if (!localUser) {
          localUser = response.user
          users.set(username, localUser)
        }

        user.value = localUser
        if (process.client) {
          localStorage.setItem('currentUser', username)
        }

        return true
      }
      return false
    } catch (error) {
      console.error('Login error:', error)
      return false
    }
  }

  const logout = () => {
    user.value = null
    if (process.client) {
      localStorage.removeItem('currentUser')
    }
  }

  const incrementBurritoConsiderations = () => {
    if (user.value) {
      user.value.burritoConsiderations++
      users.set(user.value.username, user.value)
      // Trigger reactivity
      user.value = { ...user.value }
    }
  }

  const setUser = (newUser: User) => {
    user.value = newUser
    users.set(newUser.username, newUser)
  }

  return {
    user,
    login,
    logout,
    incrementBurritoConsiderations,
    setUser,
  }
}

app/middleware/auth.ts

export default defineNuxtRouteMiddleware((to, from) => {
  const auth = useAuth()
  const user = auth.user.value

  // If user is not logged in, redirect to home/login page
  if (!user) {
    return navigateTo('/')
  }
})

app/pages/burrito.vue

<template>
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center">
      <button @click="handleConsideration" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p v-if="hasConsidered" class="success">
        Thank you for your consideration! Count: {{ user?.burritoConsiderations }}
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: {{ user?.burritoConsiderations }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  middleware: 'auth'
})

const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const hasConsidered = ref(false)

const handleConsideration = async () => {
  if (!user.value) return

  try {
    const response = await $fetch('/api/burrito/consider', {
      method: 'POST',
      body: { username: user.value.username },
    })

    if (response.success && response.user) {
      auth.setUser(response.user)
      hasConsidered.value = true

      // Client-side tracking (in addition to server-side tracking)
      posthog?.capture('burrito_considered', {
        total_considerations: response.user.burritoConsiderations,
        username: response.user.username,
      })

      setTimeout(() => {
        hasConsidered.value = false
      }, 2000)
    }
  } catch (err) {
    console.error('Error considering burrito:', err)
  }
}
</script>

app/pages/index.vue

<template>
  <div class="container">
    <h1 v-if="user">Welcome back, {{ user.username }}!</h1>
    <h1 v-else>Welcome to Burrito Consideration App</h1>

    <div v-if="user">
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </div>

    <div v-else>
      <p>Please sign in to begin your burrito journey</p>

      <form @submit.prevent="handleSubmit" class="form" novalidate>
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            id="username"
            v-model="formData.username"
            type="text"
            placeholder="Enter any username"
            :class="{ 'error-input': errors.username }"
            @blur="validateField('username')"
            @input="clearError('username')"
          />
          <p v-if="errors.username" class="field-error">{{ errors.username }}</p>
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            id="password"
            v-model="formData.password"
            type="password"
            placeholder="Enter any password"
            :class="{ 'error-input': errors.password }"
            @blur="validateField('password')"
            @input="clearError('password')"
          />
          <p v-if="errors.password" class="field-error">{{ errors.password }}</p>
        </div>

        <p v-if="error" class="error">{{ error }}</p>

        <button type="submit" class="btn-primary" :disabled="isSubmitting">
          {{ isSubmitting ? 'Signing in...' : 'Sign In' }}
        </button>
      </form>

      <p class="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { loginSchema, validateForm, type LoginFormData } from '../utils/formValidation'

const auth = useAuth()
const user = computed(() => auth.user.value)

const posthog = usePostHog()

const formData = reactive<LoginFormData>({
  username: '',
  password: '',
})

const errors = reactive<Partial<Record<keyof LoginFormData, string>>>({})
const error = ref('')
const isSubmitting = ref(false)

const validateField = (field: keyof LoginFormData) => {
  const fieldSchema = loginSchema.shape[field]
  if (!fieldSchema) return

  const result = fieldSchema.safeParse(formData[field])
  if (!result.success) {
    errors[field] = result.error.errors[0]?.message || 'Invalid value'
  } else {
    delete errors[field]
  }
}

const clearError = (field: keyof LoginFormData) => {
  delete errors[field]
}

const handleSubmit = async () => {
  // Clear previous errors
  error.value = ''
  Object.keys(errors).forEach((key) => {
    delete errors[key as keyof LoginFormData]
  })

  // Validate entire form
  const validation = validateForm(loginSchema, formData)
  if (!validation.success) {
    Object.assign(errors, validation.errors)
    return
  }

  isSubmitting.value = true

  try {
    const success = await auth.login(formData.username, formData.password)
    if (success) {
      // Identifying the user once on login/sign up is enough.
      posthog?.identify(formData.username)
      
      // Capture login event
      posthog?.capture('user_logged_in')
      formData.username = ''
      formData.password = ''
      await navigateTo('/')
    } else {
      error.value = 'Login failed. Please check your credentials and try again.'
    }
  } catch (err) {
    console.error('Login failed:', err)
    error.value = 'An error occurred during login. Please try again.'
  } finally {
    isSubmitting.value = false
  }
}
</script>

app/pages/profile.vue

<template>
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> {{ user?.username }}</p>
      <p><strong>Burrito Considerations:</strong> {{ user?.burritoConsiderations }}</p>
    </div>

    <div style="margin-top: 2rem">
      <button @click="triggerTestError" class="btn-primary" style="background-color: #dc3545">
        Trigger Test Error (for PostHog)
      </button>
    </div>

    <div style="margin-top: 2rem">
      <h3>Your Burrito Journey</h3>
      <p v-if="user?.burritoConsiderations === 0">
        You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
      </p>
      <p v-else-if="user?.burritoConsiderations === 1">
        You've considered the burrito potential once. Keep going!
      </p>
      <p v-else-if="user && user.burritoConsiderations < 5">
        You're getting the hang of burrito consideration!
      </p>
      <p v-else-if="user && user.burritoConsiderations < 10">
        You're becoming a burrito consideration expert!
      </p>
      <p v-else>You are a true burrito consideration master! 🌯</p>
    </div>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  middleware: 'auth'
})

const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()

const triggerTestError = () => {
  try {
    throw new Error('Test error for PostHog error tracking')
  } catch (err) {
    console.error('Captured error:', err)
    posthog?.captureException(err)
  }
}
</script>

app/utils/formValidation.ts

import { z } from 'zod'

export const loginSchema = z.object({
  username: z
    .string()
    .min(1, 'Username is required')
    .min(3, 'Username must be at least 3 characters')
    .max(50, 'Username must be less than 50 characters'),
  password: z
    .string()
    .min(1, 'Password is required')
    .min(3, 'Password must be at least 3 characters'),
})

export type LoginFormData = z.infer<typeof loginSchema>

export function validateForm<T>(schema: z.ZodSchema<T>, data: unknown): {
  success: boolean
  data?: T
  errors?: Record<string, string>
} {
  const result = schema.safeParse(data)

  if (result.success) {
    return { success: true, data: result.data }
  }

  const errors: Record<string, string> = {}
  result.error.errors.forEach((error) => {
    const path = error.path.join('.')
    errors[path] = error.message
  })

  return { success: false, errors }
}

nuxt.config.ts

import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',
  devtools: { enabled: true },
  css: [resolve(__dirname, 'assets/css/main.css')],
  modules: ['@posthog/nuxt'],
  runtimeConfig: {
    public: {
      posthog: {
        publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
        host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
      },
    },
  },
  posthogConfig: {
    publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '', // Find it in project settings https://app.posthog.com/settings/project
    host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', // Optional: defaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU region
    clientConfig: {
      capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
      tracing_headers: [ 'localhost', 'yourdomain.com' ], // Add your domain here
    },
    serverConfig: {
      enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
    },
    sourcemaps: {
      enabled: true,
      envId: process.env.PROJECT_ID || '', // Your project ID from PostHog settings https://app.posthog.com/settings/environment#variables
      personalApiKey: process.env.PERSONAL_API_KEY || '', // Your personal API key from PostHog settings https://app.posthog.com/settings/user-api-keys (requires organization:read and error_tracking:write scopes)
      project: 'my-application', // Optional: defaults to git repository name
      version: '1.0.0', // Optional: defaults to current git commit
    },
  },
})


public/robots.txt

User-Agent: *
Disallow:

server/api/auth/login.post.ts

import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'

export default defineEventHandler(async (event) => {
  const body = await readBody<{ username: string; password: string }>(event)
  const { username, password } = body || {}

  if (!username || !password) {
    throw createError({
      statusCode: 400,
      message: 'Username and password required',
    })
  }

  const user = getOrCreateUser(username)
  const isNewUser = !users.has(username)

  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  // Capture server-side login event
  const posthog = useServerPostHog()
  
  posthog.capture({
    distinctId: distinctId,
    event: 'server_login',
    properties: {
      $session_id: sessionId,
      username: username,
      isNewUser: isNewUser,
      source: 'api',
    },
  })

  // This handler is short-lived; flush so the enqueued event sends before it returns
  await posthog.flush()

  return {
    success: true,
    user,
  }
})

server/api/burrito/consider.post.ts

import { useServerPostHog } from '../../utils/posthog'
import { users, incrementBurritoConsiderations } from '../../utils/users'
import { defineEventHandler, readBody, createError, getHeader } from 'h3'

export default defineEventHandler(async (event) => {
  const body = await readBody<{ username: string }>(event)
  const username = body?.username

  if (!username) {
    throw createError({
      statusCode: 400,
      message: 'Username required',
    })
  }

  if (!users.has(username)) {
    throw createError({
      statusCode: 404,
      message: 'User not found',
    })
  }

  // Increment burrito considerations (fake, in-memory)
  const user = incrementBurritoConsiderations(username)

  const sessionId = getHeader(event, 'x-posthog-session-id')
  const distinctId = getHeader(event, 'x-posthog-distinct-id')

  // Capture server-side burrito consideration event
  const posthog = useServerPostHog()
  
  posthog.capture({
    distinctId: distinctId,
    event: 'burrito_considered',
    properties: {
      $session_id: sessionId,
      username: username,
      total_considerations: user.burritoConsiderations,
      source: 'api',
    },
  })

  // This handler is short-lived; flush so the enqueued event sends before it returns
  await posthog.flush()

  return {
    success: true,
    user: { ...user },
  }
})

server/utils/posthog.ts

import { PostHog } from 'posthog-node'

let client: PostHog | null = null

export function useServerPostHog(): PostHog {
  if (!client) {
    const config = useRuntimeConfig()
    // The @posthog/nuxt module exposes config at runtimeConfig.public.posthog
    const posthogConfig = config.public.posthog
    client = new PostHog(posthogConfig.publicKey, {
      host: posthogConfig.host,
      flushAt: 1,
      flushInterval: 0,
    })
  }
  return client
}

server/utils/users.ts

// Shared in-memory storage for users (fake, no database)
export const users = new Map<string, { username: string; burritoConsiderations: number }>()

export function getOrCreateUser(username: string): { username: string; burritoConsiderations: number } {
  let user = users.get(username)
  
  if (!user) {
    user = { username, burritoConsiderations: 0 }
    users.set(username, user)
  }
  
  return user
}

export function incrementBurritoConsiderations(username: string): { username: string; burritoConsiderations: number } {
  const user = users.get(username)
  
  if (!user) {
    throw new Error('User not found')
  }
  
  user.burritoConsiderations++
  users.set(username, user)
  
  return { ...user }
}

references/EXAMPLE-php.md

PostHog php Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/php


README.md

PostHog PHP Example - CLI Todo App

A simple command-line todo application built with plain PHP (no framework) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web PHP applications.

Purpose

This example serves as:

  • Verification that the context-mill wizard works for plain PHP projects
  • Reference implementation of PostHog best practices for non-framework PHP code
  • Working example you can run and modify

Features Demonstrated

  • SDK initialization - Uses PostHog::init(...) once with environment-based configuration
  • Event tracking - Captures user actions with distinctId and properties
  • User identification - Associates properties with users via PostHog::identify(...)
  • Error tracking - Enables automatic PHP error tracking and manually captures handled exceptions
  • Proper flushing - Calls PostHog::flush() before CLI exit

Quick Start

1. Install Dependencies
composer install
2. Configure PostHog
# Copy environment template
cp .env.example .env

# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com
3. Run the App
# Add a todo
php todo.php add "Buy groceries"

# List all todos
php todo.php list

# Complete a todo
php todo.php complete 1

# Delete a todo
php todo.php delete 1

# Show statistics
php todo.php stats

What Gets Tracked

The app tracks these events in PostHog:

Event Properties Purpose
todo_added todo_id, todo_length, total_todos When user adds a new todo
todos_viewed total_todos, completed_todos When user lists todos
todo_completed todo_id, time_to_complete_hours When user completes a todo
todo_deleted todo_id, was_completed When user deletes a todo
stats_viewed total_todos, completed_todos, pending_todos When user views stats
$exception exception details and command context When handled errors occur

Code Structure

basics/php/
├── todo.php             # Main CLI application
├── composer.json        # PHP dependencies
├── .env.example         # Environment variable template
├── .gitignore           # Git ignore rules
└── README.md            # This file

Key Implementation Patterns

1. Initialize Once
PostHog::init($projectToken, [
    'host' => $host,
    'error_tracking' => [
        'enabled' => true,
    ],
]);
2. Event Tracking Pattern
PostHog::capture([
    'distinctId' => 'user_123',
    'event' => 'event_name',
    'properties' => ['key' => 'value'],
]);
3. Identifying Users
PostHog::identify([
    'distinctId' => 'user_123',
    'properties' => ['app_language' => 'php'],
]);
4. Exception Tracking
try {
    riskyOperation();
} catch (Throwable $e) {
    PostHog::captureException($e, 'user_123', [
        'command' => 'example_command',
    ]);
}
5. Flush Before CLI Exit
PostHog::flush();

Running Without PostHog

The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.

Next Steps

  • Modify todo.php to experiment with PostHog tracking
  • Add new commands and track their usage
  • Explore feature flags: PostHog::isFeatureEnabled('flag-name', 'user_id')
  • Check your PostHog dashboard to see tracked events

Learn More


.env.example

# PostHog configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

todo.php

<?php

declare(strict_types=1);

// Simple CLI Todo App with PostHog Analytics
//
// A minimal plain PHP CLI application demonstrating PostHog integration
// for non-framework PHP projects (CLIs, scripts, data pipelines, etc.).

require __DIR__ . '/vendor/autoload.php';

use PostHog\PostHog;

const DATA_FILE = '.todo_app_php.json';

function loadEnvFile(string $path): void
{
    if (!file_exists($path)) {
        return;
    }

    foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
            continue;
        }

        [$key, $value] = explode('=', $line, 2);
        $key = trim($key);
        $value = trim($value, " \t\n\r\0\x0B\"'");

        if ($key !== '' && getenv($key) === false) {
            putenv($key . '=' . $value);
            $_ENV[$key] = $value;
        }
    }
}

function dataFilePath(): string
{
    $home = getenv('HOME') ?: sys_get_temp_dir();
    return rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . DATA_FILE;
}

function initializePostHog(): bool
{
    loadEnvFile(__DIR__ . '/.env');

    $projectToken = getenv('POSTHOG_PROJECT_TOKEN');
    if (!$projectToken || str_starts_with($projectToken, 'phc_your_')) {
        echo "WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)\n";
        echo "         App will work but analytics won't be tracked\n";
        return false;
    }

    PostHog::init($projectToken, [
        'host' => getenv('POSTHOG_HOST') ?: 'https://us.i.posthog.com',
        'error_tracking' => [
            'enabled' => true,
            'context_provider' => static function (array $payload): array {
                return [
                    'distinctId' => getUserId(),
                    'properties' => [
                        'app' => 'php_todo_cli',
                        'runtime' => PHP_VERSION,
                        '$exception_source' => $payload['source'] ?? null,
                    ],
                ];
            },
        ],
    ]);

    return true;
}

function getUserId(): string
{
    $path = dataFilePath();
    if (file_exists($path)) {
        $data = json_decode((string) file_get_contents($path), true);
        if (is_array($data) && isset($data['user_id'])) {
            return (string) $data['user_id'];
        }
    }

    return 'user_' . bin2hex(random_bytes(4));
}

function loadTodos(): array
{
    $path = dataFilePath();
    if (!file_exists($path)) {
        return ['user_id' => getUserId(), 'todos' => []];
    }

    $data = json_decode((string) file_get_contents($path), true);
    if (!is_array($data)) {
        return ['user_id' => getUserId(), 'todos' => []];
    }

    $data['todos'] = $data['todos'] ?? [];
    $data['user_id'] = $data['user_id'] ?? getUserId();
    return $data;
}

function saveTodos(array $data): void
{
    file_put_contents(dataFilePath(), json_encode($data, JSON_PRETTY_PRINT) . PHP_EOL);
}

function identifyUser(bool $posthogEnabled): void
{
    if (!$posthogEnabled) {
        return;
    }

    PostHog::identify([
        'distinctId' => getUserId(),
        'properties' => [
            'app_language' => 'php',
            'app_type' => 'cli',
        ],
    ]);
}

function trackEvent(bool $posthogEnabled, string $eventName, array $properties = []): void
{
    if (!$posthogEnabled) {
        return;
    }

    PostHog::capture([
        'distinctId' => getUserId(),
        'event' => $eventName,
        'properties' => $properties,
    ]);
}

function cmdAdd(string $text, bool $posthogEnabled): void
{
    $data = loadTodos();

    $todo = [
        'id' => count($data['todos']) + 1,
        'text' => $text,
        'completed' => false,
        'created_at' => date(DATE_ATOM),
    ];

    $data['todos'][] = $todo;
    saveTodos($data);

    echo "Added todo #{$todo['id']}: {$todo['text']}\n";

    trackEvent($posthogEnabled, 'todo_added', [
        'todo_id' => $todo['id'],
        'todo_length' => strlen($todo['text']),
        'total_todos' => count($data['todos']),
    ]);
}

function cmdList(bool $posthogEnabled): void
{
    $data = loadTodos();

    if (count($data['todos']) === 0) {
        echo "No todos yet! Add one with: php todo.php add 'Your task'\n";
        return;
    }

    echo "\nYour Todos (" . count($data['todos']) . " total):\n\n";

    foreach ($data['todos'] as $todo) {
        $status = $todo['completed'] ? 'X' : ' ';
        echo "  [{$status}] #{$todo['id']}: {$todo['text']}\n";
    }

    echo "\n";

    trackEvent($posthogEnabled, 'todos_viewed', [
        'total_todos' => count($data['todos']),
        'completed_todos' => count(array_filter($data['todos'], static fn (array $todo): bool => (bool) $todo['completed'])),
    ]);
}

function cmdComplete(int $id, bool $posthogEnabled): void
{
    $data = loadTodos();

    foreach ($data['todos'] as &$todo) {
        if ((int) $todo['id'] !== $id) {
            continue;
        }

        if ($todo['completed']) {
            echo "Todo #{$id} is already completed\n";
            return;
        }

        $todo['completed'] = true;
        $todo['completed_at'] = date(DATE_ATOM);
        saveTodos($data);

        echo "Completed todo #{$todo['id']}: {$todo['text']}\n";

        $timeToComplete = (strtotime($todo['completed_at']) - strtotime($todo['created_at'])) / 3600;
        trackEvent($posthogEnabled, 'todo_completed', [
            'todo_id' => $todo['id'],
            'time_to_complete_hours' => $timeToComplete,
        ]);
        return;
    }

    echo "ERROR: Todo #{$id} not found\n";
}

function cmdDelete(int $id, bool $posthogEnabled): void
{
    $data = loadTodos();

    foreach ($data['todos'] as $index => $todo) {
        if ((int) $todo['id'] !== $id) {
            continue;
        }

        unset($data['todos'][$index]);
        $data['todos'] = array_values($data['todos']);
        saveTodos($data);

        echo "Deleted todo #{$id}\n";

        trackEvent($posthogEnabled, 'todo_deleted', [
            'todo_id' => $todo['id'],
            'was_completed' => $todo['completed'],
        ]);
        return;
    }

    echo "ERROR: Todo #{$id} not found\n";
}

function cmdStats(bool $posthogEnabled): void
{
    $data = loadTodos();

    $total = count($data['todos']);
    $completed = count(array_filter($data['todos'], static fn (array $todo): bool => (bool) $todo['completed']));
    $pending = $total - $completed;
    $rate = $total > 0 ? number_format($completed / $total * 100, 1) : '0.0';

    echo "\nStats:\n\n";
    echo "  Total todos:     {$total}\n";
    echo "  Completed:       {$completed}\n";
    echo "  Pending:         {$pending}\n";
    echo "  Completion rate: {$rate}%\n\n";

    trackEvent($posthogEnabled, 'stats_viewed', [
        'total_todos' => $total,
        'completed_todos' => $completed,
        'pending_todos' => $pending,
    ]);
}

function printUsage(): void
{
    echo <<<USAGE
Simple todo app with PostHog analytics

Usage:
  php todo.php add "Todo text"    Add a new todo
  php todo.php list               List all todos
  php todo.php complete <id>      Mark todo as completed
  php todo.php delete <id>        Delete a todo
  php todo.php stats              Show statistics
USAGE;
}

$posthogEnabled = false;

try {
    $posthogEnabled = initializePostHog();
    identifyUser($posthogEnabled);

    $command = $argv[1] ?? null;
    if (!$command) {
        printUsage();
        exit(0);
    }

    switch ($command) {
        case 'add':
            $text = $argv[2] ?? null;
            if (!$text) {
                echo "ERROR: Please provide todo text\n";
                echo "Usage: php todo.php add \"Your task\"\n";
                exit(1);
            }
            cmdAdd($text, $posthogEnabled);
            break;

        case 'list':
            cmdList($posthogEnabled);
            break;

        case 'complete':
            $id = (int) ($argv[2] ?? 0);
            if ($id <= 0) {
                echo "ERROR: Please provide a valid todo ID\n";
                echo "Usage: php todo.php complete <id>\n";
                exit(1);
            }
            cmdComplete($id, $posthogEnabled);
            break;

        case 'delete':
            $id = (int) ($argv[2] ?? 0);
            if ($id <= 0) {
                echo "ERROR: Please provide a valid todo ID\n";
                echo "Usage: php todo.php delete <id>\n";
                exit(1);
            }
            cmdDelete($id, $posthogEnabled);
            break;

        case 'stats':
            cmdStats($posthogEnabled);
            break;

        default:
            echo "ERROR: Unknown command '{$command}'\n";
            printUsage();
            exit(1);
    }
} catch (Throwable $e) {
    echo "ERROR: {$e->getMessage()}\n";

    if ($posthogEnabled) {
        PostHog::captureException($e, getUserId(), [
            'command' => $argv[1] ?? null,
            'app' => 'php_todo_cli',
        ]);
    }

    exit(1);
} finally {
    if ($posthogEnabled) {
        PostHog::flush();
    }
}

references/EXAMPLE-python.md

PostHog python Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/python


README.md

PostHog Python Example - CLI Todo App

A simple command-line todo application built with plain Python (no frameworks) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web Python applications.

Purpose

This example serves as:

  • Verification that the context-mill wizard works for plain Python projects
  • Reference implementation of PostHog best practices for non-framework Python code
  • Working example you can run and modify

Features Demonstrated

  • Instance-based API - Uses Posthog(...) class instead of module-level API
  • Exception autocapture - Automatic tracking of unhandled exceptions
  • Proper shutdown - Uses shutdown() to flush events before exit
  • Event tracking - Captures user actions with distinct_id and properties
  • User identification - Sets properties on users via identify(), and updates them later with set() and setOnce()
  • Error handling - Manual exception capture for handled errors

Quick Start

1. Install Dependencies
# Create virtual environment (recommended)
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install dependencies
pip install -r requirements.txt
2. Configure PostHog
# Copy environment template
cp .env.example .env

# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com
3. Run the App
# Add a todo
python todo.py add "Buy groceries"

# List all todos
python todo.py list

# Complete a todo
python todo.py complete 1

# Delete a todo
python todo.py delete 1

# Show statistics
python todo.py stats

What Gets Tracked

The app tracks these events in PostHog:

Event Properties Purpose
todo_added todo_id, todo_length, total_todos When user adds a new todo
todos_viewed total_todos, completed_todos When user lists todos
todo_completed todo_id, time_to_complete_hours When user completes a todo
todo_deleted todo_id, was_completed When user deletes a todo
stats_viewed total_todos, completed_todos, pending_todos When user views stats

Code Structure

basics/python/
├── todo.py              # Main CLI application
├── requirements.txt     # Python dependencies
├── .env.example        # Environment variable template
├── .gitignore          # Git ignore rules
└── README.md           # This file

Key Implementation Patterns

1. Instance-Based Initialization
from posthog import Posthog

posthog = Posthog(
    api_key,
    host='https://us.i.posthog.com',
    enable_exception_autocapture=True  # Automatically capture exceptions
)
2. Event Tracking Pattern
# Track events with distinct_id
posthog_client.capture(
    distinct_id="user_123",
    event="event_name",
    properties={"key": "value"}
)
3. Proper Shutdown
try:
    # Your application code
    pass
finally:
    # Always call shutdown() to flush events and close connections
    posthog.shutdown()
4. Identifying Users
# Set person properties on a user profile
posthog_client.set(
    distinct_id="user_123",
    properties={"email": "user@example.com", "plan": "pro"}
)
5. Exception Handling
try:
    # Code that might fail
    risky_operation()
except Exception as e:
    # Manually capture handled errors you want to track
    posthog_client.capture_exception(e, distinct_id="user_123")

Running Without PostHog

The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.

Next Steps

  • Modify todo.py to experiment with PostHog tracking
  • Add new commands and track their usage
  • Explore feature flags: posthog.feature_enabled('flag-name', user_id)
  • Check your PostHog dashboard to see tracked events

Learn More


.env.example

# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true

requirements.txt

posthog>=3.0.0
python-dotenv>=1.0.0

todo.py

#!/usr/bin/env python3
"""Simple CLI Todo App with PostHog Analytics

A minimal plain Python CLI application demonstrating PostHog integration
for non-framework Python projects (CLIs, scripts, data pipelines, etc.).
"""

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from posthog import Posthog

# Load environment variables
load_dotenv()

# Data file location
DATA_FILE = Path.home() / ".todo_app.json"


def initialize_posthog():
    """Initialize PostHog with instance-based API.

    Returns PostHog instance or None if project token not configured.
    """
    project_token = os.getenv('POSTHOG_PROJECT_TOKEN')

    if not project_token:
        print("WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)")
        print("         App will work but analytics won't be tracked")
        return None

    # Create PostHog instance with opinionated defaults
    posthog = Posthog(
        project_token,
        host=os.getenv('POSTHOG_HOST', 'https://us.i.posthog.com'),
        debug=os.getenv('POSTHOG_DEBUG', 'False').lower() == 'true',
        enable_exception_autocapture=True  # Auto-capture unhandled exceptions
    )

    return posthog


def get_user_id():
    """Get or create a user ID for this installation.

    Uses a UUID stored in the data file to represent this user.
    In a real app, this would be your actual user ID.
    """
    import uuid

    if DATA_FILE.exists():
        data = json.loads(DATA_FILE.read_text())
        if 'user_id' in data:
            return data['user_id']

    # Create new user ID
    return f"user_{uuid.uuid4().hex[:8]}"


def load_todos():
    """Load todos from disk."""
    if not DATA_FILE.exists():
        return {"user_id": get_user_id(), "todos": []}

    return json.loads(DATA_FILE.read_text())


def save_todos(data):
    """Save todos to disk."""
    DATA_FILE.write_text(json.dumps(data, indent=2))


def track_event(posthog, event_name, properties=None):
    """Track an event with PostHog.

    Uses the real PostHog Python SDK API.
    """
    if not posthog:
        return

    posthog.capture(
        distinct_id=get_user_id(),
        event=event_name,
        properties=properties or {}
    )


def cmd_add(args, posthog):
    """Add a new todo item."""
    data = load_todos()

    todo = {
        "id": len(data["todos"]) + 1,
        "text": args.text,
        "completed": False,
        "created_at": datetime.now().isoformat()
    }

    data["todos"].append(todo)
    save_todos(data)

    print(f"Added todo #{todo['id']}: {todo['text']}")

    # Track the event
    track_event(posthog, "todo_added", {
        "todo_id": todo["id"],
        "todo_length": len(todo["text"]),
        "total_todos": len(data["todos"])
    })


def cmd_list(args, posthog):
    """List all todos."""
    data = load_todos()

    if not data["todos"]:
        print("No todos yet! Add one with: todo add 'Your task'")
        return

    print(f"\nYour Todos ({len(data['todos'])} total):\n")

    for todo in data["todos"]:
        status = "X" if todo["completed"] else " "
        print(f"  [{status}] #{todo['id']}: {todo['text']}")

    print()

    # Track the event
    track_event(posthog, "todos_viewed", {
        "total_todos": len(data["todos"]),
        "completed_todos": sum(1 for t in data["todos"] if t["completed"])
    })


def cmd_complete(args, posthog):
    """Mark a todo as completed."""
    data = load_todos()

    todo = next((t for t in data["todos"] if t["id"] == args.id), None)

    if not todo:
        print(f"ERROR: Todo #{args.id} not found")
        return

    if todo["completed"]:
        print(f"Todo #{args.id} is already completed")
        return

    todo["completed"] = True
    todo["completed_at"] = datetime.now().isoformat()
    save_todos(data)

    print(f"Completed todo #{todo['id']}: {todo['text']}")

    # Track the event
    track_event(posthog, "todo_completed", {
        "todo_id": todo["id"],
        "time_to_complete_hours": (
            datetime.fromisoformat(todo["completed_at"]) -
            datetime.fromisoformat(todo["created_at"])
        ).total_seconds() / 3600
    })


def cmd_delete(args, posthog):
    """Delete a todo."""
    data = load_todos()

    todo = next((t for t in data["todos"] if t["id"] == args.id), None)

    if not todo:
        print(f"ERROR: Todo #{args.id} not found")
        return

    data["todos"].remove(todo)
    save_todos(data)

    print(f"Deleted todo #{args.id}")

    # Track the event
    track_event(posthog, "todo_deleted", {
        "todo_id": todo["id"],
        "was_completed": todo["completed"]
    })


def cmd_stats(args, posthog):
    """Show usage statistics."""
    data = load_todos()

    total = len(data["todos"])
    completed = sum(1 for t in data["todos"] if t["completed"])
    pending = total - completed

    print(f"\nStats:\n")
    print(f"  Total todos:     {total}")
    print(f"  Completed:       {completed}")
    print(f"  Pending:         {pending}")
    print(f"  Completion rate: {(completed/total*100) if total > 0 else 0:.1f}%")
    print()

    # Track the event
    track_event(posthog, "stats_viewed", {
        "total_todos": total,
        "completed_todos": completed,
        "pending_todos": pending
    })


def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Simple todo app with PostHog analytics"
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Add command
    add_parser = subparsers.add_parser("add", help="Add a new todo")
    add_parser.add_argument("text", help="Todo text")

    # List command
    subparsers.add_parser("list", help="List all todos")

    # Complete command
    complete_parser = subparsers.add_parser("complete", help="Mark todo as completed")
    complete_parser.add_argument("id", type=int, help="Todo ID")

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete a todo")
    delete_parser.add_argument("id", type=int, help="Todo ID")

    # Stats command
    subparsers.add_parser("stats", help="Show statistics")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return

    # Initialize PostHog
    posthog = initialize_posthog()

    try:
        # Route to appropriate command
        if args.command == "add":
            cmd_add(args, posthog)
        elif args.command == "list":
            cmd_list(args, posthog)
        elif args.command == "complete":
            cmd_complete(args, posthog)
        elif args.command == "delete":
            cmd_delete(args, posthog)
        elif args.command == "stats":
            cmd_stats(args, posthog)

    except Exception as e:
        print(f"ERROR: {e}")

        # Manually capture handled errors
        if posthog:
            posthog.capture_exception(e, get_user_id())

        sys.exit(1)

    finally:
        # IMPORTANT: Always shutdown PostHog to flush events
        if posthog:
            posthog.shutdown()


if __name__ == "__main__":
    main()

references/EXAMPLE-react-native.md

PostHog react-native Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-native


README.md

PostHog React Native example

This is a bare React Native example (no Expo) demonstrating PostHog integration with product analytics, user identification, autocapture, and error tracking.

Features

  • Product analytics: Track user events and behaviors
  • Autocapture: Automatic touch event and screen view tracking
  • Error tracking: Capture and track errors manually
  • User authentication: Demo login system with PostHog user identification
  • Session persistence: AsyncStorage for maintaining user sessions across app restarts
  • Native navigation: React Navigation v7 with native stack navigator

Prerequisites

For iOS Development

You need a Mac with the following installed:

  1. Xcode (from the Mac App Store)

    • Open App Store and search for "Xcode"
    • Install it (~12GB download)
    • After installing, open Xcode once to accept the license agreement
  2. Xcode Command Line Tools

    xcode-select --install
  3. CocoaPods (iOS dependency manager)

    brew install cocoapods

    Or without Homebrew:

    sudo gem install cocoapods
For Android Development
  1. Android Studio (the Android IDE)

    brew install --cask android-studio

    Or download from: https://developer.android.com/studio

  2. First-time Android Studio Setup

    • Open Android Studio
    • Complete the setup wizard (downloads Android SDK automatically)
    • Go to Settings → Languages & Frameworks → Android SDK
    • Ensure "Android SDK Platform 34" (or latest) is installed
  3. Create an Android Emulator

    • In Android Studio: Tools → Device Manager
    • Click Create Device
    • Select a phone (e.g., "Pixel 7")
    • Download a system image (e.g., API 34)
    • Finish and click the Play button to launch
  4. Environment Variables (add to ~/.zshrc or ~/.bashrc)

    # Android SDK
    export ANDROID_HOME=$HOME/Library/Android/sdk
    export PATH=$PATH:$ANDROID_HOME/emulator
    export PATH=$PATH:$ANDROID_HOME/platform-tools
    
    # Java from Android Studio (required for Gradle)
    export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
    export PATH=$JAVA_HOME/bin:$PATH

    Then run source ~/.zshrc to apply.

  5. Create local.properties file (if SDK location is not detected) Create android/local.properties with:

    sdk.dir=$HOME/Library/Android/sdk
  6. Clear Gradle cache (required when jumping between different versions of Gradle)

    rm -rf ~/.gradle/caches/modules-2/files-2.1/org.gradle.toolchains/foojay-resolver

Getting started

1. Install dependencies
npm install
2. Configure environment variables

Create a .env file:

cp .env.example .env

Edit .env and add your PostHog project token:

POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

Note: The app will still run without a PostHog project token - analytics will simply be disabled.

3. Run on iOS

Install iOS dependencies (first time only):

cd ios && pod install && cd ..

Run the app:

npm run ios

Note: First build takes 5-10 minutes. Subsequent builds are much faster.

4. Run on Android

Make sure an Android emulator is running (from Android Studio Device Manager), then:

npm run android

Note: First build takes 3-5 minutes.

Troubleshooting

iOS Issues

"No `Podfile' found"

  • Make sure you're in the ios directory: cd ios && pod install

Build fails with signing errors

  • Open ios/BurritoApp.xcworkspace in Xcode
  • Select the project → Signing & Capabilities
  • Select your development team

Simulator not launching

  • Open Xcode → Open Developer Tool → Simulator
  • Or run: open -a Simulator
Android Issues

"SDK location not found"

  • Ensure ANDROID_HOME is set in your shell profile
  • Run source ~/.zshrc after adding it

"No connected devices"

  • Launch an emulator from Android Studio Device Manager
  • Or connect a physical device with USB debugging enabled

Gradle build fails

  • Try: cd android && ./gradlew clean && cd ..
  • Then: npm run android

Project structure

src/
├── config/
│   └── posthog.ts           # PostHog client configuration
├── contexts/
│   └── AuthContext.tsx      # Authentication context with PostHog integration
├── navigation/
│   └── RootNavigator.tsx    # React Navigation stack navigator
├── screens/
│   ├── HomeScreen.tsx       # Home/login screen
│   ├── BurritoScreen.tsx    # Demo feature screen with event tracking
│   └── ProfileScreen.tsx    # User profile with error tracking demo
├── services/
│   └── storage.ts           # AsyncStorage wrapper for persistence
├── styles/
│   └── theme.ts             # Shared style constants
└── types/
    └── env.d.ts             # Type declarations for environment variables

App.tsx                      # Root component with PostHogProvider
index.js                     # App entry point
.env                         # Environment variables (create from .env.example)
ios/                         # Native iOS project (Xcode)
android/                     # Native Android project (Android Studio)

Key integration points

PostHog client setup (config/posthog.ts)

The PostHog client is configured with V4 SDK options. If no project token is provided, analytics are disabled gracefully:

import PostHog from 'posthog-react-native'
import Config from 'react-native-config'

const projectToken = Config.POSTHOG_PROJECT_TOKEN
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'

export const posthog = new PostHog(projectToken || 'placeholder_key', {
  host: Config.POSTHOG_HOST || 'https://us.i.posthog.com',
  disabled: !isPostHogConfigured,  // Disable if no project token
  captureAppLifecycleEvents: true,
  debug: __DEV__,
  flushAt: 20,
  flushInterval: 10000,
  preloadFeatureFlags: true,
})
Provider setup with React Navigation v7 (App.tsx)

For React Navigation v7, PostHogProvider must be placed inside NavigationContainer, and screen tracking must be done manually:

import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native'
import { PostHogProvider } from 'posthog-react-native'
import { posthog } from './src/config/posthog'

export default function App() {
  const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null)
  const routeNameRef = useRef<string | undefined>()

  return (
    <NavigationContainer
      ref={navigationRef}
      onReady={() => {
        routeNameRef.current = navigationRef.current?.getCurrentRoute()?.name
      }}
      onStateChange={() => {
        // Manual screen tracking for React Navigation v7
        const previousRouteName = routeNameRef.current
        const currentRouteName = navigationRef.current?.getCurrentRoute()?.name

        if (previousRouteName !== currentRouteName && currentRouteName) {
          posthog.screen(currentRouteName, {
            previous_screen: previousRouteName,
          })
        }
        routeNameRef.current = currentRouteName
      }}
    >
      <PostHogProvider
        client={posthog}
        autocapture={{
          captureScreens: false,  // Disabled for React Navigation v7
          captureTouches: true,   // Enable touch event autocapture
          propsToCapture: ['testID'],
        }}
      >
        <AuthProvider>
          <RootNavigator />
        </AuthProvider>
      </PostHogProvider>
    </NavigationContainer>
  )
}
Autocapture

PostHog autocapture automatically tracks:

  • Touch events: When users interact with the screen
  • App lifecycle events: Application Installed, Updated, Opened, Became Active, Backgrounded

Use testID prop on components to help identify them in analytics:

<TouchableOpacity testID="consider-burrito-button" onPress={handlePress}>
  <Text>Consider Burrito</Text>
</TouchableOpacity>
User identification (contexts/AuthContext.tsx)

Use $set and $set_once for person properties:

import { usePostHog } from 'posthog-react-native'

const posthog = usePostHog()

// On login - identify with person properties
posthog.identify(username, {
  $set: {
    username: username,
  },
  $set_once: {
    first_login_date: new Date().toISOString(),
  },
})

// Capture login event
posthog.capture('user_logged_in', {
  username: username,
  is_new_user: isNewUser,
})

// On logout - reset clears distinct ID and anonymous ID
posthog.capture('user_logged_out')
posthog.reset()
Event tracking (screens/BurritoScreen.tsx)

Capture custom events with properties:

import { usePostHog } from 'posthog-react-native'

const posthog = usePostHog()

// We recommend using a [object] [verb] format for event names
posthog.capture('burrito_considered', {
  total_considerations: user.burritoConsiderations + 1,
  username: user.username,
})
Error tracking (screens/ProfileScreen.tsx)

Capture exceptions using captureException:

import { usePostHog } from 'posthog-react-native'

const posthog = usePostHog()

try {
  throw new Error('Test error for PostHog error tracking')
} catch (err) {
  posthog.captureException(err)
}
Session persistence (services/storage.ts)

AsyncStorage replaces localStorage for persisting user sessions:

import AsyncStorage from '@react-native-async-storage/async-storage'

export const storage = {
  getCurrentUser: async (): Promise<string | null> => {
    return await AsyncStorage.getItem('currentUser')
  },

  setCurrentUser: async (username: string): Promise<void> => {
    await AsyncStorage.setItem('currentUser', username)
  },

  saveUser: async (user: User): Promise<void> => {
    const users = await storage.getUsers()
    users[user.username] = user
    await AsyncStorage.setItem('users', JSON.stringify(users))
  },
}

Learn more


tests/App.test.tsx

/**
 * @format
 */

import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';

test('renders correctly', async () => {
  await ReactTestRenderer.act(() => {
    ReactTestRenderer.create(<App />);
  });
});

.env.example

POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

.prettierrc.js

module.exports = {
  arrowParens: 'avoid',
  singleQuote: true,
  trailingComma: 'all',
};

App.tsx

import React, { useRef } from 'react'
import { StatusBar } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import {
  NavigationContainer,
  NavigationContainerRef,
} from '@react-navigation/native'
import { PostHogProvider } from 'posthog-react-native'

import { AuthProvider } from './src/contexts/AuthContext'
import { RootNavigator, RootStackParamList } from './src/navigation/RootNavigator'
import { posthog } from './src/config/posthog'
import { colors } from './src/styles/theme'

/**
 * Burrito Consideration App
 *
 * A demo React Native application showcasing PostHog analytics integration.
 *
 * Features:
 * - User authentication (demo mode - accepts any credentials)
 * - Burrito consideration counter with event tracking
 * - User profile with statistics
 * - Error tracking demonstration
 *
 * @see https://posthog.com/docs/libraries/react-native
 */
export default function App() {
  const navigationRef = useRef<NavigationContainerRef<RootStackParamList>>(null)
  const routeNameRef = useRef<string | undefined>()

  return (
    <SafeAreaProvider>
      <StatusBar
        barStyle="light-content"
        backgroundColor={colors.headerBackground}
      />
      <NavigationContainer
        ref={navigationRef}
        onReady={() => {
          // Store the initial route name
          routeNameRef.current = navigationRef.current?.getCurrentRoute()?.name
        }}
        onStateChange={() => {
          // Track screen views manually for React Navigation v7
          const previousRouteName = routeNameRef.current
          const currentRouteName = navigationRef.current?.getCurrentRoute()?.name

          if (previousRouteName !== currentRouteName && currentRouteName) {
            // Capture screen view event
            posthog.screen(currentRouteName, {
              previous_screen: previousRouteName,
            })
          }

          // Update the stored route name
          routeNameRef.current = currentRouteName
        }}
      >
        {/*
          PostHogProvider is placed INSIDE NavigationContainer for React Navigation v7.

          For React Navigation v7, we disable automatic screen capture and handle it
          manually via onStateChange above. Touch event autocapture is still enabled.

          @see https://posthog.com/docs/libraries/react-native#with-react-navigationnative-and-autocapture
        */}
        <PostHogProvider
          client={posthog}
          autocapture={{
            // Disable automatic screen capture for React Navigation v7
            // We handle screen tracking manually via NavigationContainer.onStateChange
            captureScreens: false,
            // Enable touch event autocapture
            captureTouches: true,
            // Limit which props are captured for touch events
            propsToCapture: ['testID'],
            // Maximum number of elements captured in touch event hierarchy
            maxElementsCaptured: 20,
          }}
        >
          <AuthProvider>
            <RootNavigator />
          </AuthProvider>
        </PostHogProvider>
      </NavigationContainer>
    </SafeAreaProvider>
  )
}

babel.config.js

module.exports = {
  presets: ['module:@react-native/babel-preset'],
};

Gemfile

source 'https://rubygems.org'

# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"

# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'

# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'

index.js

/**
 * @format
 */

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);

jest.config.js

module.exports = {
  preset: 'react-native',
};

metro.config.js

const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

/**
 * Metro configuration
 * https://reactnative.dev/docs/metro
 *
 * @type {import('@react-native/metro-config').MetroConfig}
 */
const config = {};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

src/config/posthog.ts

import PostHog from 'posthog-react-native'
import Config from 'react-native-config'

// Environment variables are embedded at build time via react-native-config
// Ensure .env file exists with POSTHOG_PROJECT_TOKEN and POSTHOG_HOST
const projectToken = Config.POSTHOG_PROJECT_TOKEN
const host = Config.POSTHOG_HOST || 'https://us.i.posthog.com'
const isPostHogConfigured = projectToken && projectToken !== 'phc_your_project_token_here'

if (!isPostHogConfigured) {
  console.warn(
    'PostHog project token not configured. Analytics will be disabled. ' +
    'Set POSTHOG_PROJECT_TOKEN in your .env file to enable analytics.'
  )
}

/**
 * PostHog client instance for bare React Native
 *
 * Configuration loaded from .env via react-native-config (embedded at build time).
 * Required peer dependencies: @react-native-async-storage/async-storage,
 * react-native-device-info, react-native-localize
 *
 * @see https://posthog.com/docs/libraries/react-native
 */
export const posthog = new PostHog(projectToken || 'placeholder_key', {
  // PostHog API host (usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com')
  host,

  // Enable PostHog only when a project token is configured
  disabled: !isPostHogConfigured,

  // Capture app lifecycle events:
  // - Application Installed, Application Updated
  // - Application Opened, Application Became Active, Application Backgrounded
  captureAppLifecycleEvents: true,

  // Enable debug mode in development for verbose logging
  debug: __DEV__,

  // Batching: queue events and flush periodically to optimize battery usage
  flushAt: 20,              // Number of events to queue before sending
  flushInterval: 10000,     // Interval in ms between periodic flushes
  maxBatchSize: 100,        // Maximum events per batch
  maxQueueSize: 1000,       // Maximum queued events (oldest dropped when full)

  // Feature flags
  preloadFeatureFlags: true,        // Load flags on initialization
  sendFeatureFlagEvent: true,       // Track getFeatureFlag calls for experiments
  featureFlagsRequestTimeoutMs: 10000, // Timeout for flag requests (prevents blocking)

  // Network settings
  requestTimeout: 10000,    // General request timeout in ms
  fetchRetryCount: 3,       // Number of retry attempts for failed requests
  fetchRetryDelay: 3000,    // Delay between retries in ms
})

// Export helper to check if PostHog is enabled
export const isPostHogEnabled = isPostHogConfigured

src/contexts/AuthContext.tsx

import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  ReactNode,
  useCallback,
} from 'react'
import { usePostHog } from 'posthog-react-native'
import { storage, User } from '../services/storage'

interface AuthContextType {
  user: User | null
  isLoading: boolean
  login: (username: string, password: string) => Promise<boolean>
  logout: () => Promise<void>
  incrementBurritoConsiderations: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

interface AuthProviderProps {
  children: ReactNode
}

/**
 * Authentication Provider with PostHog integration
 *
 * Manages user authentication state and integrates with PostHog for:
 * - User identification (posthog.identify)
 * - Login/logout event tracking
 * - Session reset on logout
 *
 * @see https://posthog.com/docs/libraries/react-native#identifying-users
 */
export function AuthProvider({ children }: AuthProviderProps) {
  const posthog = usePostHog()
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  // Restore session on app launch
  useEffect(() => {
    restoreSession()
  }, [])

  const restoreSession = async () => {
    try {
      const storedUsername = await storage.getCurrentUser()
      if (storedUsername) {
        const existingUser = await storage.getUser(storedUsername)
        if (existingUser) {
          setUser(existingUser)

          // Re-identify user in PostHog on session restore
          // This ensures events are correctly attributed after app restart
          posthog.identify(storedUsername, {
            $set: {
              username: storedUsername,
            },
          })
        }
      }
    } catch (error) {
      console.error('Failed to restore session:', error)
    } finally {
      setIsLoading(false)
    }
  }

  const login = useCallback(
    async (username: string, password: string): Promise<boolean> => {
      // Simple validation (demo app accepts any username/password)
      if (!username.trim() || !password.trim()) {
        return false
      }

      try {
        // Check if user exists or create new
        const existingUser = await storage.getUser(username)
        const isNewUser = !existingUser

        const userData: User = existingUser || {
          username,
          burritoConsiderations: 0,
        }

        // Save user data
        await storage.saveUser(userData)
        await storage.setCurrentUser(username)
        setUser(userData)

        // PostHog identify - use username as distinct ID
        // $set updates properties every time, $set_once only sets if not already set
        // @see https://posthog.com/docs/libraries/react-native#identifying-users
        posthog.identify(username, {
          $set: {
            username: username,
          },
          $set_once: {
            first_login_date: new Date().toISOString(),
          },
        })

        // Capture login event with properties
        // @see https://posthog.com/docs/libraries/react-native#capturing-events
        posthog.capture('user_logged_in', {
          username: username,
          is_new_user: isNewUser,
        })

        return true
      } catch (error) {
        console.error('Login error:', error)
        return false
      }
    },
    [posthog],
  )

  const logout = useCallback(async () => {
    // Capture logout event before reset
    posthog.capture('user_logged_out')

    // Reset PostHog - clears the current user's distinct ID and anonymous ID
    // This should be called when the user logs out
    // @see https://posthog.com/docs/libraries/react-native#reset-after-logout
    posthog.reset()

    await storage.removeCurrentUser()
    setUser(null)
  }, [posthog])

  const incrementBurritoConsiderations = useCallback(async () => {
    if (user) {
      const updatedUser: User = {
        ...user,
        burritoConsiderations: user.burritoConsiderations + 1,
      }
      setUser(updatedUser)
      await storage.saveUser(updatedUser)
    }
  }, [user])

  return (
    <AuthContext.Provider
      value={{
        user,
        isLoading,
        login,
        logout,
        incrementBurritoConsiderations,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

src/navigation/RootNavigator.tsx

import React from 'react'
import { ActivityIndicator, View, StyleSheet } from 'react-native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useAuth } from '../contexts/AuthContext'
import { colors } from '../styles/theme'

import HomeScreen from '../screens/HomeScreen'
import BurritoScreen from '../screens/BurritoScreen'
import ProfileScreen from '../screens/ProfileScreen'

// Type definitions for navigation
export type RootStackParamList = {
  Home: undefined
  Burrito: undefined
  Profile: undefined
}

const Stack = createNativeStackNavigator<RootStackParamList>()

export function RootNavigator() {
  const { isLoading } = useAuth()

  // Show loading indicator while restoring session
  if (isLoading) {
    return (
      <View style={styles.loadingContainer}>
        <ActivityIndicator size="large" color={colors.primary} />
      </View>
    )
  }

  return (
    <Stack.Navigator
      screenOptions={{
        headerStyle: {
          backgroundColor: colors.headerBackground,
        },
        headerTintColor: colors.headerText,
        headerTitleStyle: {
          fontWeight: 'bold',
        },
        headerBackTitleVisible: false,
        animation: 'slide_from_right',
      }}
    >
      <Stack.Screen
        name="Home"
        component={HomeScreen}
        options={{
          title: 'Burrito App',
        }}
      />
      <Stack.Screen
        name="Burrito"
        component={BurritoScreen}
        options={{
          title: 'Burrito Consideration',
        }}
      />
      <Stack.Screen
        name="Profile"
        component={ProfileScreen}
        options={{
          title: 'Profile',
        }}
      />
    </Stack.Navigator>
  )
}

const styles = StyleSheet.create({
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: colors.background,
  },
})

src/screens/BurritoScreen.tsx

import React, { useState, useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
  colors,
  spacing,
  typography,
  borderRadius,
  shadows,
} from '../styles/theme'

type BurritoScreenNavigationProp = NativeStackNavigationProp<
  RootStackParamList,
  'Burrito'
>

/**
 * Burrito Consideration Screen
 *
 * Demonstrates PostHog event tracking with custom properties.
 * Each time the user considers a burrito, an event is captured.
 *
 * @see https://posthog.com/docs/libraries/react-native#capturing-events
 */
export default function BurritoScreen() {
  const { user, incrementBurritoConsiderations } = useAuth()
  const navigation = useNavigation<BurritoScreenNavigationProp>()
  const posthog = usePostHog()
  const [hasConsidered, setHasConsidered] = useState(false)

  // Redirect to home if not logged in
  useEffect(() => {
    if (!user) {
      navigation.navigate('Home')
    }
  }, [user, navigation])

  if (!user) {
    return null
  }

  const handleConsideration = async () => {
    const newCount = user.burritoConsiderations + 1

    // Update state first for immediate feedback
    await incrementBurritoConsiderations()
    setHasConsidered(true)

    // Hide success message after 2 seconds
    setTimeout(() => setHasConsidered(false), 2000)

    // Capture custom event in PostHog with properties
    // We recommend using a [object] [verb] format for event names
    // @see https://posthog.com/docs/libraries/react-native#capturing-events
    posthog.capture('burrito_considered', {
      total_considerations: newCount,
      username: user.username,
    })
  }

  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Text style={styles.title}>Burrito Consideration Zone</Text>
        <Text style={styles.text}>
          Take a moment to truly consider the potential of burritos.
        </Text>

        {/*
          testID is captured by PostHog autocapture for touch events
          This helps identify the button in analytics
          @see https://posthog.com/docs/libraries/react-native#autocapture
        */}
        <TouchableOpacity
          style={styles.burritoButton}
          onPress={handleConsideration}
          activeOpacity={0.8}
          testID="consider-burrito-button"
        >
          <Text style={styles.burritoButtonText}>Consider Burrito</Text>
        </TouchableOpacity>

        {hasConsidered && (
          <View style={styles.successContainer}>
            <Text style={styles.success}>
              Thank you for your consideration!
            </Text>
            <Text style={styles.successCount}>
              Count: {user.burritoConsiderations}
            </Text>
          </View>
        )}

        <View style={styles.stats}>
          <Text style={styles.statsTitle}>Consideration Stats</Text>
          <Text style={styles.statsText}>
            Total considerations: {user.burritoConsiderations}
          </Text>
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
    padding: spacing.md,
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  text: {
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.lg,
    lineHeight: 24,
  },
  burritoButton: {
    backgroundColor: colors.burrito,
    borderRadius: borderRadius.sm,
    padding: spacing.lg,
    alignItems: 'center',
    marginVertical: spacing.md,
    ...shadows.sm,
  },
  burritoButtonText: {
    color: colors.white,
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.bold,
  },
  successContainer: {
    alignItems: 'center',
    marginVertical: spacing.sm,
  },
  success: {
    color: colors.success,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.medium,
  },
  successCount: {
    color: colors.success,
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.bold,
    marginTop: spacing.xs,
  },
  stats: {
    backgroundColor: colors.statsBackground,
    padding: spacing.md,
    borderRadius: borderRadius.sm,
    marginTop: spacing.lg,
  },
  statsTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.xs,
  },
  statsText: {
    fontSize: typography.sizes.md,
    color: colors.text,
  },
})

src/screens/HomeScreen.tsx

import React, { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  KeyboardAvoidingView,
  Platform,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
  colors,
  spacing,
  typography,
  borderRadius,
  shadows,
} from '../styles/theme'

type HomeScreenNavigationProp = NativeStackNavigationProp<
  RootStackParamList,
  'Home'
>

export default function HomeScreen() {
  const { user, login, logout } = useAuth()
  const navigation = useNavigation<HomeScreenNavigationProp>()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleSubmit = async () => {
    setError('')

    if (!username.trim() || !password.trim()) {
      setError('Please provide both username and password')
      return
    }

    setIsSubmitting(true)
    try {
      const success = await login(username, password)
      if (success) {
        setUsername('')
        setPassword('')
      } else {
        setError('An error occurred during login')
      }
    } catch {
      setError('An error occurred during login')
    } finally {
      setIsSubmitting(false)
    }
  }

  // Logged in view
  if (user) {
    return (
      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={styles.scrollContent}
      >
        <View style={styles.card}>
          <Text style={styles.title}>Welcome back, {user.username}!</Text>
          <Text style={styles.text}>
            You are logged in. Feel free to explore:
          </Text>

          <View style={styles.buttonGroup}>
            <TouchableOpacity
              style={[styles.button, styles.burritoButton]}
              onPress={() => navigation.navigate('Burrito')}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>Consider Burritos</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[styles.button, styles.primaryButton]}
              onPress={() => navigation.navigate('Profile')}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>View Profile</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[styles.button, styles.logoutButton]}
              onPress={logout}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>Logout</Text>
            </TouchableOpacity>
          </View>
        </View>
      </ScrollView>
    )
  }

  // Login view
  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
      >
        <View style={styles.card}>
          <Text style={styles.title}>Welcome to Burrito Consideration App</Text>
          <Text style={styles.text}>
            Please sign in to begin your burrito journey
          </Text>

          <View style={styles.form}>
            <Text style={styles.label}>Username:</Text>
            <TextInput
              style={styles.input}
              value={username}
              onChangeText={setUsername}
              placeholder="Enter any username"
              placeholderTextColor={colors.textLight}
              autoCapitalize="none"
              autoCorrect={false}
              autoComplete="username"
              editable={!isSubmitting}
            />

            <Text style={styles.label}>Password:</Text>
            <TextInput
              style={styles.input}
              value={password}
              onChangeText={setPassword}
              placeholder="Enter any password"
              placeholderTextColor={colors.textLight}
              secureTextEntry
              autoComplete="password"
              editable={!isSubmitting}
            />

            {error ? <Text style={styles.error}>{error}</Text> : null}

            <TouchableOpacity
              style={[
                styles.button,
                styles.primaryButton,
                isSubmitting && styles.buttonDisabled,
              ]}
              onPress={handleSubmit}
              disabled={isSubmitting}
              activeOpacity={0.8}
            >
              <Text style={styles.buttonText}>
                {isSubmitting ? 'Signing In...' : 'Sign In'}
              </Text>
            </TouchableOpacity>
          </View>

          <Text style={styles.note}>
            Note: This is a demo app. Use any username and password to sign in.
          </Text>
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
  },
  scrollView: {
    flex: 1,
    backgroundColor: colors.background,
  },
  scrollContent: {
    flexGrow: 1,
    padding: spacing.md,
    justifyContent: 'center',
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  text: {
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.md,
    lineHeight: 24,
  },
  form: {
    marginTop: spacing.md,
  },
  label: {
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.medium,
    color: colors.text,
    marginBottom: spacing.xs,
  },
  input: {
    backgroundColor: colors.inputBackground,
    borderWidth: 1,
    borderColor: colors.border,
    borderRadius: borderRadius.sm,
    padding: spacing.sm,
    fontSize: typography.sizes.md,
    color: colors.text,
    marginBottom: spacing.md,
  },
  buttonGroup: {
    marginTop: spacing.md,
    gap: spacing.sm,
  },
  button: {
    borderRadius: borderRadius.sm,
    padding: spacing.md,
    alignItems: 'center',
    marginTop: spacing.sm,
  },
  primaryButton: {
    backgroundColor: colors.primary,
  },
  burritoButton: {
    backgroundColor: colors.burrito,
  },
  logoutButton: {
    backgroundColor: colors.danger,
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  buttonText: {
    color: colors.white,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.semibold,
  },
  error: {
    color: colors.danger,
    marginBottom: spacing.sm,
    fontSize: typography.sizes.sm,
  },
  note: {
    marginTop: spacing.lg,
    color: colors.textSecondary,
    fontSize: typography.sizes.sm,
    textAlign: 'center',
    lineHeight: 20,
  },
})

src/screens/ProfileScreen.tsx

import React, { useEffect } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { usePostHog } from 'posthog-react-native'
import { useAuth } from '../contexts/AuthContext'
import { RootStackParamList } from '../navigation/RootNavigator'
import {
  colors,
  spacing,
  typography,
  borderRadius,
  shadows,
} from '../styles/theme'

type ProfileScreenNavigationProp = NativeStackNavigationProp<
  RootStackParamList,
  'Profile'
>

/**
 * Profile Screen
 *
 * Displays user information and demonstrates PostHog error tracking.
 * The test error button shows how to capture exceptions manually.
 *
 * @see https://posthog.com/docs/libraries/react-native#error-tracking
 */
export default function ProfileScreen() {
  const { user } = useAuth()
  const navigation = useNavigation<ProfileScreenNavigationProp>()
  const posthog = usePostHog()

  // Redirect to home if not logged in
  useEffect(() => {
    if (!user) {
      navigation.navigate('Home')
    }
  }, [user, navigation])

  if (!user) {
    return null
  }

  /**
   * Triggers a test error and captures it in PostHog
   *
   * This demonstrates manual exception capture via captureException.
   * In production, you would typically set up automatic exception capture
   * or use the before_send callback for customization.
   *
   * @see https://posthog.com/docs/libraries/react-native#error-tracking
   */
  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking')
    } catch (err) {
      const error = err as Error

      posthog.captureException(error, {
        username: user.username,
        screen: 'Profile',
      })

      console.error('Captured error:', error)
      Alert.alert(
        'Error Captured',
        'The test error has been sent to PostHog!',
        [{ text: 'OK' }],
      )
    }
  }

  const getJourneyMessage = () => {
    const count = user.burritoConsiderations
    if (count === 0) {
      return "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
    } else if (count === 1) {
      return "You've considered the burrito potential once. Keep going!"
    } else if (count < 5) {
      return "You're getting the hang of burrito consideration!"
    } else if (count < 10) {
      return "You're becoming a burrito consideration expert!"
    } else {
      return 'You are a true burrito consideration master!'
    }
  }

  return (
    <View style={styles.container}>
      <View style={styles.card}>
        <Text style={styles.title}>User Profile</Text>

        <View style={styles.stats}>
          <Text style={styles.statsTitle}>Your Information</Text>
          <View style={styles.infoRow}>
            <Text style={styles.infoLabel}>Username:</Text>
            <Text style={styles.infoValue}>{user.username}</Text>
          </View>
          <View style={styles.infoRow}>
            <Text style={styles.infoLabel}>Burrito Considerations:</Text>
            <Text style={styles.infoValue}>{user.burritoConsiderations}</Text>
          </View>
        </View>

        {/*
          testID is captured by PostHog autocapture for touch events
          @see https://posthog.com/docs/libraries/react-native#autocapture
        */}
        <TouchableOpacity
          style={styles.errorButton}
          onPress={triggerTestError}
          activeOpacity={0.8}
          testID="trigger-error-button"
        >
          <Text style={styles.buttonText}>Trigger Test Error (for PostHog)</Text>
        </TouchableOpacity>

        <View style={styles.journey}>
          <Text style={styles.journeyTitle}>Your Burrito Journey</Text>
          <Text style={styles.journeyText}>{getJourneyMessage()}</Text>
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
    padding: spacing.md,
  },
  card: {
    backgroundColor: colors.cardBackground,
    borderRadius: borderRadius.md,
    padding: spacing.lg,
    ...shadows.md,
  },
  title: {
    fontSize: typography.sizes.xl,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginBottom: spacing.md,
  },
  stats: {
    backgroundColor: colors.statsBackground,
    padding: spacing.md,
    borderRadius: borderRadius.sm,
  },
  statsTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  infoRow: {
    flexDirection: 'row',
    marginBottom: spacing.xs,
  },
  infoLabel: {
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.bold,
    color: colors.text,
    marginRight: spacing.xs,
  },
  infoValue: {
    fontSize: typography.sizes.md,
    color: colors.text,
  },
  errorButton: {
    backgroundColor: colors.danger,
    borderRadius: borderRadius.sm,
    padding: spacing.md,
    alignItems: 'center',
    marginTop: spacing.lg,
  },
  buttonText: {
    color: colors.white,
    fontSize: typography.sizes.md,
    fontWeight: typography.weights.semibold,
  },
  journey: {
    marginTop: spacing.lg,
  },
  journeyTitle: {
    fontSize: typography.sizes.lg,
    fontWeight: typography.weights.semibold,
    color: colors.text,
    marginBottom: spacing.sm,
  },
  journeyText: {
    fontSize: typography.sizes.md,
    color: colors.text,
    lineHeight: 24,
  },
})

src/services/storage.ts

import AsyncStorage from '@react-native-async-storage/async-storage'

const CURRENT_USER_KEY = 'currentUser'
const USERS_KEY = 'users'

export interface User {
  username: string
  burritoConsiderations: number
}

/**
 * Storage service for persisting user data
 * Uses AsyncStorage (React Native's async key-value storage)
 */
export const storage = {
  /**
   * Get the currently logged in user's username
   */
  getCurrentUser: async (): Promise<string | null> => {
    try {
      return await AsyncStorage.getItem(CURRENT_USER_KEY)
    } catch (error) {
      console.error('Error getting current user:', error)
      return null
    }
  },

  /**
   * Set the currently logged in user's username
   */
  setCurrentUser: async (username: string): Promise<void> => {
    try {
      await AsyncStorage.setItem(CURRENT_USER_KEY, username)
    } catch (error) {
      console.error('Error setting current user:', error)
    }
  },

  /**
   * Remove the current user (logout)
   */
  removeCurrentUser: async (): Promise<void> => {
    try {
      await AsyncStorage.removeItem(CURRENT_USER_KEY)
    } catch (error) {
      console.error('Error removing current user:', error)
    }
  },

  /**
   * Get all stored users
   */
  getUsers: async (): Promise<Record<string, User>> => {
    try {
      const data = await AsyncStorage.getItem(USERS_KEY)
      return data ? JSON.parse(data) : {}
    } catch (error) {
      console.error('Error getting users:', error)
      return {}
    }
  },

  /**
   * Get a specific user by username
   */
  getUser: async (username: string): Promise<User | null> => {
    try {
      const users = await storage.getUsers()
      return users[username] || null
    } catch (error) {
      console.error('Error getting user:', error)
      return null
    }
  },

  /**
   * Save a user to storage
   */
  saveUser: async (user: User): Promise<void> => {
    try {
      const users = await storage.getUsers()
      users[user.username] = user
      await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users))
    } catch (error) {
      console.error('Error saving user:', error)
    }
  },

  /**
   * Clear all stored data (for testing/debugging)
   */
  clearAll: async (): Promise<void> => {
    try {
      await AsyncStorage.multiRemove([CURRENT_USER_KEY, USERS_KEY])
    } catch (error) {
      console.error('Error clearing storage:', error)
    }
  },
}

src/styles/theme.ts

/**
 * Theme constants for consistent styling across the app
 * Matches the color scheme from the TanStack Start web version
 */

export const colors = {
  // Primary colors
  primary: '#0070f3',
  primaryDark: '#0051cc',

  // Status colors
  success: '#28a745',
  successDark: '#218838',
  danger: '#dc3545',
  dangerDark: '#c82333',

  // Feature colors
  burrito: '#e07c24',
  burritoDark: '#c96a1a',

  // Neutral colors
  background: '#f5f5f5',
  white: '#ffffff',
  text: '#333333',
  textSecondary: '#666666',
  textLight: '#999999',
  border: '#dddddd',
  borderLight: '#eeeeee',

  // Component-specific
  statsBackground: '#f8f9fa',
  headerBackground: '#333333',
  headerText: '#ffffff',
  inputBackground: '#ffffff',
  cardBackground: '#ffffff',
}

export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,
}

export const typography = {
  sizes: {
    xs: 12,
    sm: 14,
    md: 16,
    lg: 18,
    xl: 24,
    xxl: 32,
  },
  weights: {
    normal: '400' as const,
    medium: '500' as const,
    semibold: '600' as const,
    bold: '700' as const,
  },
}

export const borderRadius = {
  sm: 4,
  md: 8,
  lg: 12,
  full: 9999,
}

export const shadows = {
  sm: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.05,
    shadowRadius: 2,
    elevation: 1,
  },
  md: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  lg: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 5,
  },
}

src/types/env.d.ts

declare module 'react-native-config' {
  export interface NativeConfig {
    POSTHOG_PROJECT_TOKEN?: string
    POSTHOG_HOST?: string
  }

  export const Config: NativeConfig
  export default Config
}

references/EXAMPLE-react-react-router-6.md

PostHog react-react-router-6 Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-6


README.md

PostHog React Router 6 example

This is a React Router 6 example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Client-side Tracking: Examples of client-side tracking methods

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:5173 with your browser to see the app.

Project Structure

src/
├── components/
│   └── Header.jsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.jsx      # Authentication context with PostHog integration
├── routes/
│   ├── Root.jsx             # Root route component
│   ├── Home.jsx             # Home/Login page
│   ├── Burrito.jsx          # Demo feature page with event tracking
│   └── Profile.jsx            # User profile with error tracking demo
├── main.jsx                 # App entry point with PostHog initialization
└── globals.css              # Global styles

Key Integration Points

Client-side initialization (main.jsx)
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
});

<PostHogProvider client={posthog}>
  <RouterProvider router={router} />
</PostHogProvider>
User identification (AuthContext.jsx)

The user is identified when the user logs in on the client-side.

posthog.identify(username);
posthog.capture('user_logged_in');

The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.

You should use these headers in the backend to identify events.

Important: do not identify users on the server-side.

Event tracking (Burrito.jsx)
posthog?.capture('burrito_considered', {
  total_considerations: updatedUser.burritoConsiderations,
  username: user.username,
});
Error tracking

Note: The app can be wrapped with PostHogErrorBoundary from @posthog/react (imported in main.jsx) to automatically capture unhandled React errors. Manual error capture can be added to components using posthog?.captureException(err).

Learn More


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>react-react-router-6</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

src/components/Header.jsx

import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function Header() {
  const { user, logout } = useAuth();
  const posthog = usePostHog();

  const handleLogout = () => {
    if (user) {
      posthog.capture('user_logged_out', {
        username: user.username,
        distinct_id: user.username,
      });
    }
    logout();
  };

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={handleLogout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}


src/contexts/AuthContext.jsx

import { createContext, useContext, useState } from 'react';

const AuthContext = createContext(undefined);

const users = new Map();

export function AuthProvider({ children }) {
  const [user, setUser] = useState(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username, password) => {
    if (!username || !password) {
      return false;
    }

    let localUser = users.get(username);
    if (!localUser) {
      localUser = { 
        username, 
        burritoConsiderations: 0 
      };
      users.set(username, localUser);
    }

    setUser(localUser);
    localStorage.setItem('currentUser', username);
    
    return true;
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const setUserState = (newUser) => {
    setUser(newUser);
    users.set(newUser.username, newUser);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}


src/main.jsx

import './globals.css'

import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Root from './routes/Root';
import Home from './routes/Home';
import Burrito from './routes/Burrito';
import Profile from './routes/Profile';

import posthog from 'posthog-js'; 
import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react' 

// Initialize PostHog
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, { 
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, 
  defaults: '2026-01-30', 
}); 

const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");

ReactDOM.createRoot(root).render(
  <StrictMode>
    <PostHogProvider client={posthog}> 
    <PostHogErrorBoundary>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Root />}>
          <Route index element={<Home />} />
          <Route path="burrito" element={<Burrito />} />
          <Route path="profile" element={<Profile />} />
        </Route>
      </Routes>
    </BrowserRouter>
    </PostHogErrorBoundary>
    </PostHogProvider>
  </StrictMode>,
);

src/routes/Burrito.jsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function BurritoPage() {
  const { user, setUser } = useAuth();
  const navigate = useNavigate();
  const [hasConsidered, setHasConsidered] = useState(false);
  const posthog = usePostHog()

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const handleConsideration = () => {
    const updatedUser = {
      ...user,
      burritoConsiderations: user.burritoConsiderations + 1
    };
    setUser(updatedUser);
    setHasConsidered(true);
    setTimeout(() => setHasConsidered(false), 2000);
    posthog.capture('burrito_considered', {
      total_considerations: updatedUser.burritoConsiderations,
      username: user.username,
      distinct_id: user.username,
    });
  };

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button
          onClick={handleConsideration}
          className="btn-burrito"
        >
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  );
}


src/routes/Home.jsx

import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function Home() {
  const { user, login } = useAuth();
  const posthog = usePostHog();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');

    const success = await login(username, password);
    if (success) {
      posthog.capture('user_logged_in', {
        username: username,
        distinct_id: username,
      });
      setUsername('');
      setPassword('');
    } else {
      setError('Please provide both username and password');
    }
  };

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">Sign In</button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  );
}


src/routes/Profile.jsx

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

export default function ProfilePage() {
  const { user } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p><strong>Username:</strong> {user.username}</p>
        <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You&apos;ve considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You&apos;re getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You&apos;re becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master! 🌯</p>
        )}
      </div>
    </div>
  );
}


src/routes/Root.jsx

import { Outlet } from "react-router-dom";
import Header from "../components/Header";
import { AuthProvider } from "../contexts/AuthContext";

export default function Root() {
  return (
    <AuthProvider>
      <Header />
      <main>
        <Outlet />
      </main>
    </AuthProvider>
  );
}


vite.config.js

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
})

references/EXAMPLE-react-react-router-7-data.md

PostHog react-react-router-7-data Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-data


README.md

PostHog React Router 7 Data Mode example

This is a React Router 7 Data Mode example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Client-side Tracking: Examples of client-side tracking methods
  • Data Mode: React Router 7 data mode with client-side routing

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:5173 with your browser to see the app.

Project Structure

app/
├── components/
│   └── Header.tsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx      # Authentication context with PostHog integration
├── routes/
│   ├── home.tsx             # Home/Login page
│   ├── burrito.tsx          # Demo feature page with event tracking
│   └── profile.tsx          # User profile with error tracking demo
├── root.tsx                 # Root route with error boundary
└── routes.tsx               # Route configuration

index.tsx                    # App entry point with PostHog initialization

Key Integration Points

Client-side initialization (index.tsx)
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
});

<PostHogProvider client={posthog}>
  <RouterProvider router={router} />
</PostHogProvider>
User identification (AuthContext.tsx)

The user is identified when the user logs in on the client-side.

posthog.identify(username);
posthog.capture('user_logged_in');

The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.

You should use these headers in the backend to identify events.

Important: do not identify users on the server-side.

Event tracking (burrito.tsx)
posthog?.capture('burrito_considered', {
  total_considerations: updatedUser.burritoConsiderations,
  username: user.username,
});
Error tracking (root.tsx, profile.tsx)

Errors are captured in two ways:

  1. Error boundary - The RootErrorBoundary in root.tsx automatically captures unhandled React Router errors:
export function RootErrorBoundary() {
  const error = useRouteError();
  const posthog = usePostHog();
  if (error) {
    posthog.captureException(error);
  }
  // ... error UI
}
  1. Manual error capture in components (profile.tsx):
posthog?.captureException(err);

Learn More


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=

.react-router/types/+future.ts

// Generated by React Router

import "react-router";

declare module "react-router" {
  interface Future {
    v8_middleware: false
  }
}

.react-router/types/+routes.ts

// Generated by React Router

import "react-router"

declare module "react-router" {
  interface Register {
    pages: Pages
    routeFiles: RouteFiles
    routeModules: RouteModules
  }
}

type Pages = {
  "/": {
    params: {};
  };
};

type RouteFiles = {
  "root.tsx": {
    id: "root";
    page: "/";
  };
  "routes/home.tsx": {
    id: "routes/home";
    page: "/";
  };
};

type RouteModules = {
  "root": typeof import("./app/root.tsx");
  "routes/home": typeof import("./app/routes/home.tsx");
};

.react-router/types/app/+types/root.ts

// Generated by React Router

import type { GetInfo, GetAnnotations } from "react-router/internal";

type Module = typeof import("../root.js")

type Info = GetInfo<{
  file: "root.tsx",
  module: Module
}>

type Matches = [{
  id: "root";
  module: typeof import("../root.js");
}];

type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;

export namespace Route {
  // links
  export type LinkDescriptors = Annotations["LinkDescriptors"];
  export type LinksFunction = Annotations["LinksFunction"];

  // meta
  export type MetaArgs = Annotations["MetaArgs"];
  export type MetaDescriptors = Annotations["MetaDescriptors"];
  export type MetaFunction = Annotations["MetaFunction"];

  // headers
  export type HeadersArgs = Annotations["HeadersArgs"];
  export type HeadersFunction = Annotations["HeadersFunction"];

  // middleware
  export type MiddlewareFunction = Annotations["MiddlewareFunction"];

  // clientMiddleware
  export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];

  // loader
  export type LoaderArgs = Annotations["LoaderArgs"];

  // clientLoader
  export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];

  // action
  export type ActionArgs = Annotations["ActionArgs"];

  // clientAction
  export type ClientActionArgs = Annotations["ClientActionArgs"];

  // HydrateFallback
  export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];

  // Component
  export type ComponentProps = Annotations["ComponentProps"];

  // ErrorBoundary
  export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

.react-router/types/app/routes/+types/home.ts

// Generated by React Router

import type { GetInfo, GetAnnotations } from "react-router/internal";

type Module = typeof import("../home.js")

type Info = GetInfo<{
  file: "routes/home.tsx",
  module: Module
}>

type Matches = [{
  id: "root";
  module: typeof import("../../root.js");
}, {
  id: "routes/home";
  module: typeof import("../home.js");
}];

type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;

export namespace Route {
  // links
  export type LinkDescriptors = Annotations["LinkDescriptors"];
  export type LinksFunction = Annotations["LinksFunction"];

  // meta
  export type MetaArgs = Annotations["MetaArgs"];
  export type MetaDescriptors = Annotations["MetaDescriptors"];
  export type MetaFunction = Annotations["MetaFunction"];

  // headers
  export type HeadersArgs = Annotations["HeadersArgs"];
  export type HeadersFunction = Annotations["HeadersFunction"];

  // middleware
  export type MiddlewareFunction = Annotations["MiddlewareFunction"];

  // clientMiddleware
  export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];

  // loader
  export type LoaderArgs = Annotations["LoaderArgs"];

  // clientLoader
  export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];

  // action
  export type ActionArgs = Annotations["ActionArgs"];

  // clientAction
  export type ClientActionArgs = Annotations["ClientActionArgs"];

  // HydrateFallback
  export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];

  // Component
  export type ComponentProps = Annotations["ComponentProps"];

  // ErrorBoundary
  export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

app/components/Header.tsx

import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function Header() {
  const { user, logout } = useAuth();
  const posthog = usePostHog();

  const handleLogout = () => {
    posthog?.capture('user_logged_out');
    posthog?.reset();
    logout();
  };

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={handleLogout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}

app/contexts/AuthContext.tsx

import { usePostHog } from '@posthog/react';
import { createContext, useContext, useState, type ReactNode } from 'react';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  setUser: (user: User) => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  const posthog = usePostHog();
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username: string, password: string): Promise<boolean> => {
    // Client-side only fake auth - no server calls
    if (!username || !password) {
      return false;
    }

    let localUser = users.get(username);
    if (!localUser) {
      localUser = { 
        username, 
        burritoConsiderations: 0 
      };
      users.set(username, localUser);
    }

    setUser(localUser);
    localStorage.setItem('currentUser', username);
    
    // Identifying the user once on login/sign up is enough.
    posthog.identify(username);
    posthog.capture('user_logged_in');

    return true;
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const setUserState = (newUser: User) => {
    setUser(newUser);
    users.set(newUser.username, newUser);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

app/root.tsx

import { Outlet, useRouteError, isRouteErrorResponse } from "react-router";
import Header from "./components/Header";
import { AuthProvider } from "./contexts/AuthContext";
import "./globals.css";
import { usePostHog } from "@posthog/react";

export default function Root() {
  return (
      <AuthProvider>
        <Header />
        <main>
          <Outlet />
        </main>
      </AuthProvider>
  );
}

export function RootErrorBoundary() {
  const error = useRouteError();
  
  const posthog = usePostHog();
  if (error) {
    posthog.captureException(error);
  }

  if (isRouteErrorResponse(error)) {
    return (
      <>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
      </>
    );
  } else if (error instanceof Error) {
    return (
      <div>
        <h1>Error</h1>
        <p>{error.message}</p>
        <p>The stack trace is:</p>
        <pre>{error.stack}</pre>
      </div>
    );
  } else {
    return <h1>Unknown Error</h1>;
  }
}

app/routes.tsx

import React from "react";
import type { RouteObject } from "react-router";
import Root, { RootErrorBoundary } from "./root";
import Home from "./routes/home";
import Burrito from "./routes/burrito";
import Profile from "./routes/profile";

export const routes: RouteObject[] = [
  {
    path: "/",
    element: <Root />,
    ErrorBoundary: RootErrorBoundary,
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: "burrito",
        element: <Burrito />,
      },
      {
        path: "profile",
        element: <Profile />,
      },
    ],
  },
];


app/routes/burrito.tsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function BurritoPage() {
  const { user, setUser } = useAuth();
  const navigate = useNavigate();
  const posthog = usePostHog();
  const [hasConsidered, setHasConsidered] = useState(false);

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const handleConsideration = () => {
    // Client-side only - no server calls
    const updatedUser = {
      ...user,
      burritoConsiderations: user.burritoConsiderations + 1
    };
    setUser(updatedUser);
    setHasConsidered(true);
    setTimeout(() => setHasConsidered(false), 2000);
    
    // Capture burrito consideration event
    posthog?.capture('burrito_considered', {
      total_considerations: updatedUser.burritoConsiderations,
      username: user.username,
    });
  };

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button
          onClick={handleConsideration}
          className="btn-burrito"
        >
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  );
}

app/routes/home.tsx

import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';

export default function Home() {
  const { user, login } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    const success = await login(username, password);
    if (success) {
      setUsername('');
      setPassword('');
    } else {
      setError('Please provide both username and password');
    }
  };

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">Sign In</button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  );
}

app/routes/profile.tsx

import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function ProfilePage() {
  const { user } = useAuth();
  const navigate = useNavigate();
  const posthog = usePostHog();

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      posthog?.captureException(err);
      console.error('Captured error:', err);
      alert('Error captured and sent to PostHog!');
    }
  };

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p><strong>Username:</strong> {user.username}</p>
        <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
          Trigger Test Error (for PostHog)
        </button>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You&apos;ve considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You&apos;re getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You&apos;re becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master! 🌯</p>
        )}
      </div>
    </div>
  );
}

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Router 7 Data Mode</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/index.tsx"></script>
  </body>
</html>

index.tsx

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import Root, { RootErrorBoundary } from "./app/root";
import Home from "./app/routes/home";
import Burrito from "./app/routes/burrito";
import Profile from "./app/routes/profile";

import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
});

const router = createBrowserRouter([
  {
    path: "/",
    Component: Root,
    ErrorBoundary: RootErrorBoundary,
    children: [
      {
        index: true,
        Component: Home,
      },
      {
        path: "burrito",
        Component: Burrito,
      },
      {
        path: "profile",
        Component: Profile,
      },
    ],
  },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <PostHogProvider client={posthog}>
      <RouterProvider router={router} />
    </PostHogProvider>
  </StrictMode>
);


vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
});


references/EXAMPLE-react-react-router-7-declarative.md

PostHog react-react-router-7-declarative Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-declarative


README.md

PostHog React Router 7 Declarative example

This is a React Router 7 Declarative example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Client-side Tracking: Examples of client-side tracking methods
  • Declarative Routing: React Router 7 declarative routing configuration

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:5173 with your browser to see the app.

Project Structure

src/
├── components/
│   └── Header.tsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx      # Authentication context with PostHog integration
├── routes/
│   ├── Root.tsx             # Root route component
│   ├── Home.tsx             # Home/Login page
│   ├── Burrito.tsx          # Demo feature page with event tracking
│   └── Profile.tsx          # User profile with error tracking demo
├── main.tsx                 # App entry point with PostHog initialization
└── globals.css              # Global styles

Key Integration Points

Client-side initialization (main.tsx)
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
});

<PostHogProvider client={posthog}>
  <RouterProvider router={router} />
</PostHogProvider>
User identification (AuthContext.tsx)

The user is identified when the user logs in on the client-side.

posthog.identify(username);
posthog.capture('user_logged_in');

The session and distinct ID can be passed to the backend by including the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers.

You should use these headers in the backend to identify events.

Important: Identify the user once on the client-side to consolidate the new user ID and the automatically generated anonymous ID. Don't identify again on the server-side.

Event tracking (Burrito.tsx)
posthog?.capture('burrito_considered', {
  total_considerations: updatedUser.burritoConsiderations,
  username: user.username,
});
Error tracking (PostHogErrorBoundary)

The app is wrapped with PostHogErrorBoundary from @posthog/react in main.tsx to automatically capture unhandled React errors:

<PostHogProvider client={posthog}>
  <PostHogErrorBoundary>
    {/* app content */}
  </PostHogErrorBoundary>
</PostHogProvider>

Manual error capture can also be added to components using posthog?.captureException(err).

Learn More


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>react-react-router-7-declarative</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

src/App.tsx

import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App

src/components/Header.tsx

import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function Header() {
  const { user, logout } = useAuth();
  const posthog = usePostHog();

  const handleLogout = () => {
    posthog?.reset();
    logout();
  };

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={handleLogout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}


src/contexts/AuthContext.tsx

import { usePostHog } from '@posthog/react';
import { createContext, useContext, useState, type ReactNode } from 'react';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  setUser: (user: User) => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  const posthog = usePostHog();
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username: string, password: string): Promise<boolean> => {
    // Client-side only fake auth - no server calls
    if (!username || !password) {
      return false;
    }

    let localUser = users.get(username);
    if (!localUser) {
      localUser = { 
        username, 
        burritoConsiderations: 0 
      };
      users.set(username, localUser);
    }

    setUser(localUser);
    localStorage.setItem('currentUser', username);
    
    // Identifying the user once on login/sign up is enough.
    posthog.identify(username);
    posthog.capture('user_logged_in');
    
    return true;
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const setUserState = (newUser: User) => {
    setUser(newUser);
    users.set(newUser.username, newUser);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, setUser: setUserState }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}


src/main.tsx

import './globals.css'

import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router";
import Root from './routes/Root';
import Home from './routes/Home';
import Burrito from './routes/Burrito';
import Profile from './routes/Profile';

import posthog from 'posthog-js';
import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
});

const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");

ReactDOM.createRoot(root).render(
  <StrictMode>
    <PostHogProvider client={posthog}>
    <PostHogErrorBoundary>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Root />}>
          <Route index element={<Home />} />
          <Route path="burrito" element={<Burrito />} />
          <Route path="profile" element={<Profile />} />
          </Route>
        </Routes>
      </BrowserRouter>
      </PostHogErrorBoundary> 
    </PostHogProvider>
  </StrictMode>,
);


src/routes/Burrito.tsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

export default function BurritoPage() {
  const { user, setUser } = useAuth();
  const navigate = useNavigate();
  const [hasConsidered, setHasConsidered] = useState(false);

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const handleConsideration = () => {
    // Client-side only - no server calls
    const updatedUser = {
      ...user,
      burritoConsiderations: user.burritoConsiderations + 1
    };
    setUser(updatedUser);
    setHasConsidered(true);
    setTimeout(() => setHasConsidered(false), 2000);
  };

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button
          onClick={handleConsideration}
          className="btn-burrito"
        >
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  );
}


src/routes/Home.tsx

import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';

export default function Home() {
  const { user, login } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    const success = await login(username, password);
    if (success) {
      setUsername('');
      setPassword('');
    } else {
      setError('Please provide both username and password');
    }
  };

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">Sign In</button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  );
}


src/routes/Profile.tsx

import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

export default function ProfilePage() {
  const { user } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p><strong>Username:</strong> {user.username}</p>
        <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You&apos;ve considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You&apos;re getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You&apos;re becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master! 🌯</p>
        )}
      </div>
    </div>
  );
}


src/routes/Root.tsx

import { Outlet } from "react-router";
import Header from "../components/Header";
import { AuthProvider } from "../contexts/AuthContext";

export default function Root() {
  return (
    <AuthProvider>
      <Header />
      <main>
        <Outlet />
      </main>
    </AuthProvider>
  );
}

src/vite-env.d.ts

/// <reference types="vite/client" />


vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
})

references/EXAMPLE-react-react-router-7-framework.md

PostHog react-react-router-7-framework Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-react-router-7-framework


README.md

PostHog React Router 7 Framework example

This is a React Router 7 Framework example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product Analytics: Track user events and behaviors
  • Session Replay: Record and replay user sessions
  • Error Tracking: Capture and track errors
  • User Authentication: Demo login system with PostHog user identification
  • Server-side & Client-side Tracking: Examples of both tracking methods
  • SSR Support: Server-side rendering with React Router 7 Framework

Getting Started

1. Install Dependencies
npm install
# or
pnpm install
2. Configure Environment Variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the Development Server
npm run dev
# or
pnpm dev

Open http://localhost:5173 with your browser to see the app.

Project Structure

app/
├── components/
│   └── Header.tsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx      # Authentication context
├── lib/
│   ├── posthog-middleware.ts # Server-side PostHog middleware
│   └── db.ts                # Database utilities
├── routes/
│   ├── home.tsx             # Home/Login page
│   ├── burrito.tsx          # Demo feature page with event tracking
│   ├── profile.tsx          # User profile with error tracking demo
│   ├── api.auth.login.ts    # Login API with server-side tracking
│   └── api.burrito.consider.ts # Burrito API with server-side tracking
├── entry.client.tsx         # Client entry with PostHog initialization
├── entry.server.tsx         # Server entry
└── root.tsx                 # Root route with error boundary

Key Integration Points

Client-side initialization (entry.client.tsx)
import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
  tracing_headers: [ window.location.hostname ],
});

<PostHogProvider client={posthog}>
  <HydratedRouter />
</PostHogProvider>
User identification (home.tsx)

The user is identified when the user logs in on the client-side.

posthog?.identify(username, {
  username: username,
});
posthog?.capture('user_logged_in', {
  username: username,
});

The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the tracing_headers option in the PostHog initialization.

Important: do not identify users on the server-side.

Server-side middleware (posthog-middleware.ts)

The PostHog middleware creates a server-side PostHog client for each request and extracts session and user context from request headers:

export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
  const posthog = new PostHog(process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
    host: process.env.VITE_PUBLIC_POSTHOG_HOST!,
    flushAt: 1,
    flushInterval: 0,
  });

  const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
  const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');

  context.posthog = posthog;

  const response = await posthog.withContext(
    { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
    next
  );

  await posthog.shutdown().catch(() => {});
  return response;
};

Key Points:

  • Creates a new PostHog Node client for each request
  • Extracts sessionId and distinctId from request headers (automatically set by the client-side SDK)
  • Sets the PostHog client on the request context for use in route handlers
  • Uses withContext() to associate server-side events with the correct session/user
  • Properly shuts down the client after each request
Event tracking (burrito.tsx)
posthog?.capture('burrito_considered', {
  total_considerations: count,
  username: username,
});
Error tracking (root.tsx, profile.tsx)

Errors are captured in two ways:

  1. Error boundary - The ErrorBoundary in root.tsx automatically captures unhandled React Router errors:
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  const posthog = usePostHog();
  posthog.captureException(error);
  // ... error UI
}
  1. Manual error capture in components (profile.tsx):
posthog.captureException(err);
Server-side tracking (api.auth.login.ts, api.burrito.consider.ts)

Server-side events use the PostHog client from the request context (set by the middleware):

const posthog = (context as any).posthog as PostHog | undefined;
if (posthog) {
  posthog.capture({ event: 'server_login' });
}

Key Points:

  • The PostHog client is available via context.posthog (set by the middleware)
  • Events are automatically associated with the correct user/session via the middleware's withContext() call
  • The distinctId and sessionId are extracted from request headers and used to maintain context between client and server

Learn More


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=
VITE_PUBLIC_POSTHOG_HOST=
PROJECT_ID=

app/components/Header.tsx

import { Link } from 'react-router';
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export default function Header() {
  const { user, logout } = useAuth();
  const posthog = usePostHog();

  const handleLogout = () => {
    posthog?.capture('user_logged_out');
    posthog?.reset();
    logout();
  };

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
              <Link to="/error">Error</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={handleLogout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  );
}


app/contexts/AuthContext.tsx

import { createContext, useContext, useState, type ReactNode } from 'react';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  incrementBurritoConsiderations: () => void;
  setUser: (user: User) => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });

  const login = async (username: string, password: string): Promise<boolean> => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      });

      if (response.ok) {
        const { user: userData } = await response.json();

        let localUser = users.get(username);
        if (!localUser) {
          localUser = userData as User;
          users.set(username, localUser);
        }

        setUser(localUser);
        localStorage.setItem('currentUser', username);
        
        return true;
      }
      return false;
    } catch (error) {
      console.error('Login error:', error);
      return false;
    }
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++;
      users.set(user.username, user);
      setUser({ ...user });
    }
  };

  const setUserState = (newUser: User) => {
    setUser(newUser);
    users.set(newUser.username, newUser);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations, setUser: setUserState }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}


app/entry.client.tsx

import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";

import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'

posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
  defaults: '2026-01-30',
  tracing_headers: [ window.location.hostname ],
});


startTransition(() => {
  hydrateRoot(
    document,
    <PostHogProvider client={posthog}>
      <StrictMode>
        <HydratedRouter />
      </StrictMode>
    </PostHogProvider>,
  );
});

app/entry.server.tsx

import { PassThrough } from "node:stream";

import type { EntryContext, RouterContextProvider } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";

export const streamTimeout = 5_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: RouterContextProvider,
) {
  // https://httpwg.org/specs/rfc9110.html#HEAD
  if (request.method.toUpperCase() === "HEAD") {
    return new Response(null, {
      status: responseStatusCode,
      headers: responseHeaders,
    });
  }

  return new Promise((resolve, reject) => {
    let shellRendered = false;
    let userAgent = request.headers.get("user-agent");

    // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
    // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
    let readyOption: keyof RenderToPipeableStreamOptions =
      (userAgent && isbot(userAgent)) || routerContext.isSpaMode
        ? "onAllReady"
        : "onShellReady";

    // Abort the rendering stream after the `streamTimeout` so it has time to
    // flush down the rejected boundaries
    let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
      () => abort(),
      streamTimeout + 1000,
    );

    const { pipe, abort } = renderToPipeableStream(
      <ServerRouter context={routerContext} url={request.url} />,
      {
        [readyOption]() {
          shellRendered = true;
          const body = new PassThrough({
            final(callback) {
              // Clear the timeout to prevent retaining the closure and memory leak
              clearTimeout(timeoutId);
              timeoutId = undefined;
              callback();
            },
          });
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");

          pipe(body);

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          );
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      },
    );
  });
}

app/lib/db.ts

import sqlite3 from "sqlite3";
import { join } from "node:path";
import { promisify } from "node:util";

const dbPath = join(process.cwd(), "burrito-considerations.db");

const db = new sqlite3.Database(dbPath);

// Initialize schema
db.serialize(() => {
  db.run(`
    CREATE TABLE IF NOT EXISTS burrito_considerations (
      username TEXT PRIMARY KEY,
      count INTEGER NOT NULL DEFAULT 0
    )
  `);
});

const dbGet = promisify(db.get.bind(db));
const dbRun = promisify(db.run.bind(db));

export function getBurritoConsiderations(username: string): Promise<number> {
  return dbGet("SELECT count FROM burrito_considerations WHERE username = ?", [username])
    .then((row: any) => row?.count ?? 0);
}

export function incrementBurritoConsiderations(username: string): Promise<number> {
  return dbRun(`
    INSERT INTO burrito_considerations (username, count)
    VALUES (?, 1)
    ON CONFLICT(username) DO UPDATE SET count = count + 1
  `, [username])
    .then(() => {
      return dbGet("SELECT count FROM burrito_considerations WHERE username = ?", [username]);
    })
    .then((row: any) => row.count);
}

app/lib/posthog-middleware.ts

import { PostHog } from "posthog-node";
import type { RouterContextProvider } from "react-router";
import type { Route } from "../+types/root";

export interface PostHogContext extends RouterContextProvider {
  posthog?: PostHog;
}

export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
  const posthog = new PostHog(process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
    host: process.env.VITE_PUBLIC_POSTHOG_HOST!,
    flushAt: 1,
    flushInterval: 0,
  });

  const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
  const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');

  (context as PostHogContext).posthog = posthog;

  const response = await posthog.withContext(
    { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
    next
  );

  await posthog.shutdown().catch(() => {});

  return response;
};


app/root.tsx

import { usePostHog } from '@posthog/react';
import {
  isRouteErrorResponse,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import "./app.css";
import "./globals.css";
import Header from "./components/Header";
import { AuthProvider } from "./contexts/AuthContext";
import { posthogMiddleware } from "./lib/posthog-middleware";

export const middleware: Route.MiddlewareFunction[] = [
  posthogMiddleware,
];

export const links: Route.LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
  {
    rel: "preconnect",
    href: "https://fonts.gstatic.com",
    crossOrigin: "anonymous",
  },
  {
    rel: "stylesheet",
    href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
  },
];

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return (
    <AuthProvider>
      <Header />
      <main>
        <Outlet />
      </main>
    </AuthProvider>
  );
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;

  const posthog = usePostHog();
  posthog.captureException(error);

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message;
    stack = error.stack;
  }

  return (
    <main className="pt-16 p-4 container mx-auto">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre className="w-full p-4 overflow-x-auto">
          <code>{stack}</code>
        </pre>
      )}
    </main>
  );
}

app/routes.ts

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("burrito", "routes/burrito.tsx"),
  route("profile", "routes/profile.tsx"),
  route("error", "routes/error.tsx"),
  route("api/auth/login", "routes/api.auth.login.ts"),
  route("api/burrito/consider", "routes/api.burrito.consider.ts"),
] satisfies RouteConfig;

app/routes/api.auth.login.ts

import type { Route } from "./+types/api.auth.login";
import { getBurritoConsiderations } from "../lib/db";
import type { PostHogContext } from "../lib/posthog-middleware";

const users = new Map<string, { username: string }>();

export { users };

export async function action({ request, context }: Route.ActionArgs) {
  const body = await request.json();
  const { username, password } = body;

  if (!username || !password) {
    return Response.json({ error: 'Username and password required' }, { status: 400 });
  }

  let user = users.get(username);
  
  if (!user) {
    user = { username };
    users.set(username, user);
  }

  const posthog = (context as PostHogContext).posthog;
  if (posthog) {
    posthog.capture({ event: 'server_login' });
  }

  const burritoConsiderations = await getBurritoConsiderations(username);

  return Response.json({ 
    success: true, 
    user: { ...user, burritoConsiderations } 
  });
}

app/routes/api.burrito.consider.ts

import type { Route } from "./+types/api.burrito.consider";
import { users } from "./api.auth.login";
import { incrementBurritoConsiderations } from "../lib/db";
import type { PostHogContext } from "../lib/posthog-middleware";

export async function action({ request, context }: Route.ActionArgs) {
  const body = await request.json();
  const { username } = body;

  if (!username) {
    return Response.json({ error: 'Username required' }, { status: 400 });
  }

  const user = users.get(username);
  
  if (!user) {
    return Response.json({ error: 'User not found' }, { status: 404 });
  }

  const burritoConsiderations = await incrementBurritoConsiderations(username);

  const posthog = (context as PostHogContext).posthog;
  posthog?.capture({ event: 'burrito_considered' });
  
  return Response.json({ 
    success: true, 
    user: { ...user, burritoConsiderations } 
  });
}


app/routes/burrito.tsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import type { Route } from "./+types/burrito";
import { useAuth } from '../contexts/AuthContext';

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Burrito Consideration - Burrito Consideration App" },
    { name: "description", content: "Consider the potential of burritos" },
  ];
}

export default function BurritoPage() {
  const { user, setUser } = useAuth();
  const navigate = useNavigate();
  const [hasConsidered, setHasConsidered] = useState(false);

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const handleConsideration = async () => {
    try {
      const response = await fetch('/api/burrito/consider', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: user.username }),
      });

      if (response.ok) {
        const { user: updatedUser } = await response.json();
        setUser(updatedUser);
        setHasConsidered(true);
        setTimeout(() => setHasConsidered(false), 2000);
      } else {
        console.error('Failed to increment burrito considerations');
      }
    } catch (err) {
      console.error('Error considering burrito:', err);
    }
  };

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button
          onClick={handleConsideration}
          className="btn-burrito"
        >
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  );
}


app/routes/error.tsx

import type { Route } from "./+types/error";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Error Test - Burrito Consideration App" },
    { name: "description", content: "Test error boundary" },
  ];
}

export default function ErrorPage() {
  // This will throw an error during render, which will be caught by ErrorBoundary
  throw new Error('Test error for ErrorBoundary - this is a render-time error');
}


app/routes/home.tsx

import { useState } from 'react';
import type { Route } from "./+types/home";
import { useAuth } from '../contexts/AuthContext';
import { usePostHog } from '@posthog/react';

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Burrito Consideration App" },
    { name: "description", content: "Consider the potential of burritos" },
  ];
}

export default function Home() {
  const { user, login } = useAuth();
  const posthog = usePostHog();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      const success = await login(username, password);
      if (success) {
        // Identifying the user once on login/sign up is enough.
        posthog?.identify(username);
        
        // Capture login event
        posthog?.capture('user_logged_in');
        
        setUsername('');
        setPassword('');
      } else {
        setError('Please provide both username and password');
      }
    } catch (err) {
      console.error('Login failed:', err);
      setError('An error occurred during login');
    }
  };

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">Sign In</button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  );
}

app/routes/profile.tsx

import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import type { Route } from "./+types/profile";
import { useAuth } from '../contexts/AuthContext';
import posthog from 'posthog-js';
import { usePostHog } from '@posthog/react';

export function meta({}: Route.MetaArgs) {
  return [
    { title: "User Profile - Burrito Consideration App" },
    { name: "description", content: "View your profile and burrito consideration stats" },
  ];
}

export default function ProfilePage() {
  const { user } = useAuth();
  const navigate = useNavigate();
  const posthog = usePostHog();
  

  useEffect(() => {
    if (!user) {
      navigate('/');
    }
  }, [user, navigate]);

  if (!user) {
    return null;
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking');
    } catch (err) {
      console.error('Captured error:', err);
      posthog.captureException(err);
    }
  };

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p><strong>Username:</strong> {user.username}</p>
        <p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
          Trigger Test Error (for PostHog)
        </button>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You&apos;ve considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You&apos;re getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You&apos;re becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master! 🌯</p>
        )}
      </div>
    </div>
  );
}


app/welcome/welcome.tsx

import logoDark from "./logo-dark.svg";
import logoLight from "./logo-light.svg";

export function Welcome() {
  return (
    <main className="flex items-center justify-center pt-16 pb-4">
      <div className="flex-1 flex flex-col items-center gap-16 min-h-0">
        <header className="flex flex-col items-center gap-9">
          <div className="w-[500px] max-w-[100vw] p-4">
            <img
              src={logoLight}
              alt="React Router"
              className="block w-full dark:hidden"
            />
            <img
              src={logoDark}
              alt="React Router"
              className="hidden w-full dark:block"
            />
          </div>
        </header>
        <div className="max-w-[300px] w-full space-y-6 px-4">
          <nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
            <p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
              What&apos;s next?
            </p>
            <ul>
              {resources.map(({ href, text, icon }) => (
                <li key={href}>
                  <a
                    className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
                    href={href}
                    target="_blank"
                    rel="noreferrer"
                  >
                    {icon}
                    {text}
                  </a>
                </li>
              ))}
            </ul>
          </nav>
        </div>
      </div>
    </main>
  );
}

const resources = [
  {
    href: "https://reactrouter.com/docs",
    text: "React Router Docs",
    icon: (
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="20"
        viewBox="0 0 20 20"
        fill="none"
        className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
      >
        <path
          d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
      </svg>
    ),
  },
  {
    href: "https://rmx.as/discord",
    text: "Join Discord",
    icon: (
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="20"
        viewBox="0 0 24 20"
        fill="none"
        className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
      >
        <path
          d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
          strokeWidth="1.5"
        />
      </svg>
    ),
  },
];

react-router.config.ts

import type { Config } from "@react-router/dev/config";

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: true,
  future: {
    v8_middleware: true,
  },
} satisfies Config;

vite.config.ts

import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
    ssr: {
      noExternal: ['posthog-js', '@posthog/react'],
    },
    server: {
      proxy: {
        '/ingest/static': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest/array': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest': {
          target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
      },
    },
  };
});

references/EXAMPLE-react-tanstack-router-code-based.md

PostHog react-tanstack-router-code-based Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-tanstack-router-code-based


README.md

PostHog TanStack Router Example (Code-Based Routing)

This is a React and TanStack Router example demonstrating PostHog integration with product analytics, session replay, and error tracking. This example uses code-based routing where routes are defined programmatically.

Features

  • Product analytics: Track user events and behaviors
  • Session replay: Record and replay user sessions
  • Error tracking: Capture and track errors
  • User authentication: Demo login system with PostHog user identification
  • Client-side tracking: Pure client-side React implementation
  • Reverse proxy: PostHog ingestion through Vite proxy

Getting started

1. Install dependencies
npm install
2. Configure environment variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the development server
npm run dev

Open http://localhost:3000 with your browser to see the app.

Project structure

src/
├── contexts/
│   └── AuthContext.tsx    # Authentication context with PostHog integration
├── main.tsx               # App entry point with all routes defined in code
├── reportWebVitals.ts     # Performance monitoring
└── styles.css             # Global styles

Key integration points

PostHog provider setup (main.tsx)

PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app in the root route component:

import { PostHogProvider } from '@posthog/react'
import { createRootRoute } from '@tanstack/react-router'

const rootRoute = createRootRoute({
  component: RootComponent,
})

function RootComponent() {
  return (
    <PostHogProvider
      apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
      options={{
        api_host: '/ingest',
        ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
        defaults: '2026-01-30',
        capture_exceptions: true,
        debug: import.meta.env.DEV,
      }}
    >
      {/* your app */}
    </PostHogProvider>
  )
}
User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.identify(username, {
  username: username,
})
Event tracking (main.tsx - BurritoPage)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.capture('burrito_considered', {
  total_considerations: count,
  username: username,
})
Error tracking (main.tsx - ProfilePage)
posthog.captureException(error)

TanStack Router details

This example uses TanStack Router with code-based routing. Key details:

  1. Client-side only: No server-side logic, no API routes, no posthog-node
  2. Code-based routing: All routes defined in main.tsx using createRoute() and createRootRoute()
  3. Manual route tree: Routes connected with addChildren() method
  4. Standard hooks: Uses useNavigate() from @tanstack/react-router
  5. Vite proxy: Uses Vite's proxy config for PostHog calls
  6. Environment variables: Uses import.meta.env.VITE_*
  7. PostHog provider: Uses PostHogProvider from @posthog/react in root route
Code-based vs File-based routing

This example demonstrates code-based routing, where routes are defined programmatically:

import { createRoute, createRootRoute, createRouter } from '@tanstack/react-router'

const rootRoute = createRootRoute({ component: RootComponent })

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: Home,
})

const burritoRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/burrito',
  component: BurritoPage,
})

const routeTree = rootRoute.addChildren([indexRoute, burritoRoute])

const router = createRouter({ routeTree })

For file-based routing (auto-generated from file structure), see the react-tanstack-router-file-based example.

Learn more


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>

.prettierignore

package-lock.json
pnpm-lock.yaml
yarn.lock

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="React TanStack Router code-based routing example"
    />
    <link rel="apple-touch-icon" href="/logo192.png" />
    <link rel="manifest" href="/manifest.json" />
    <title>React TanStack Router - Code-Based</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

prettier.config.js

//  @ts-check

/** @type {import('prettier').Config} */
const config = {
  semi: false,
  singleQuote: true,
  trailingComma: "all",
};

export default config;

public/robots.txt

# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

src/contexts/AuthContext.tsx

import { createContext, useContext, useState, type ReactNode } from 'react';
import { usePostHog } from '@posthog/react';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  incrementBurritoConsiderations: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  // Use lazy initializer to read from localStorage only once on mount
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });
  const posthog = usePostHog();

  const login = async (username: string, password: string): Promise<boolean> => {
    if (!username || !password) {
      return false;
    }

    // Get or create user in local map
    let user = users.get(username);
    const isNewUser = !user;

    if (!user) {
      user = { username, burritoConsiderations: 0 };
      users.set(username, user);
    }

    setUser(user);
    localStorage.setItem('currentUser', username);

    // Identify user in PostHog using username as distinct ID
    posthog.identify(username, {
      username: username,
      isNewUser: isNewUser,
    });

    // Capture login event
    posthog.capture('user_logged_in', {
      username: username,
      isNewUser: isNewUser,
    });

    return true;
  };

  const logout = () => {
    // Capture logout event before resetting
    posthog.capture('user_logged_out');
    posthog.reset();

    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++;
      users.set(user.username, user);
      setUser({ ...user });
    }
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

src/main.tsx

import { StrictMode, useState } from 'react'
import ReactDOM from 'react-dom/client'
import {
  Link,
  Outlet,
  RouterProvider,
  createRootRoute,
  createRoute,
  createRouter,
  useNavigate,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider, usePostHog } from '@posthog/react'

import { AuthProvider, useAuth } from './contexts/AuthContext'
import './styles.css'
import reportWebVitals from './reportWebVitals'

// ============================================================================
// Root Route
// ============================================================================

const rootRoute = createRootRoute({
  component: RootComponent,
})

function RootComponent() {
  return (
    <PostHogProvider
      apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
      options={{
        api_host: '/ingest',
        ui_host:
          import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
        defaults: '2026-01-30',
        capture_exceptions: true,
        debug: import.meta.env.DEV,
      }}
    >
      <AuthProvider>
        <Header />
        <main>
          <Outlet />
        </main>
        <TanStackDevtools
          config={{
            position: 'bottom-right',
          }}
          plugins={[
            {
              name: 'Tanstack Router',
              render: <TanStackRouterDevtoolsPanel />,
            },
          ]}
        />
      </AuthProvider>
    </PostHogProvider>
  )
}

// ============================================================================
// Header Component
// ============================================================================

function Header() {
  const { user, logout } = useAuth()

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={logout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  )
}

// ============================================================================
// Index Route (Home Page)
// ============================================================================

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: Home,
})

function Home() {
  const { user, login } = useAuth()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const success = await login(username, password)
      if (success) {
        setUsername('')
        setPassword('')
      } else {
        setError('Please provide both username and password')
      }
    } catch (err) {
      console.error('Login failed:', err)
      setError('An error occurred during login')
    }
  }

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    )
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">
          Sign In
        </button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  )
}

// ============================================================================
// Burrito Route
// ============================================================================

const burritoRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/burrito',
  component: BurritoPage,
})

function BurritoPage() {
  const { user, incrementBurritoConsiderations } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()
  const [hasConsidered, setHasConsidered] = useState(false)

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const handleConsideration = () => {
    incrementBurritoConsiderations()
    setHasConsidered(true)
    setTimeout(() => setHasConsidered(false), 2000)

    // Capture burrito consideration event
    console.log('posthog', posthog)
    posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    })
  }

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button onClick={handleConsideration} className="btn-burrito">
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  )
}

// ============================================================================
// Profile Route
// ============================================================================

const profileRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/profile',
  component: ProfilePage,
})

function ProfilePage() {
  const { user } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking')
    } catch (err) {
      posthog.captureException(err)
      console.error('Captured error:', err)
      alert('Error captured and sent to PostHog!')
    }
  }

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p>
          <strong>Username:</strong> {user.username}
        </p>
        <p>
          <strong>Burrito Considerations:</strong> {user.burritoConsiderations}
        </p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <button
          onClick={triggerTestError}
          className="btn-primary"
          style={{ backgroundColor: '#dc3545' }}
        >
          Trigger Test Error (for PostHog)
        </button>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>
            You haven't considered any burritos yet. Visit the Burrito
            Consideration page to start!
          </p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You've considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You're getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You're becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master!</p>
        )}
      </div>
    </div>
  )
}

// ============================================================================
// Route Tree & Router Setup
// ============================================================================

const routeTree = rootRoute.addChildren([indexRoute, burritoRoute, profileRoute])

const router = createRouter({
  routeTree,
  context: {},
  defaultPreload: 'intent',
  scrollRestoration: true,
  defaultStructuralSharing: true,
  defaultPreloadStaleTime: 0,
})

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

// ============================================================================
// Render the App
// ============================================================================

const rootElement = document.getElementById('app')
if (rootElement && !rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>,
  )
}

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

src/reportWebVitals.ts

const reportWebVitals = (onPerfEntry?: () => void) => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
      onCLS(onPerfEntry)
      onINP(onPerfEntry)
      onFCP(onPerfEntry)
      onLCP(onPerfEntry)
      onTTFB(onPerfEntry)
    })
  }
}

export default reportWebVitals

vite.config.ts

import { defineConfig, loadEnv } from 'vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

import { fileURLToPath, URL } from 'node:url'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  return {
    plugins: [viteReact(), tailwindcss()],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
      },
    },
    server: {
      proxy: {
        '/ingest/static': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest/array': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest': {
          target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
      },
    },
  }
})

references/EXAMPLE-react-tanstack-router-file-based.md

PostHog react-tanstack-router-file-based Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/react-tanstack-router-file-based


README.md

PostHog TanStack Router Example

This is a React and TanStack Router example demonstrating PostHog integration with product analytics, session replay, and error tracking.

Features

  • Product analytics: Track user events and behaviors
  • Session replay: Record and replay user sessions
  • Error tracking: Capture and track errors
  • User authentication: Demo login system with PostHog user identification
  • Client-side tracking: Pure client-side React implementation
  • Reverse proxy: PostHog ingestion through Vite proxy

Getting started

1. Install dependencies
npm install
2. Configure environment variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the development server
npm run dev

Open http://localhost:3000 with your browser to see the app.

Project structure

src/
├── components/
│   └── Header.tsx         # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx    # Authentication context with PostHog integration
├── routes/
│   ├── __root.tsx         # Root layout with PostHogProvider
│   ├── index.tsx          # Home/Login page
│   ├── burrito.tsx        # Demo feature page with event tracking
│   └── profile.tsx        # User profile with error tracking demo
├── main.tsx               # App entry point
└── styles.css             # Global styles

Key integration points

PostHog provider setup (routes/__root.tsx)

PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app and handles calling posthog.init() automatically:

import { PostHogProvider } from '@posthog/react'

export const Route = createRootRoute({
  component: () => (
    <PostHogProvider
      apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
      options={{
        api_host: '/ingest',
        ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
        defaults: '2026-01-30',
        capture_exceptions: true,
        debug: import.meta.env.DEV,
      }}
    >
      {/* your app */}
    </PostHogProvider>
  ),
})
User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.identify(username, {
  username: username,
})
Event tracking (routes/burrito.tsx)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.capture('burrito_considered', {
  total_considerations: count,
  username: username,
})
Error tracking (routes/profile.tsx)
posthog.captureException(error)

TanStack Router details

This example uses TanStack Router. Key details:

  1. Client-side only: No server-side logic, no API routes, no posthog-node
  2. File-based routing: Routes are files in src/routes directory
  3. Standard hooks: Uses useNavigate() from @tanstack/react-router
  4. Vite proxy: Uses Vite's proxy config for PostHog calls
  5. Environment variables: Uses import.meta.env.VITE_*
  6. PostHog provider: Uses PostHogProvider from @posthog/react in root route

Learn more


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>

.prettierignore

package-lock.json
pnpm-lock.yaml
yarn.lock

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-tsrouter-app"
    />
    <link rel="apple-touch-icon" href="/logo192.png" />
    <link rel="manifest" href="/manifest.json" />
    <title>Create TanStack App - react-tanstack</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

prettier.config.js

//  @ts-check

/** @type {import('prettier').Config} */
const config = {
  semi: false,
  singleQuote: true,
  trailingComma: "all",
};

export default config;

public/robots.txt

# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

src/components/Header.tsx

import { Link } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'

export default function Header() {
  const { user, logout } = useAuth()

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={logout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  )
}

src/contexts/AuthContext.tsx

import { createContext, useContext, useState, type ReactNode } from 'react';
import { usePostHog } from '@posthog/react';

interface User {
  username: string;
  burritoConsiderations: number;
}

interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
  incrementBurritoConsiderations: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const users: Map<string, User> = new Map();

export function AuthProvider({ children }: { children: ReactNode }) {
  // Use lazy initializer to read from localStorage only once on mount
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null;

    const storedUsername = localStorage.getItem('currentUser');
    if (storedUsername) {
      const existingUser = users.get(storedUsername);
      if (existingUser) {
        return existingUser;
      }
    }
    return null;
  });
  const posthog = usePostHog();

  const login = async (username: string, password: string): Promise<boolean> => {
    if (!username || !password) {
      return false;
    }

    // Get or create user in local map
    let user = users.get(username);
    const isNewUser = !user;

    if (!user) {
      user = { username, burritoConsiderations: 0 };
      users.set(username, user);
    }

    setUser(user);
    localStorage.setItem('currentUser', username);

    // Identify user in PostHog using username as distinct ID
    posthog.identify(username, {
      username: username,
      isNewUser: isNewUser,
    });

    // Capture login event
    posthog.capture('user_logged_in', {
      username: username,
      isNewUser: isNewUser,
    });

    return true;
  };

  const logout = () => {
    // Capture logout event before resetting
    posthog.capture('user_logged_out');
    posthog.reset();

    setUser(null);
    localStorage.removeItem('currentUser');
  };

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++;
      users.set(user.username, user);
      setUser({ ...user });
    }
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

src/main.tsx

import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'

// Import the generated route tree
import { routeTree } from './routeTree.gen.ts'

import './styles.css'
import reportWebVitals from './reportWebVitals.ts'

// Create a new router instance
const router = createRouter({
  routeTree,
  context: {},
  defaultPreload: 'intent',
  scrollRestoration: true,
  defaultStructuralSharing: true,
  defaultPreloadStaleTime: 0,
})

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

// Render the app
const rootElement = document.getElementById('app')
if (rootElement && !rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>,
  )
}

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

src/reportWebVitals.ts

const reportWebVitals = (onPerfEntry?: () => void) => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
      onCLS(onPerfEntry)
      onINP(onPerfEntry)
      onFCP(onPerfEntry)
      onLCP(onPerfEntry)
      onTTFB(onPerfEntry)
    })
  }
}

export default reportWebVitals

src/routes/__root.tsx

import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider } from '@posthog/react'

import Header from '../components/Header'
import { AuthProvider } from '../contexts/AuthContext'

export const Route = createRootRoute({
  component: () => (
    <PostHogProvider
      apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
      options={{
        api_host: '/ingest',
        ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
        defaults: '2026-01-30',
        capture_exceptions: true,
        debug: import.meta.env.DEV,
      }}
    >
      <AuthProvider>
        <Header />
        <main>
          <Outlet />
        </main>
        <TanStackDevtools
          config={{
            position: 'bottom-right',
          }}
          plugins={[
            {
              name: 'Tanstack Router',
              render: <TanStackRouterDevtoolsPanel />,
            },
          ]}
        />
      </AuthProvider>
    </PostHogProvider>
  ),
})

src/routes/burrito.tsx

import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/burrito')({
  component: BurritoPage,
})

function BurritoPage() {
  const { user, incrementBurritoConsiderations } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()
  const [hasConsidered, setHasConsidered] = useState(false)

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const handleConsideration = () => {
    incrementBurritoConsiderations()
    setHasConsidered(true)
    setTimeout(() => setHasConsidered(false), 2000)

    // Capture burrito consideration event
    console.log('posthog', posthog)
    posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    })
  }

  return (
    <div className="container">
      <h1>Burrito consideration zone</h1>
      <p>Take a moment to truly consider the potential of burritos.</p>

      <div style={{ textAlign: 'center' }}>
        <button onClick={handleConsideration} className="btn-burrito">
          I have considered the burrito potential
        </button>

        {hasConsidered && (
          <p className="success">
            Thank you for your consideration! Count: {user.burritoConsiderations}
          </p>
        )}
      </div>

      <div className="stats">
        <h3>Consideration stats</h3>
        <p>Total considerations: {user.burritoConsiderations}</p>
      </div>
    </div>
  )
}

src/routes/index.tsx

import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/')({
  component: Home,
})

function Home() {
  const { user, login } = useAuth()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const success = await login(username, password)
      if (success) {
        setUsername('')
        setPassword('')
      } else {
        setError('Please provide both username and password')
      }
    } catch (err) {
      console.error('Login failed:', err)
      setError('An error occurred during login')
    }
  }

  if (user) {
    return (
      <div className="container">
        <h1>Welcome back, {user.username}!</h1>
        <p>You are logged in. Feel free to explore:</p>
        <ul>
          <li>Consider the potential of burritos</li>
          <li>View your profile and statistics</li>
        </ul>
      </div>
    )
  }

  return (
    <div className="container">
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form onSubmit={handleSubmit} className="form">
        <div className="form-group">
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter any username"
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Enter any password"
          />
        </div>

        {error && <p className="error">{error}</p>}

        <button type="submit" className="btn-primary">
          Sign In
        </button>
      </form>

      <p className="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </div>
  )
}

src/routes/profile.tsx

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from 'posthog-js/react'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/profile')({
  component: ProfilePage,
})

function ProfilePage() {
  const { user } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking')
    } catch (err) {
      posthog.captureException(err)
      console.error('Captured error:', err)
      alert('Error captured and sent to PostHog!')
    }
  }

  return (
    <div className="container">
      <h1>User Profile</h1>

      <div className="stats">
        <h2>Your Information</h2>
        <p>
          <strong>Username:</strong> {user.username}
        </p>
        <p>
          <strong>Burrito Considerations:</strong> {user.burritoConsiderations}
        </p>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <button
          onClick={triggerTestError}
          className="btn-primary"
          style={{ backgroundColor: '#dc3545' }}
        >
          Trigger Test Error (for PostHog)
        </button>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>Your Burrito Journey</h3>
        {user.burritoConsiderations === 0 ? (
          <p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
        ) : user.burritoConsiderations === 1 ? (
          <p>You've considered the burrito potential once. Keep going!</p>
        ) : user.burritoConsiderations < 5 ? (
          <p>You're getting the hang of burrito consideration!</p>
        ) : user.burritoConsiderations < 10 ? (
          <p>You're becoming a burrito consideration expert!</p>
        ) : (
          <p>You are a true burrito consideration master!</p>
        )}
      </div>
    </div>
  )
}

vite.config.ts

import { defineConfig, loadEnv } from 'vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

import { tanstackRouter } from '@tanstack/router-plugin/vite'
import { fileURLToPath, URL } from 'node:url'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  return {
    plugins: [
      tanstackRouter({
        target: 'react',
        autoCodeSplitting: true,
      }),
      viteReact(),
      tailwindcss(),
    ],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
      },
    },
    server: {
      proxy: {
        '/ingest/static': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest/array': {
          target: 'https://us-assets.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
        '/ingest': {
          target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ingest/, ''),
        },
      },
    },
  }
})

references/EXAMPLE-ruby-on-rails.md

PostHog ruby-on-rails Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/ruby-on-rails


README.md

PostHog Ruby on Rails example

This is a Ruby on Rails example demonstrating PostHog integration with product analytics, error tracking (auto-instrumentation), feature flags, user identification, and ActiveJob instrumentation via the posthog-rails gem.

Features

  • Product analytics: Track user events and behaviors with PostHog.capture
  • Error tracking (auto): Unhandled exceptions captured automatically by posthog-rails
  • Error tracking (manual): Handled errors captured with PostHog.capture_exception
  • Rails.error integration: Rails 7+ error reporting captured automatically
  • ActiveJob instrumentation: Background job failures captured automatically
  • User identification: Associate events with authenticated users via PostHog.identify
  • Feature flags: Control feature rollouts with PostHog.is_feature_enabled
  • User context: Exceptions automatically associated with current_user
  • Frontend tracking: posthog-js captures pageviews and session replay alongside backend events

Getting started

1. Install dependencies
bundle install
2. Configure environment variables
cp .env.example .env
# Edit .env and add your PostHog project token

Get your PostHog project token from your PostHog project settings.

3. Setup database
bin/rails db:create db:migrate db:seed
4. Run the development server
bin/rails server

Open http://localhost:3000 with your browser. Login with admin@example.com / admin.

Project structure

ruby-on-rails/
├── config/
│   ├── routes.rb                        # URL routing
│   └── initializers/
│       └── posthog.rb                   # PostHog + posthog-rails configuration
├── app/
│   ├── controllers/
│   │   ├── application_controller.rb    # Base controller with current_user
│   │   ├── sessions_controller.rb       # Login/logout with PostHog identify
│   │   ├── registrations_controller.rb  # Signup with PostHog identify
│   │   ├── dashboard_controller.rb      # Feature flags + ActiveJob demo
│   │   ├── burritos_controller.rb       # Custom event tracking
│   │   ├── profiles_controller.rb       # Page view tracking
│   │   └── errors_controller.rb         # Error tracking demos
│   ├── jobs/
│   │   └── example_job.rb              # ActiveJob auto-instrumentation demo
│   ├── models/
│   │   └── user.rb                     # posthog_distinct_id + posthog_properties
│   └── views/
│       ├── layouts/application.html.erb # Base layout with posthog-js snippet
│       ├── sessions/new.html.erb        # Login page
│       ├── registrations/new.html.erb   # Signup page
│       ├── dashboard/show.html.erb      # Feature flags demo
│       ├── burritos/show.html.erb       # Event tracking demo
│       └── profiles/show.html.erb       # Error tracking demo
├── db/
│   ├── migrate/                         # Database migrations
│   └── seeds.rb                         # Default admin user
├── .env.example                         # Environment variable template
├── Gemfile                              # Ruby dependencies
└── README.md                            # This file

Key integration points

PostHog initialization (config/initializers/posthog.rb)
# Rails-specific auto-instrumentation
PostHog::Rails.configure do |config|
  config.auto_capture_exceptions = true
  config.report_rescued_exceptions = true
  config.auto_instrument_active_job = true
  config.capture_user_context = true
  config.current_user_method = :current_user
  config.user_id_method = :posthog_distinct_id
end

PostHog.init do |config|
  config.api_key = ENV.fetch('POSTHOG_PROJECT_TOKEN', nil)
  config.host = ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com')
end
User model (app/models/user.rb)
class User < ApplicationRecord
  has_secure_password

  # Called by posthog-rails for automatic user association in error reports.
  # The primary key, not the email — an email can change, which splits one
  # person's history in two, and it is PII on every event's identity.
  def posthog_distinct_id
    id.to_s
  end

  def posthog_properties
    { email: email, is_staff: is_staff, date_joined: created_at&.iso8601 }
  end
end
User identification (app/controllers/sessions_controller.rb)
# Identify the user and capture login event
PostHog.identify(
  distinct_id: user.posthog_distinct_id,
  properties: user.posthog_properties
)

PostHog.capture(
  distinct_id: user.posthog_distinct_id,
  event: 'user_logged_in',
  properties: { login_method: 'email' }
)
Feature flags (app/controllers/dashboard_controller.rb)
# Check if a feature flag is enabled
@show_new_feature = PostHog.is_feature_enabled(
  'new-dashboard-feature',
  user.posthog_distinct_id,
  person_properties: user.posthog_properties
)

# Get feature flag payload for configuration
@feature_config = PostHog.get_feature_flag_payload(
  'new-dashboard-feature',
  user.posthog_distinct_id
)
Error tracking — auto-capture

With auto_capture_exceptions: true, unhandled exceptions in controllers are captured automatically. No code needed:

# This exception is automatically captured by posthog-rails
# with the current_user's posthog_distinct_id attached
def show
  raise "Something went wrong"  # Captured automatically!
end
Error tracking — manual capture
begin
  risky_operation
rescue => e
  PostHog.capture_exception(e, current_user.posthog_distinct_id)
end
Error tracking — Rails.error integration
# posthog-rails subscribes to Rails.error automatically
Rails.error.handle(context: { user_id: user.id }) do
  risky_operation
end
ActiveJob instrumentation
# config: auto_instrument_active_job = true
# Job failures are captured automatically.
# Use the posthog_distinct_id DSL to associate errors with a user.
class ExampleJob < ApplicationJob
  posthog_distinct_id ->(distinct_id, *) { distinct_id }

  def perform(distinct_id, should_fail: false)
    raise "Job failed"  # Captured automatically with user context
  end
end

# In the controller, pass the distinct_id when enqueuing:
ExampleJob.perform_later(current_user.posthog_distinct_id, should_fail: true)

Frontend + Backend integration

This example includes the posthog-js snippet in the layout template to demonstrate how frontend and backend tracking work together.

How it works
  1. posthog-js (frontend) captures pageviews, clicks, and session replay
  2. posthog-ruby + posthog-rails (backend) captures business logic events, errors, and feature flag evaluations
  3. Shared distinct_id — frontend and backend events are linked when the same distinct_id is used on both sides. Call posthog.identify(user.id.to_s) in posthog-js after login, matching the posthog_distinct_id used on the backend
  4. Session replay lets you watch user sessions where errors occurred

Note: Unlike the Django SDK, posthog-rails does not include a context middleware that reads X-POSTHOG-SESSION-ID or X-POSTHOG-DISTINCT-ID tracing headers. Frontend and backend events are correlated through the shared distinct_id.

When to track frontend vs backend
  • Frontend: UI interactions, client-side errors, session replay, pageviews
  • Backend: Business logic (signups, purchases), server errors, feature flag evaluations, background jobs

Learn more


.env.example

# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  private

  def current_user
    @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
  end
  helper_method :current_user

  def require_login
    unless current_user
      redirect_to login_path
    end
  end
end

app/controllers/burritos_controller.rb

class BurritosController < ApplicationController
  before_action :require_login

  def show
    @burrito_count = session[:burrito_count] || 0
  end

  def consider
    count = (session[:burrito_count] || 0) + 1
    session[:burrito_count] = count

    user = current_user

    # PostHog: Track custom event
    PostHog.identify(
      distinct_id: user.posthog_distinct_id,
      properties: user.posthog_properties
    )

    PostHog.capture(
      distinct_id: user.posthog_distinct_id,
      event: 'burrito_considered',
      properties: { total_considerations: count }
    )

    render json: { success: true, count: count }
  end
end

app/controllers/dashboard_controller.rb

class DashboardController < ApplicationController
  before_action :require_login

  def show
    user = current_user

    # PostHog: Track dashboard view
    PostHog.capture(
      distinct_id: user.posthog_distinct_id,
      event: 'dashboard_viewed',
      properties: { is_staff: user.is_staff }
    )

    # PostHog: Check feature flag
    @show_new_feature = PostHog.is_feature_enabled(
      'new-dashboard-feature',
      user.posthog_distinct_id,
      person_properties: user.posthog_properties
    )

    # PostHog: Get feature flag payload for configuration
    @feature_config = PostHog.get_feature_flag_payload(
      'new-dashboard-feature',
      user.posthog_distinct_id
    )
  end

  def enqueue_test_job
    # Enqueue a job that will fail — posthog-rails captures the error automatically.
    # The distinct_id is passed so the posthog_distinct_id DSL can associate the error with this user.
    ExampleJob.perform_later(current_user.posthog_distinct_id, should_fail: true)

    render json: {
      success: true,
      message: 'Job enqueued. The job will fail and posthog-rails will capture the error automatically.'
    }
  end
end

app/controllers/errors_controller.rb

class ErrorsController < ApplicationController
  before_action :require_login

  def test
    # Manual exception capture — catch the error and report it explicitly
    begin
      raise StandardError, 'Test exception from critical operation'
    rescue StandardError => e
      # PostHog: Manually capture the exception
      PostHog.capture_exception(e, current_user.posthog_distinct_id)

      PostHog.capture(
        distinct_id: current_user.posthog_distinct_id,
        event: 'error_triggered',
        properties: {
          error_type: e.class.name,
          error_message: e.message
        }
      )

      render json: {
        success: false,
        error: e.message,
        message: 'Error has been captured by PostHog'
      }, status: :internal_server_error
    end
  end

  def test_rails_error
    # Rails.error.handle — Rails 7+ error reporting integration.
    # posthog-rails subscribes to Rails.error, so exceptions reported
    # via Rails.error.handle are automatically captured in PostHog.
    Rails.error.handle(context: { user_id: current_user.id }) do
      raise StandardError, 'Test error via Rails.error.handle — captured automatically by posthog-rails'
    end

    render json: {
      success: true,
      message: 'Error was handled via Rails.error.handle and captured by posthog-rails'
    }
  end
end

app/controllers/profiles_controller.rb

class ProfilesController < ApplicationController
  before_action :require_login

  def show
    # PostHog: Track profile view
    PostHog.capture(
      distinct_id: current_user.posthog_distinct_id,
      event: 'profile_viewed'
    )
  end
end

app/controllers/registrations_controller.rb

class RegistrationsController < ApplicationController
  def new
    redirect_to dashboard_path if current_user
  end

  def create
    user = User.new(
      email: params[:email],
      password: params[:password],
      password_confirmation: params[:password_confirmation]
    )

    if user.save
      session[:user_id] = user.id

      # PostHog: Identify the new user and capture signup event
      PostHog.identify(
        distinct_id: user.posthog_distinct_id,
        properties: user.posthog_properties
      )

      PostHog.capture(
        distinct_id: user.posthog_distinct_id,
        event: 'user_signed_up',
        properties: { signup_method: 'form' }
      )

      redirect_to dashboard_path
    else
      flash[:error] = user.errors.full_messages.join(', ')
      render :new, status: :unprocessable_entity
    end
  end
end

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
    redirect_to dashboard_path if current_user
  end

  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      session[:user_id] = user.id

      # PostHog: Identify the user and capture login event
      PostHog.identify(
        distinct_id: user.posthog_distinct_id,
        properties: user.posthog_properties
      )

      PostHog.capture(
        distinct_id: user.posthog_distinct_id,
        event: 'user_logged_in',
        properties: { login_method: 'email' }
      )

      redirect_to dashboard_path
    else
      flash[:error] = 'Invalid email or password'
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    if current_user
      # PostHog: Track logout before session ends
      PostHog.capture(
        distinct_id: current_user.posthog_distinct_id,
        event: 'user_logged_out'
      )
    end

    session.delete(:user_id)
    redirect_to login_path
  end
end

app/jobs/application_job.rb

class ApplicationJob < ActiveJob::Base
end

app/jobs/example_job.rb

# Example ActiveJob demonstrating posthog-rails auto-instrumentation.
#
# When auto_instrument_active_job is enabled in the PostHog config,
# posthog-rails automatically captures exceptions from failed jobs.
# The job class name, queue, and arguments are included as properties
# on the error event.
#
# Use the posthog_distinct_id DSL to associate job errors with a user.
# The proc receives the same arguments as perform and should return
# the distinct_id string. Without this, job errors have no user context.
class ExampleJob < ApplicationJob
  queue_as :default

  # Extract distinct_id from the first argument so posthog-rails
  # can associate the error with the user who triggered the job.
  posthog_distinct_id ->(distinct_id, *) { distinct_id }

  def perform(distinct_id, should_fail: false)
    if should_fail
      raise StandardError, 'Example job failure - this error is automatically captured by posthog-rails'
    end

    Rails.logger.info "ExampleJob completed successfully for #{distinct_id}"
  end
end

app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
end

app/models/user.rb

class User < ApplicationRecord
  has_secure_password

  validates :email, presence: true, uniqueness: true

  # Called by posthog-rails for automatic user association in error reports.
  # When auto_capture_exceptions and capture_user_context are enabled,
  # posthog-rails calls this method on current_user to get the distinct_id.
  # The primary key, not the email: an email can change, which would split one
  # person's history in two, and it is PII on every event's identity.
  def posthog_distinct_id
    id.to_s
  end

  # Helper used by controllers when calling PostHog.identify to set person properties.
  # These properties appear on the person profile in PostHog.
  def posthog_properties
    {
      email: email,
      is_staff: is_staff,
      date_joined: created_at&.iso8601
    }
  end
end

app/views/burritos/show.html.erb

<% content_for(:title) { 'Burrito - PostHog Rails example' } %>

<div class="card">
    <h1>Burrito consideration tracker</h1>
    <p>This page demonstrates custom event tracking with PostHog.</p>
</div>

<div class="card" style="text-align: center;">
    <h2>Times considered</h2>
    <div class="count" id="burrito-count"><%= @burrito_count %></div>
    <button onclick="considerBurrito()" style="font-size: 18px; padding: 15px 30px;">
        Consider a burrito
    </button>
</div>

<div class="card">
    <h3>How event tracking works</h3>
    <p>Each time you click the button, a <code>burrito_considered</code> event is sent to PostHog:</p>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto; margin-top: 15px;"><code>PostHog.capture(
  distinct_id: user.posthog_distinct_id,
  event: 'burrito_considered',
  properties: { total_considerations: count }
)</code></pre>
</div>

<% content_for :scripts do %>
<script>
async function considerBurrito() {
    try {
        const response = await fetch('/api/burrito/consider', {
            method: 'POST',
            headers: {
                'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
                'Content-Type': 'application/json',
            },
        });

        const data = await response.json();

        if (data.success) {
            document.getElementById('burrito-count').textContent = data.count;
        }
    } catch (error) {
        console.error('Error:', error);
    }
}
</script>
<% end %>

app/views/dashboard/show.html.erb

<% content_for(:title) { 'Dashboard - PostHog Rails example' } %>

<div class="card">
    <h1>Dashboard</h1>
    <p>Welcome back, <strong><%= current_user.email %></strong>!</p>
</div>

<div class="card">
    <h2>Feature flags</h2>
    <p>Feature flags allow you to control feature rollouts and run A/B tests.</p>

    <% if @show_new_feature %>
    <div class="feature-flag">
        <h3>New feature enabled!</h3>
        <p>
            This section is only visible because the <code>new-dashboard-feature</code>
            flag is enabled for your user.
        </p>
        <% if @feature_config %>
        <p><strong>Feature config:</strong> <%= @feature_config %></p>
        <% end %>
    </div>
    <% else %>
    <div style="background: #f3f4f6; padding: 15px; border-radius: 8px; margin-top: 15px;">
        <p>
            The <code>new-dashboard-feature</code> flag is not enabled for your user.
            Create this flag in your PostHog project to see it in action.
        </p>
    </div>
    <% end %>
</div>

<div class="card">
    <h2>ActiveJob instrumentation</h2>
    <p>
        Click below to enqueue a background job that will fail.
        <code>posthog-rails</code> automatically captures the exception — no extra code needed.
    </p>
    <button onclick="enqueueTestJob()" style="margin-top: 10px;">Enqueue failing job</button>
    <div id="job-result" style="margin-top: 15px; display: none;"></div>
</div>

<div class="card">
    <h3>How feature flags work</h3>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># Check if a feature flag is enabled
show_feature = PostHog.is_feature_enabled(
  'new-dashboard-feature',
  user.posthog_distinct_id,
  person_properties: user.posthog_properties
)

# Get feature flag payload for configuration
config = PostHog.get_feature_flag_payload(
  'new-dashboard-feature',
  user.posthog_distinct_id
)</code></pre>
</div>

<% content_for :scripts do %>
<script>
async function enqueueTestJob() {
    const resultDiv = document.getElementById('job-result');
    try {
        const response = await fetch('/api/test-job', {
            method: 'POST',
            headers: {
                'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
                'Content-Type': 'application/json',
            },
        });
        const data = await response.json();
        resultDiv.style.display = 'block';
        resultDiv.innerHTML = '<div class="flash success">' + data.message + '</div>';
    } catch (error) {
        resultDiv.style.display = 'block';
        resultDiv.innerHTML = '<div class="flash error">Request failed: ' + error + '</div>';
    }
}
</script>
<% end %>

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><%= content_for?(:title) ? yield(:title) : 'PostHog Rails example' %></title>
    <%= csrf_meta_tags %>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            line-height: 1.6;
            background-color: #f5f5f5;
            color: #333;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        nav {
            background: #1d4ed8;
            padding: 15px 20px;
            margin-bottom: 30px;
        }
        nav a {
            color: white;
            text-decoration: none;
            margin-right: 20px;
        }
        nav a:hover {
            text-decoration: underline;
        }
        .card {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1, h2, h3 {
            margin-bottom: 15px;
            color: #1d4ed8;
        }
        button, .btn {
            background: #1d4ed8;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
            display: inline-block;
            text-decoration: none;
        }
        button:hover, .btn:hover {
            background: #1e40af;
        }
        button.danger {
            background: #dc2626;
        }
        button.danger:hover {
            background: #b91c1c;
        }
        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        .flash {
            padding: 10px 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .flash.error {
            background: #fee2e2;
            color: #dc2626;
        }
        .flash.success {
            background: #d1fae5;
            color: #059669;
        }
        .feature-flag {
            background: #fef3c7;
            border: 2px dashed #f59e0b;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
        }
        code {
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: monospace;
        }
        .count {
            font-size: 48px;
            font-weight: bold;
            color: #1d4ed8;
            text-align: center;
            padding: 20px;
        }
    </style>

    <!-- PostHog frontend tracking (posthog-js) -->
    <script>
      // POSTHOG_BROWSER_SNIPPET_START
      !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
      // POSTHOG_BROWSER_SNIPPET_END
      posthog.init('<%= ENV["POSTHOG_PROJECT_TOKEN"] %>', {
        api_host: '<%= ENV.fetch("POSTHOG_HOST", "https://us.i.posthog.com") %>',
        person_profiles: 'identified_only'
      })
    </script>
</head>
<body>
    <% if current_user %>
    <nav style="display: flex; align-items: center;">
        <a href="<%= dashboard_path %>">Dashboard</a>
        <a href="<%= burrito_path %>">Burrito</a>
        <a href="<%= profile_path %>">Profile</a>
        <%= button_to 'Logout (' + current_user.email + ')', logout_path, method: :delete, form: { style: 'margin-left: auto;' }, style: 'background: transparent; border: none; color: white; cursor: pointer; font-size: inherit; padding: 0;' %>
    </nav>
    <% end %>

    <div class="container">
        <% if flash[:error] %>
        <div class="flash error"><%= flash[:error] %></div>
        <% end %>
        <% if flash[:notice] %>
        <div class="flash success"><%= flash[:notice] %></div>
        <% end %>

        <%= yield %>
    </div>

    <%= yield :scripts %>
</body>
</html>

app/views/profiles/show.html.erb

<% content_for(:title) { 'Profile - PostHog Rails example' } %>

<div class="card">
    <h1>Profile</h1>
    <p>This page demonstrates error tracking with PostHog and <code>posthog-rails</code>.</p>
</div>

<div class="card">
    <h2>User information</h2>
    <table style="width: 100%; border-collapse: collapse;">
        <tr>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><%= current_user.email %></td>
        </tr>
        <tr>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><strong>Date Joined:</strong></td>
            <td style="padding: 10px; border-bottom: 1px solid #eee;"><%= current_user.created_at %></td>
        </tr>
        <tr>
            <td style="padding: 10px;"><strong>Staff Status:</strong></td>
            <td style="padding: 10px;"><%= current_user.is_staff ? 'Yes' : 'No' %></td>
        </tr>
    </table>
</div>

<div class="card">
    <h2>Error tracking demo</h2>
    <p>Click the buttons below to trigger different types of errors and see how PostHog captures them.</p>

    <div style="margin-top: 20px;">
        <button class="danger" onclick="triggerError('manual')">
            Manual capture_exception
        </button>
        <button class="danger" onclick="triggerError('rails')" style="margin-left: 10px;">
            Rails.error.handle
        </button>
    </div>

    <div id="error-result" style="margin-top: 20px; display: none;"></div>
</div>

<div class="card">
    <h3>How error tracking works</h3>
    <p><strong>Auto-capture (no code needed):</strong></p>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># config/initializers/posthog.rb
PostHog::Rails.configure do |config|
  config.auto_capture_exceptions = true
  config.capture_user_context = true
end
# That's it! Unhandled exceptions are captured automatically.</code></pre>

    <p style="margin-top: 15px;"><strong>Manual capture:</strong></p>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code>begin
  risky_operation
rescue => e
  PostHog.capture_exception(e, user.posthog_distinct_id)
end</code></pre>

    <p style="margin-top: 15px;"><strong>Rails.error integration:</strong></p>
    <pre style="background: #f3f4f6; padding: 15px; border-radius: 5px; overflow-x: auto;"><code># posthog-rails subscribes to Rails.error automatically
Rails.error.handle(context: { user_id: user.id }) do
  risky_operation
end</code></pre>
</div>

<% content_for :scripts do %>
<script>
async function triggerError(type) {
    const resultDiv = document.getElementById('error-result');
    const url = type === 'rails' ? '/api/test-rails-error' : '/api/test-error';

    try {
        const response = await fetch(url, {
            method: 'POST',
            headers: {
                'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
                'Content-Type': 'application/json',
            },
        });

        const data = await response.json();

        resultDiv.style.display = 'block';
        if (data.success) {
            resultDiv.innerHTML = '<div class="flash success">' + data.message + '</div>';
        } else {
            resultDiv.innerHTML = '<div class="flash error"><strong>Error captured:</strong> ' + data.error + '<br><small>' + data.message + '</small></div>';
        }
    } catch (error) {
        resultDiv.style.display = 'block';
        resultDiv.innerHTML = '<div class="flash error">Request failed: ' + error + '</div>';
    }
}
</script>
<% end %>

app/views/registrations/new.html.erb

<% content_for(:title) { 'Sign Up - PostHog Rails example' } %>

<div class="card">
    <h1>Sign Up</h1>
    <p>Create an account to see PostHog analytics in action.</p>

    <form action="<%= signup_path %>" method="post" style="margin-top: 20px;">
        <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
        <input type="email" name="email" placeholder="Email" required>
        <input type="password" name="password" placeholder="Password" required>
        <input type="password" name="password_confirmation" placeholder="Confirm Password" required>
        <button type="submit">Sign Up</button>
    </form>

    <p style="margin-top: 15px; color: #666; font-size: 14px;">
        Already have an account? <a href="<%= login_path %>">Login</a>
    </p>
</div>

app/views/sessions/new.html.erb

<% content_for(:title) { 'Login - PostHog Rails example' } %>

<div class="card">
    <h1>PostHog Rails example</h1>
    <p>Welcome! This example demonstrates PostHog integration with Ruby on Rails, including automatic error tracking via <code>posthog-rails</code>.</p>
</div>

<div class="card">
    <h2>Login</h2>
    <p>Login to see PostHog analytics in action.</p>

    <form action="<%= login_path %>" method="post" style="margin-top: 20px;">
        <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
        <input type="email" name="email" placeholder="Email" required>
        <input type="password" name="password" placeholder="Password" required>
        <button type="submit">Login</button>
    </form>

    <p style="margin-top: 15px; color: #666; font-size: 14px;">
        Don't have an account? <a href="<%= signup_path %>">Sign up</a><br>
        Tip: Run <code>bin/rails db:seed</code> to create admin@example.com / admin
    </p>
</div>

<div class="card">
    <h3>What this example demonstrates</h3>
    <ul style="padding-left: 20px;">
        <li><strong>User identification</strong> — Users are identified with <code>PostHog.identify</code> on login</li>
        <li><strong>Event tracking</strong> — Custom events captured with <code>PostHog.capture</code></li>
        <li><strong>Feature flags</strong> — Conditional features with <code>PostHog.is_feature_enabled</code></li>
        <li><strong>Error tracking (auto)</strong> — Unhandled exceptions captured automatically by <code>posthog-rails</code></li>
        <li><strong>Error tracking (manual)</strong> — Handled errors captured with <code>PostHog.capture_exception</code></li>
        <li><strong>ActiveJob instrumentation</strong> — Background job failures captured automatically</li>
        <li><strong>Rails.error integration</strong> — Rails 7+ error reporting captured by <code>posthog-rails</code></li>
        <li><strong>Frontend tracking</strong> — posthog-js captures pageviews and session replay</li>
    </ul>
</div>

config.ru

require_relative 'config/environment'
run Rails.application

config/application.rb

require_relative 'boot'
require 'rails/all'

Bundler.require(*Rails.groups)

module PosthogExample
  class Application < Rails::Application
    config.load_defaults 7.1

    # Use SQLite for all stores
    config.active_job.queue_adapter = :async
  end
end

config/boot.rb

ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)

require 'bundler/setup'

config/environment.rb

require_relative 'application'
Rails.application.initialize!

config/environments/development.rb

require 'active_support/core_ext/integer/time'

Rails.application.configure do
  config.enable_reloading = true
  config.eager_load = false
  config.consider_all_requests_local = true
  config.server_timing = true

  # Secret key for development (not used in production)
  config.secret_key_base = 'dev-secret-key-for-posthog-example-only'

  config.action_controller.perform_caching = false
  config.cache_store = :memory_store

  config.active_support.deprecation = :log
  config.active_support.disallowed_deprecation = :raise
  config.active_support.disallowed_deprecation_warnings = []

  config.active_record.migration_error = :page_load
  config.active_record.verbose_query_logs = true
end

config/initializers/posthog.rb

# PostHog configuration with posthog-rails auto-instrumentation
#
# The posthog-rails gem provides:
# - Automatic exception capture for unhandled controller errors
# - ActiveJob instrumentation for background job failures
# - User context detection from current_user
# - Rails.error integration for rescued exceptions 
PostHog.init do |config|
  config.api_key = ENV.fetch('POSTHOG_PROJECT_TOKEN', nil)
  config.host = ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com')
end

PostHog::Rails.configure do |config|
  # Auto-capture unhandled exceptions in controllers
  config.auto_capture_exceptions = true

  # Also capture exceptions that Rails rescues (e.g. ActiveRecord::RecordNotFound)
  config.report_rescued_exceptions = true

  # Auto-instrument ActiveJob failures
  config.auto_instrument_active_job = true

  # Automatically associate errors with the current user
  config.capture_user_context = true
  config.current_user_method = :current_user
  config.user_id_method = :posthog_distinct_id
end


config/routes.rb

Rails.application.routes.draw do
  # Auth
  get 'login', to: 'sessions#new'
  post 'login', to: 'sessions#create'
  delete 'logout', to: 'sessions#destroy'

  get 'signup', to: 'registrations#new'
  post 'signup', to: 'registrations#create'

  # App
  get 'dashboard', to: 'dashboard#show'
  get 'burrito', to: 'burritos#show'
  post 'api/burrito/consider', to: 'burritos#consider'
  get 'profile', to: 'profiles#show'

  # Error tracking demos
  post 'api/test-error', to: 'errors#test'
  post 'api/test-rails-error', to: 'errors#test_rails_error'

  # Background job demo
  post 'api/test-job', to: 'dashboard#enqueue_test_job'

  root 'sessions#new'
end

db/migrate/20240101000000_create_users.rb

class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.boolean :is_staff, default: false

      t.timestamps
    end

    add_index :users, :email, unique: true
  end
end

db/schema.rb

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2024_01_01_000000) do
  create_table "users", force: :cascade do |t|
    t.string "email", null: false
    t.string "password_digest", null: false
    t.boolean "is_staff", default: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["email"], name: "index_users_on_email", unique: true
  end
end

db/seeds.rb

# Create a default admin user for testing
User.find_or_create_by!(email: 'admin@example.com') do |user|
  user.password = 'admin'
  user.password_confirmation = 'admin'
  user.is_staff = true
end

puts 'Seed data created: admin@example.com / admin'

Gemfile

source 'https://rubygems.org'

gem 'rails', '~> 7.1'
gem 'sqlite3', '~> 1.7'
gem 'puma', '~> 6.0'
gem 'bcrypt', '~> 3.1'
gem 'dotenv-rails', '~> 3.0'

# PostHog
gem 'posthog-ruby', '~> 3.0'
gem 'posthog-rails'

Rakefile

require_relative 'config/application'
Rails.application.load_tasks

references/EXAMPLE-ruby.md

PostHog ruby Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/ruby


README.md

PostHog Ruby Example - CLI Todo App

A simple command-line todo application built with plain Ruby (no frameworks) demonstrating PostHog integration for CLIs, scripts, data pipelines, and non-web Ruby applications.

Purpose

This example serves as:

  • Verification that the context-mill wizard works for plain Ruby projects
  • Reference implementation of PostHog best practices for non-framework Ruby code
  • Working example you can run and modify

Features Demonstrated

  • Instance-based API - Uses PostHog::Client.new(...) for explicit client management
  • Proper shutdown - Uses shutdown in ensure block to flush events before exit
  • Event tracking - Captures user actions with distinct_id and properties
  • User identification - Associates properties with users via identify
  • Error handling - Manual exception capture for handled errors

Quick Start

1. Install Dependencies
# Install bundler if needed
gem install bundler

# Install dependencies
bundle install
2. Configure PostHog
# Copy environment template
cp .env.example .env

# Edit .env and add your PostHog project token
# POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
# POSTHOG_HOST=https://us.i.posthog.com
3. Run the App
# Add a todo
ruby todo.rb add "Buy groceries"

# List all todos
ruby todo.rb list

# Complete a todo
ruby todo.rb complete 1

# Delete a todo
ruby todo.rb delete 1

# Show statistics
ruby todo.rb stats

What Gets Tracked

The app tracks these events in PostHog:

Event Properties Purpose
todo_added todo_id, todo_length, total_todos When user adds a new todo
todos_viewed total_todos, completed_todos When user lists todos
todo_completed todo_id, time_to_complete_hours When user completes a todo
todo_deleted todo_id, was_completed When user deletes a todo
stats_viewed total_todos, completed_todos, pending_todos When user views stats

Code Structure

basics/ruby/
├── todo.rb              # Main CLI application
├── Gemfile              # Ruby dependencies
├── .env.example         # Environment variable template
├── .gitignore           # Git ignore rules
└── README.md            # This file

Key Implementation Patterns

1. Instance-Based Initialization
require 'posthog-ruby'

posthog = PostHog::Client.new(
  api_key: api_key,
  host: 'https://us.i.posthog.com',
  on_error: proc { |status, msg| puts "PostHog error: #{status} - #{msg}" }
)
2. Event Tracking Pattern
# Track events with distinct_id
posthog.capture(
  distinct_id: 'user_123',
  event: 'event_name',
  properties: { key: 'value' }
)
3. Proper Shutdown
begin
  # Your application code
ensure
  # Always call shutdown to flush events and close connections
  posthog&.shutdown
end
4. Identifying Users
# Identify users (optional - adds user properties)
posthog.identify(
  distinct_id: 'user_123',
  properties: { email: 'user@example.com', plan: 'pro' }
)

Running Without PostHog

The app works fine without PostHog configured - it simply won't track analytics. You'll see a warning message but the app continues to function normally.

Next Steps

  • Modify todo.rb to experiment with PostHog tracking
  • Add new commands and track their usage
  • Explore feature flags: posthog.is_feature_enabled('flag-name', 'user_id')
  • Check your PostHog dashboard to see tracked events

Learn More


.env.example

# PostHog Configuration
POSTHOG_PROJECT_TOKEN=phc_your_project_token_here
POSTHOG_HOST=https://us.i.posthog.com

# Optional: Enable debug mode to see PostHog requests
# POSTHOG_DEBUG=true

Gemfile

source 'https://rubygems.org'

gem 'posthog-ruby', '~> 3.3'
gem 'dotenv', '~> 3.0'

todo.rb

#!/usr/bin/env ruby
# frozen_string_literal: true

# Simple CLI Todo App with PostHog Analytics
#
# A minimal plain Ruby CLI application demonstrating PostHog integration
# for non-framework Ruby projects (CLIs, scripts, data pipelines, etc.).

require 'json'
require 'securerandom'
require 'time'
require 'dotenv/load'
require 'posthog'

# Data file location
DATA_FILE = File.join(Dir.home, '.todo_app.json')

def initialize_posthog
  # Initialize PostHog with instance-based API.
  # Returns PostHog client or nil if project token not configured.
  project_token = ENV['POSTHOG_PROJECT_TOKEN']

  unless project_token
    puts 'WARNING: PostHog not configured (POSTHOG_PROJECT_TOKEN not set)'
    puts '         App will work but analytics won\'t be tracked'
    return nil
  end

  PostHog::Client.new(
    api_key: project_token,
    host: ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com'),
    on_error: proc { |status, msg| puts "PostHog error: #{status} - #{msg}" }
  )
end

def get_user_id
  # Get or create a user ID for this installation.
  # Uses a UUID stored in the data file to represent this user.
  if File.exist?(DATA_FILE)
    data = JSON.parse(File.read(DATA_FILE))
    return data['user_id'] if data['user_id']
  end

  "user_#{SecureRandom.hex(4)}"
end

def load_todos
  # Load todos from disk.
  return { 'user_id' => get_user_id, 'todos' => [] } unless File.exist?(DATA_FILE)

  JSON.parse(File.read(DATA_FILE))
end

def save_todos(data)
  # Save todos to disk.
  File.write(DATA_FILE, JSON.pretty_generate(data))
end

def track_event(posthog, event_name, properties = {})
  # Track an event with PostHog.
  return unless posthog

  posthog.capture(
    distinct_id: get_user_id,
    event: event_name,
    properties: properties
  )
end

def cmd_add(text, posthog)
  # Add a new todo item.
  data = load_todos

  todo = {
    'id' => data['todos'].length + 1,
    'text' => text,
    'completed' => false,
    'created_at' => Time.now.iso8601
  }

  data['todos'] << todo
  save_todos(data)

  puts "Added todo ##{todo['id']}: #{todo['text']}"

  track_event(posthog, 'todo_added', {
    'todo_id' => todo['id'],
    'todo_length' => todo['text'].length,
    'total_todos' => data['todos'].length
  })
end

def cmd_list(posthog)
  # List all todos.
  data = load_todos

  if data['todos'].empty?
    puts "No todos yet! Add one with: ruby todo.rb add 'Your task'"
    return
  end

  puts "\nYour Todos (#{data['todos'].length} total):\n\n"

  data['todos'].each do |todo|
    status = todo['completed'] ? 'X' : ' '
    puts "  [#{status}] ##{todo['id']}: #{todo['text']}"
  end

  puts

  track_event(posthog, 'todos_viewed', {
    'total_todos' => data['todos'].length,
    'completed_todos' => data['todos'].count { |t| t['completed'] }
  })
end

def cmd_complete(id, posthog)
  # Mark a todo as completed.
  data = load_todos

  todo = data['todos'].find { |t| t['id'] == id }

  unless todo
    puts "ERROR: Todo ##{id} not found"
    return
  end

  if todo['completed']
    puts "Todo ##{id} is already completed"
    return
  end

  todo['completed'] = true
  todo['completed_at'] = Time.now.iso8601
  save_todos(data)

  puts "Completed todo ##{todo['id']}: #{todo['text']}"

  time_to_complete = (Time.parse(todo['completed_at']) - Time.parse(todo['created_at'])) / 3600.0

  track_event(posthog, 'todo_completed', {
    'todo_id' => todo['id'],
    'time_to_complete_hours' => time_to_complete
  })
end

def cmd_delete(id, posthog)
  # Delete a todo.
  data = load_todos

  todo = data['todos'].find { |t| t['id'] == id }

  unless todo
    puts "ERROR: Todo ##{id} not found"
    return
  end

  data['todos'].delete(todo)
  save_todos(data)

  puts "Deleted todo ##{id}"

  track_event(posthog, 'todo_deleted', {
    'todo_id' => todo['id'],
    'was_completed' => todo['completed']
  })
end

def cmd_stats(posthog)
  # Show usage statistics.
  data = load_todos

  total = data['todos'].length
  completed = data['todos'].count { |t| t['completed'] }
  pending = total - completed

  puts "\nStats:\n\n"
  puts "  Total todos:     #{total}"
  puts "  Completed:       #{completed}"
  puts "  Pending:         #{pending}"
  puts "  Completion rate: #{total > 0 ? format('%.1f', completed.to_f / total * 100) : '0.0'}%"
  puts

  track_event(posthog, 'stats_viewed', {
    'total_todos' => total,
    'completed_todos' => completed,
    'pending_todos' => pending
  })
end

def print_usage
  puts <<~USAGE
    Simple todo app with PostHog analytics

    Usage:
      ruby todo.rb add "Todo text"    Add a new todo
      ruby todo.rb list               List all todos
      ruby todo.rb complete <id>      Mark todo as completed
      ruby todo.rb delete <id>        Delete a todo
      ruby todo.rb stats              Show statistics
  USAGE
end

# Main entry point
posthog = nil

begin
  posthog = initialize_posthog

  command = ARGV[0]

  unless command
    print_usage
    exit 0
  end

  case command
  when 'add'
    text = ARGV[1]
    unless text
      puts 'ERROR: Please provide todo text'
      puts 'Usage: ruby todo.rb add "Your task"'
      exit 1
    end
    cmd_add(text, posthog)
  when 'list'
    cmd_list(posthog)
  when 'complete'
    id = ARGV[1]&.to_i
    unless id && id > 0
      puts 'ERROR: Please provide a valid todo ID'
      puts 'Usage: ruby todo.rb complete <id>'
      exit 1
    end
    cmd_complete(id, posthog)
  when 'delete'
    id = ARGV[1]&.to_i
    unless id && id > 0
      puts 'ERROR: Please provide a valid todo ID'
      puts 'Usage: ruby todo.rb delete <id>'
      exit 1
    end
    cmd_delete(id, posthog)
  when 'stats'
    cmd_stats(posthog)
  else
    puts "ERROR: Unknown command '#{command}'"
    print_usage
    exit 1
  end
rescue StandardError => e
  puts "ERROR: #{e.message}"

  # Manually capture handled errors
  posthog&.capture_exception(e, get_user_id)

  exit 1
ensure
  # IMPORTANT: Always shutdown PostHog to flush events
  posthog&.shutdown
end

references/EXAMPLE-sveltekit.md

PostHog sveltekit Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/sveltekit


README.md

SvelteKit PostHog example

This example demonstrates how to integrate PostHog with a SvelteKit application, including:

  • Client-side PostHog initialization using SvelteKit hooks
  • Server-side PostHog tracking with the Node.js SDK
  • Reverse proxy to avoid ad blockers
  • User identification and event tracking
  • Error tracking with captureException
  • Session replay configuration

Getting started

1. Install dependencies
npm install
2. Configure environment variables

Copy the example environment file and add your PostHog credentials:

cp .env.example .env

Edit .env with your PostHog project token:

PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

You can find your project token in your PostHog project settings.

3. Run the development server
npm run dev

Open http://localhost:5173 to view the app.

Project structure

src/
├── lib/
│   ├── auth.svelte.ts              # Auth context with Svelte 5 runes
│   ├── components/
│   │   └── Header.svelte           # Navigation component
│   └── server/
│       └── posthog.ts              # Server-side PostHog singleton
├── routes/
│   ├── +layout.svelte              # Root layout with auth provider
│   ├── +page.svelte                # Home/login page
│   ├── burrito/
│   │   └── +page.svelte            # Event tracking demo
│   ├── profile/
│   │   └── +page.svelte            # Error tracking demo
│   └── api/
│       └── auth/
│           └── login/
│               └── +server.ts      # Login API with server-side tracking
├── hooks.client.ts                 # Client-side PostHog init + error handling
├── hooks.server.ts                 # Server hooks with reverse proxy
├── app.css                         # Global styles
└── app.html                        # HTML template

Key integration points

Client-side initialization (src/hooks.client.ts)

PostHog is initialized in the SvelteKit client hooks init function, which runs once when the app starts:

import posthog from 'posthog-js';

export async function init() {
  posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, {
    api_host: '/ingest',
    ui_host: 'https://us.posthog.com',
    defaults: '2026-01-30',
    capture_exceptions: true
  });
}
Server-side tracking (src/lib/server/posthog.ts)

A singleton pattern ensures one PostHog client instance for server-side tracking:

import { PostHog } from 'posthog-node';

let posthogClient: PostHog | null = null;

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, {
      host: PUBLIC_POSTHOG_HOST,
      flushAt: 1,
      flushInterval: 0
    });
  }
  return posthogClient;
}
Reverse proxy (src/hooks.server.ts)

The server hooks handle proxies requests through /ingest to avoid ad blockers:

export const handle: Handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith('/ingest')) {
    const pathname = event.url.pathname.replace('/ingest', '');
    const host = pathname.startsWith('/static')
      ? 'https://us-assets.i.posthog.com'
      : 'https://us.i.posthog.com';
    // Proxy to PostHog...
  }
  return resolve(event);
};
User identification

When a user logs in, they are identified in PostHog:

import posthog from 'posthog-js';

// On login
posthog.identify(userId, { username });
posthog.capture('user_logged_in', { username });

// On logout
posthog.capture('user_logged_out');
posthog.reset();
Error tracking

Errors are automatically captured via the handleError hook:

export const handleError: HandleClientError = async ({ error }) => {
  posthog.captureException(error);
  return { message: 'An error occurred' };
};

You can also manually capture errors:

try {
  // Some operation
} catch (err) {
  posthog.captureException(err);
}
Session replay configuration

For session replay to work correctly, add this to svelte.config.js:

export default {
  kit: {
    paths: {
      relative: false
    }
  }
};

Features demonstrated

  1. Login page (/) - User authentication with PostHog identification
  2. Burrito page (/burrito) - Custom event tracking with properties
  3. Profile page (/profile) - Error tracking demonstration

Learn more


.env.example

# PostHog configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

.npmrc

engine-strict=true
min-release-age=7

src/app.d.ts

// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
	namespace App {
		// interface Error {}
		// interface Locals {}
		// interface PageData {}
		// interface PageState {}
		// interface Platform {}
	}
}

export {};

src/app.html

<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

src/hooks.client.ts

import posthog from 'posthog-js';
import { PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public';
import type { HandleClientError } from '@sveltejs/kit';

// Initialize PostHog when the app starts in the browser
export async function init() {
	posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, {
		api_host: '/ingest',
		ui_host: 'https://us.posthog.com',
  defaults: '2026-01-30',
		capture_exceptions: true
	});
}

// Capture client-side errors with PostHog
export const handleError: HandleClientError = async ({ error, status, message }) => {
	posthog.captureException(error);

	return {
		message,
		status
	};
};

src/hooks.server.ts

import type { Handle, HandleServerError } from '@sveltejs/kit';
import { getPostHogClient } from '$lib/server/posthog';

// Handle requests - includes reverse proxy for PostHog
export const handle: Handle = async ({ event, resolve }) => {
	const { pathname } = event.url;

	// Reverse proxy for PostHog - route /ingest requests to PostHog servers
	if (pathname.startsWith('/ingest')) {
		const useAssetHost = pathname.startsWith('/ingest/static/') || pathname.startsWith('/ingest/array/')
		const hostname = useAssetHost ? 'us-assets.i.posthog.com' : 'us.i.posthog.com';

		const url = new URL(event.request.url);
		url.protocol = 'https:';
		url.hostname = hostname;
		url.port = '443';
		url.pathname = pathname.replace(/^\/ingest/, '');

		const headers = new Headers(event.request.headers);
		headers.set('host', hostname);
		headers.set('accept-encoding', '');

		const clientIp = event.request.headers.get('x-forwarded-for') || event.getClientAddress();
		if (clientIp) {
			headers.set('x-forwarded-for', clientIp);
		}

		const response = await fetch(url.toString(), {
			method: event.request.method,
			headers,
			body: event.request.body,
			// @ts-expect-error - duplex is required for streaming request bodies
			duplex: 'half'
		});

		return response;
	}

	return resolve(event);
};

// Capture server-side errors with PostHog
export const handleError: HandleServerError = async ({ error, status, message }) => {
	const posthog = getPostHogClient();

	posthog.capture({
		distinctId: 'server',
		event: 'server_error',
		properties: {
			error: error instanceof Error ? error.message : String(error),
			status,
			message
		}
	});

	// handleError runs per request; flush so the enqueued event sends before it returns
	await posthog.flush();

	return {
		message,
		status
	};
};

src/lib/auth.svelte.ts

import { getContext, setContext } from 'svelte';
import posthog from 'posthog-js';
import { browser } from '$app/environment';

export interface User {
	username: string;
	burritoConsiderations: number;
}

const AUTH_KEY = Symbol('auth');

// Class-based auth state using Svelte 5 $state in class fields
// This is the recommended pattern for encapsulating reactive state + behavior
export class AuthState {
	user = $state<User | null>(null);

	constructor() {
		// Restore user from localStorage on creation (browser only)
		if (browser) {
			const storedUsername = localStorage.getItem('currentUser');
			if (storedUsername) {
				this.user = { username: storedUsername, burritoConsiderations: 0 };
			}
		}
	}

	login = async (username: string, password: string): Promise<boolean> => {
		try {
			const response = await fetch('/api/auth/login', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ username, password })
			});

			if (response.ok) {
				const { user: userData } = await response.json();
				this.user = userData as User;

				if (browser) {
					localStorage.setItem('currentUser', username);
					posthog.identify(username, { username });
					posthog.capture('user_logged_in', { username });
				}

				return true;
			}
			return false;
		} catch (error) {
			console.error('Login error:', error);
			return false;
		}
	};

	logout = (): void => {
		if (browser) {
			posthog.capture('user_logged_out');
			posthog.reset();
			localStorage.removeItem('currentUser');
		}
		this.user = null;
	};

	incrementBurritoConsiderations = (): void => {
		if (this.user) {
			this.user = {
				...this.user,
				burritoConsiderations: this.user.burritoConsiderations + 1
			};
		}
	};
}

export function setAuthContext(auth: AuthState) {
	setContext(AUTH_KEY, auth);
}

export function getAuthContext(): AuthState {
	return getContext<AuthState>(AUTH_KEY);
}

src/lib/components/Header.svelte

<script lang="ts">
	import { getAuthContext } from '$lib/auth.svelte';

	const auth = getAuthContext();
</script>

<header class="header">
	<div class="header-container">
		<nav>
			<a href="/">Home</a>
			{#if auth.user}
				<a href="/burrito">Burrito</a>
				<a href="/profile">Profile</a>
			{/if}
		</nav>
		<div class="user-section">
			{#if auth.user}
				<span>Welcome, {auth.user.username}</span>
				<button class="btn-logout" onclick={() => auth.logout()}>Logout</button>
			{/if}
		</div>
	</div>
</header>

src/lib/index.ts

// place files you want to import through the `$lib` alias in this folder.

src/lib/server/posthog.ts

import { PostHog } from 'posthog-node';
import { PUBLIC_POSTHOG_PROJECT_TOKEN, PUBLIC_POSTHOG_HOST } from '$env/static/public';

let posthogClient: PostHog | null = null;

export function getPostHogClient() {
	if (!posthogClient) {
		posthogClient = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, {
			host: PUBLIC_POSTHOG_HOST,
			flushAt: 1,
			flushInterval: 0
		});
	}
	return posthogClient;
}

export async function shutdownPostHog() {
	if (posthogClient) {
		await posthogClient.shutdown();
	}
}

src/routes/+layout.svelte

<script lang="ts">
	import { AuthState, setAuthContext } from '$lib/auth.svelte';
	import Header from '$lib/components/Header.svelte';
	import '../app.css';

	let { children } = $props();

	// Create and provide auth context
	const auth = new AuthState();
	setAuthContext(auth);
</script>

<svelte:head>
	<title>Burrito consideration app</title>
	<meta name="description" content="Consider the potential of burritos with PostHog analytics" />
</svelte:head>

<Header />
<main>
	{@render children()}
</main>

src/routes/+page.svelte

<script lang="ts">
	import { getAuthContext } from '$lib/auth.svelte';

	const auth = getAuthContext();

	let username = $state('');
	let password = $state('');
	let error = $state('');

	async function handleSubmit(e: Event) {
		e.preventDefault();
		error = '';

		try {
			const success = await auth.login(username, password);
			if (success) {
				username = '';
				password = '';
			} else {
				error = 'Please provide both username and password';
			}
		} catch (err) {
			console.error('Login failed:', err);
			error = 'An error occurred during login';
		}
	}
</script>

<div class="container">
	{#if auth.user}
		<h1>Welcome back, {auth.user.username}!</h1>
		<p>You are logged in. Check out the navigation to explore features.</p>
		<ul>
			<li><a href="/burrito">Consider a burrito</a></li>
			<li><a href="/profile">View your profile</a></li>
		</ul>
	{:else}
		<h1>Welcome to Burrito consideration app</h1>
		<p>Sign in to start considering burritos.</p>

		<form class="form" onsubmit={handleSubmit}>
			<div class="form-group">
				<label for="username">Username:</label>
				<input type="text" id="username" bind:value={username} required />
			</div>

			<div class="form-group">
				<label for="password">Password:</label>
				<input type="password" id="password" bind:value={password} required />
			</div>

			{#if error}
				<p class="error">{error}</p>
			{/if}

			<button type="submit" class="btn-primary">Sign In</button>
		</form>

		<p class="note">
			Enter any username and password to sign in. This is a demo app.
		</p>
	{/if}
</div>

src/routes/api/auth/login/+server.ts

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPostHogClient } from '$lib/server/posthog';

const users = new Map<string, { username: string; burritoConsiderations: number }>();

export const POST: RequestHandler = async ({ request }) => {
	const { username, password } = await request.json();

	if (!username || !password) {
		return json({ error: 'Username and password required' }, { status: 400 });
	}

	let user = users.get(username);
	const isNewUser = !user;

	if (!user) {
		user = { username, burritoConsiderations: 0 };
		users.set(username, user);
	}

	// Capture server-side login event with user context
	const posthog = getPostHogClient();
	posthog.withContext(
		{
			distinctId: username,
			personProperties: {
				username,
				createdAt: isNewUser ? new Date().toISOString() : undefined
			}
		},
		() => {
			posthog.capture({
				event: 'server_login',
				properties: {
					isNewUser,
					source: 'api'
				}
			});
		}
	);

	// Flush events to ensure they're sent
	await posthog.flush();

	return json({ success: true, user });
};

src/routes/burrito/+page.svelte

<script lang="ts">
	import { goto } from '$app/navigation';
	import { browser } from '$app/environment';
	import posthog from 'posthog-js';
	import { getAuthContext } from '$lib/auth.svelte';

	const auth = getAuthContext();

	let hasConsidered = $state(false);

	// Redirect to home if not logged in
	$effect(() => {
		if (browser && !auth.user) {
			goto('/');
		}
	});

	function handleConsideration() {
		if (!auth.user) return;

		auth.incrementBurritoConsiderations();
		hasConsidered = true;
		setTimeout(() => (hasConsidered = false), 2000);

		// Capture burrito consideration event with PostHog
		posthog.capture('burrito_considered', {
			total_considerations: auth.user.burritoConsiderations,
			username: auth.user.username
		});
	}
</script>

<div class="container">
	{#if auth.user}
		<h1>Burrito consideration zone</h1>
		<p>This is where you consider the infinite potential of burritos.</p>
		<p>Current considerations: <strong>{auth.user.burritoConsiderations}</strong></p>

		<button class="btn-burrito" onclick={handleConsideration}>
			I have considered the burrito potential
		</button>

		{#if hasConsidered}
			<p class="success">
				Thank you for your consideration! Count: {auth.user.burritoConsiderations}
			</p>
		{/if}

		<div class="note">
			<p>Each consideration is tracked as a PostHog event with custom properties.</p>
		</div>
	{:else}
		<p>Please log in to consider burritos.</p>
	{/if}
</div>

src/routes/profile/+page.svelte

<script lang="ts">
	import { goto } from '$app/navigation';
	import { browser } from '$app/environment';
	import posthog from 'posthog-js';
	import { getAuthContext } from '$lib/auth.svelte';

	const auth = getAuthContext();

	// Redirect to home if not logged in
	$effect(() => {
		if (browser && !auth.user) {
			goto('/');
		}
	});

	function triggerTestError() {
		try {
			throw new Error('Test error for PostHog error tracking');
		} catch (err) {
			posthog.captureException(err);
			console.error('Captured error:', err);
			alert('Error captured and sent to PostHog!');
		}
	}
</script>

<div class="container">
	{#if auth.user}
		<h1>User profile</h1>

		<div class="stats">
			<h2>Your information</h2>
			<p><strong>Username:</strong> {auth.user.username}</p>
			<p><strong>Burrito considerations:</strong> {auth.user.burritoConsiderations}</p>
		</div>

		<h2 style="margin-top: 2rem;">Error tracking demo</h2>
		<p>Click the button below to trigger a test error that will be captured by PostHog.</p>

		<button class="btn-primary" onclick={triggerTestError} style="margin-top: 1rem;">
			Trigger test error (for PostHog)
		</button>

		<div class="note">
			<p>This demonstrates PostHog's error tracking capabilities.</p>
			<p>The error will appear in your PostHog error tracking dashboard.</p>
		</div>
	{:else}
		<p>Please log in to view your profile.</p>
	{/if}
</div>

static/robots.txt

# allow crawling everything by default
User-agent: *
Disallow:

svelte.config.js

import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	// Consult https://svelte.dev/docs/kit/integrations
	// for more information about preprocessors
	preprocess: vitePreprocess(),

	kit: {
		// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
		// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
		// See https://svelte.dev/docs/kit/adapters for more information about adapters.
		adapter: adapter(),
		// Required for PostHog session replay to work correctly with SSR
		paths: {
			relative: false
		}
	}
};

export default config;

vite.config.ts

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [sveltekit()]
});

references/EXAMPLE-swift.md

PostHog swift Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/swift


README.md

PostHog Swift (iOS/macOS) example

This is a SwiftUI example demonstrating PostHog integration with product analytics, error tracking, and user identification. The app targets both iOS and macOS using NavigationSplitView.

Features

  • Product analytics: Track user events and behaviors
  • Error tracking: Capture and track errors
  • User identification: Associate events with authenticated users
  • Multi-platform: Runs on iOS, iPadOS, macOS, and visionOS

Getting started

1. Add the PostHog dependency

The Xcode project already includes the PostHog iOS SDK via Swift Package Manager. When you open the project, Xcode will resolve the package automatically.

To add it manually to a new project: File > Add Package Dependencies > enter https://github.com/PostHog/posthog-ios.

2. Set your PostHog project token

Open BurritoConsiderationClientApp.swift and replace the <your-project-token> placeholder in posthogProjectToken with your project token from your PostHog project settings.

The PostHog project token is a public client-side key — it is designed to ship in the app binary — so hardcoding it is safe and is the recommended approach for iOS distribution.

Don't rely on Xcode scheme environment variables as the only source. Scheme environment variables are injected only when launching from Xcode (debug/simulator); they are absent in Archive / Release builds (TestFlight, App Store). Reading them is fine, but treat them as an optional override over a value that ships in the binary — never force-unwrap or fatalError on their absence, or production builds will crash on launch.

3. Build and run

Open BurritoConsiderationClient.xcodeproj in Xcode and run on an iOS Simulator or macOS.

Project structure

BurritoConsiderationClient/
├── BurritoConsiderationClientApp.swift  # App entry point with PostHog initialization
├── ContentView.swift                    # NavigationSplitView with sidebar routing
├── UserState.swift                      # @Observable user state with PostHog identify
├── LoginView.swift                      # Login form
├── DashboardView.swift                  # Welcome screen with dashboard_viewed tracking
├── BurritoView.swift                    # Burrito consideration with event capture
├── ProfileView.swift                    # Profile with journey progress and error trigger
└── Assets.xcassets/                     # Asset catalog

Key integration points

PostHog initialization (BurritoConsiderationClientApp.swift)
import PostHog

// The project token is a public client-side key, so it's safe to ship in the
// binary. Replace the placeholder with your token from the PostHog project settings.
let config = PostHogConfig(apiKey: "<your-project-token>", host: "https://us.i.posthog.com")
config.captureApplicationLifecycleEvents = true
PostHogSDK.shared.setup(config)
User identification (UserState.swift)
PostHogSDK.shared.identify(username, userProperties: [
    "username": username,
])
Screen view tracking (DashboardView.swift, ProfileView.swift)
.onAppear {
    PostHogSDK.shared.capture("dashboard_viewed", properties: [
        "username": userState.username ?? "unknown",
    ])
}
Event tracking (BurritoView.swift)
PostHogSDK.shared.capture("burrito_considered", properties: [
    "total_considerations": count,
    "username": username,
])
Error tracking (ProfileView.swift)
PostHogSDK.shared.capture("test_error_triggered", properties: [
    "error_type": "test",
    "error_message": error.localizedDescription,
])
User logout (UserState.swift)
PostHogSDK.shared.capture("user_logged_out")
PostHogSDK.shared.reset()

Learn more


BurritoConsiderationClient.xcodeproj/project.xcworkspace/contents.xcworkspacedata

<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "self:">
   </FileRef>
</Workspace>

BurritoConsiderationClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

{
  "originHash" : "a2fc303e4b16c93c972ef2ddc4042cf91a9400e5d1639bc9740a80c0336cdd4e",
  "pins" : [
    {
      "identity" : "posthog-ios",
      "kind" : "remoteSourceControl",
      "location" : "https://github.com/PostHog/posthog-ios",
      "state" : {
        "revision" : "1783865d79a1cabc472cf2d56a1fe3f797417b52",
        "version" : "3.40.0"
      }
    }
  ],
  "version" : 3
}

BurritoConsiderationClient.xcodeproj/xcshareddata/xcschemes/BurritoConsiderationClient.xcscheme

<?xml version="1.0" encoding="UTF-8"?>
<Scheme
   LastUpgradeVersion = "2630"
   version = "1.7">
   <BuildAction
      parallelizeBuildables = "YES"
      buildImplicitDependencies = "YES"
      buildArchitectures = "Automatic">
      <BuildActionEntries>
         <BuildActionEntry
            buildForTesting = "YES"
            buildForRunning = "YES"
            buildForProfiling = "YES"
            buildForArchiving = "YES"
            buildForAnalyzing = "YES">
            <BuildableReference
               BuildableIdentifier = "primary"
               BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
               BuildableName = "BurritoConsiderationClient.app"
               BlueprintName = "BurritoConsiderationClient"
               ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
            </BuildableReference>
         </BuildActionEntry>
      </BuildActionEntries>
   </BuildAction>
   <TestAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      shouldUseLaunchSchemeArgsEnv = "YES"
      shouldAutocreateTestPlan = "YES">
   </TestAction>
   <LaunchAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      launchStyle = "0"
      useCustomWorkingDirectory = "NO"
      ignoresPersistentStateOnLaunch = "NO"
      debugDocumentVersioning = "YES"
      debugServiceExtension = "internal"
      allowLocationSimulation = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
            BuildableName = "BurritoConsiderationClient.app"
            BlueprintName = "BurritoConsiderationClient"
            ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
      <EnvironmentVariables>
         <EnvironmentVariable
            key = "POSTHOG_PROJECT_TOKEN"
            value = "phc_jE9kXU0depRekiuabVROlxxkIXn95NqsNO3qB4qNKtl"
            isEnabled = "YES">
         </EnvironmentVariable>
         <EnvironmentVariable
            key = "POSTHOG_HOST"
            value = "https://us.i.posthog.com"
            isEnabled = "YES">
         </EnvironmentVariable>
      </EnvironmentVariables>
   </LaunchAction>
   <ProfileAction
      buildConfiguration = "Release"
      shouldUseLaunchSchemeArgsEnv = "YES"
      savedToolIdentifier = ""
      useCustomWorkingDirectory = "NO"
      debugDocumentVersioning = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "1F6BD9732F3520A100189B0B"
            BuildableName = "BurritoConsiderationClient.app"
            BlueprintName = "BurritoConsiderationClient"
            ReferencedContainer = "container:BurritoConsiderationClient.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
   </ProfileAction>
   <AnalyzeAction
      buildConfiguration = "Debug">
   </AnalyzeAction>
   <ArchiveAction
      buildConfiguration = "Release"
      revealArchiveInOrganizer = "YES">
   </ArchiveAction>
</Scheme>

BurritoConsiderationClient/BurritoConsiderationClientApp.swift

//
//  BurritoConsiderationClientApp.swift
//  BurritoConsiderationClient
//
//  Created by Danilo Campos on 2/5/26.
//

import SwiftUI
import PostHog

// PostHog configuration.
//
// The project token is a PUBLIC client-side key — it is designed to ship in the
// app binary, so hardcoding it here is safe and is the recommended approach for
// iOS. Replace the placeholder below with your project token from
// https://app.posthog.com/project/settings.
private let posthogProjectToken = "<your-project-token>"
private let posthogHost = "https://us.i.posthog.com"

@main
struct BurritoConsiderationClientApp: App {
    @State private var userState = UserState()

    init() {
        let config = PostHogConfig(apiKey: posthogProjectToken, host: posthogHost)
        config.captureApplicationLifecycleEvents = true
        config.debug = true
        PostHogSDK.shared.setup(config)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(userState)
        }
    }
}

BurritoConsiderationClient/BurritoView.swift

//
//  BurritoView.swift
//  BurritoConsiderationClient
//

import SwiftUI
import PostHog

struct BurritoView: View {
    @Environment(UserState.self) private var userState
    @State private var showConfirmation = false

    var body: some View {
        VStack(spacing: 24) {
            Text("Take a moment to truly consider the potential of burritos.")
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)

            Text("🌯")
                .font(.system(size: 80))

            Button("I Have Considered the Burrito Potential") {
                userState.burritoConsiderations += 1

                // PostHog: Capture burrito consideration event
                PostHogSDK.shared.capture("burrito_considered", properties: [
                    "total_considerations": userState.burritoConsiderations,
                    "username": userState.username ?? "unknown",
                ])

                showConfirmation = true
                Task {
                    try? await Task.sleep(for: .seconds(2))
                    showConfirmation = false
                }
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)

            if showConfirmation {
                Text("Thank you for your consideration! Count: \(userState.burritoConsiderations)")
                    .foregroundStyle(.green)
                    .transition(.opacity)
            }

            Text("Total considerations: \(userState.burritoConsiderations)")
                .font(.title2)
                .padding(.top)
        }
        .padding()
        .animation(.default, value: showConfirmation)
        .navigationTitle("Burrito Consideration Zone")
    }
}

BurritoConsiderationClient/ContentView.swift

//
//  ContentView.swift
//  BurritoConsiderationClient
//
//  Created by Danilo Campos on 2/5/26.
//

import SwiftUI

enum Screen: CaseIterable, Identifiable {
    case dashboard, burrito, profile

    var id: Self { self }

    var title: String {
        switch self {
        case .dashboard: "Home"
        case .burrito: "Burrito"
        case .profile: "Profile"
        }
    }

    var icon: String {
        switch self {
        case .dashboard: "house"
        case .burrito: "fork.knife"
        case .profile: "person.circle"
        }
    }
}

struct ContentView: View {
    @Environment(UserState.self) private var userState
    @State private var selectedScreen: Screen? = .dashboard

    var body: some View {
        if userState.isLoggedIn {
            NavigationSplitView {
                List(Screen.allCases, selection: $selectedScreen) { screen in
                    Label(screen.title, systemImage: screen.icon)
                }
                .navigationTitle("Menu")
            } detail: {
                if let selectedScreen {
                    switch selectedScreen {
                    case .dashboard:
                        DashboardView()
                    case .burrito:
                        BurritoView()
                    case .profile:
                        ProfileView()
                    }
                } else {
                    Text("Select an item from the sidebar")
                        .foregroundStyle(.secondary)
                }
            }
        } else {
            NavigationStack {
                LoginView()
            }
        }
    }
}

BurritoConsiderationClient/DashboardView.swift

//
//  DashboardView.swift
//  BurritoConsiderationClient
//

import SwiftUI
import PostHog

struct DashboardView: View {
    @Environment(UserState.self) private var userState

    var body: some View {
        VStack(spacing: 20) {
            Text("Welcome back, \(userState.username ?? "")!")
                .font(.largeTitle)
                .padding(.top, 40)

            Text("You are logged in. Feel free to explore:")
                .foregroundStyle(.secondary)

            VStack(alignment: .leading, spacing: 12) {
                Label("Consider the potential of burritos", systemImage: "fork.knife")
                Label("View your profile and statistics", systemImage: "person.circle")
            }
            .padding()

            Spacer()
        }
        .padding()
        .navigationTitle("Home")
        .onAppear {
            // PostHog: Track dashboard view
            PostHogSDK.shared.capture("dashboard_viewed", properties: [
                "username": userState.username ?? "unknown",
            ])
        }
    }
}

BurritoConsiderationClient/LoginView.swift

//
//  LoginView.swift
//  BurritoConsiderationClient
//

import SwiftUI

struct LoginView: View {
    @Environment(UserState.self) private var userState
    @State private var username = ""
    @State private var password = ""
    @State private var showError = false

    var body: some View {
        Form {
            Section("Login") {
                TextField("Username", text: $username)
                    #if os(iOS)
                    .textInputAutocapitalization(.never)
                    #endif
                    .autocorrectionDisabled()

                SecureField("Password", text: $password)
            }

            Section {
                Button("Log In") {
                    if !userState.login(username: username, password: password) {
                        showError = true
                    }
                }
                .disabled(username.isEmpty || password.isEmpty)
            }
        }
        .formStyle(.grouped)
        .navigationTitle("Burrito Consideration")
        .alert("Login Failed", isPresented: $showError) {
            Button("OK", role: .cancel) { }
        } message: {
            Text("Please enter a valid username and password.")
        }
    }
}

BurritoConsiderationClient/ProfileView.swift

//
//  ProfileView.swift
//  BurritoConsiderationClient
//

import SwiftUI
import PostHog

struct ProfileView: View {
    @Environment(UserState.self) private var userState

    private var journeyMessage: String {
        switch userState.burritoConsiderations {
        case 0:
            "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!"
        case 1:
            "You've considered the burrito potential once. Keep going!"
        case 2...4:
            "You're getting the hang of burrito consideration!"
        case 5...9:
            "You're becoming a burrito consideration expert!"
        default:
            "You are a true burrito consideration master!"
        }
    }

    var body: some View {
        Form {
            Section("Your Information") {
                LabeledContent("Username", value: userState.username ?? "—")
                LabeledContent("Burrito Considerations", value: "\(userState.burritoConsiderations)")
            }

            Section("Your Burrito Journey") {
                Text(journeyMessage)
            }

            Section("Diagnostics") {
                Button("Trigger Test Error") {
                    let error = NSError(
                        domain: "com.posthog.BurritoConsiderationClient",
                        code: 42,
                        userInfo: [NSLocalizedDescriptionKey: "Test error triggered by user"]
                    )

                    // PostHog: Capture exception for error tracking
                    PostHogSDK.shared.capture("test_error_triggered", properties: [
                        "error_type": "test",
                        "error_message": error.localizedDescription,
                        "username": userState.username ?? "unknown",
                    ])
                }
            }

            Section {
                Button("Log Out", role: .destructive) {
                    userState.logout()
                }
            }
        }
        .formStyle(.grouped)
        .navigationTitle("Profile")
        .onAppear {
            // PostHog: Track profile view
            PostHogSDK.shared.capture("profile_viewed", properties: [
                "username": userState.username ?? "unknown",
            ])
        }
    }
}

BurritoConsiderationClient/UserState.swift

//
//  UserState.swift
//  BurritoConsiderationClient
//

import Foundation
import PostHog

@Observable
class UserState {
    var username: String?
    var burritoConsiderations: Int = 0

    var isLoggedIn: Bool {
        username != nil
    }

    func login(username: String, password: String) -> Bool {
        // In a real app, validate credentials against a backend
        guard !username.isEmpty, !password.isEmpty else {
            return false
        }

        self.username = username
        self.burritoConsiderations = 0

        // PostHog: Identify user on login
        PostHogSDK.shared.identify(username, userProperties: [
            "username": username,
        ])

        // PostHog: Capture login event
        PostHogSDK.shared.capture("user_logged_in", properties: [
            "username": username,
        ])

        return true
    }

    func logout() {
        // PostHog: Capture logout event before reset
        PostHogSDK.shared.capture("user_logged_out")
        PostHogSDK.shared.reset()

        username = nil
        burritoConsiderations = 0
    }
}

references/EXAMPLE-tanstack-start.md

PostHog tanstack-start Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/tanstack-start


README.md

PostHog TanStack Start example

This is a TanStack Start example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.

Features

  • Product analytics: Track user events and behaviors
  • Session replay: Record and replay user sessions
  • Error tracking: Capture and track errors automatically
  • User authentication: Demo login system with PostHog user identification
  • Server-side & client-side tracking: Complete examples of both tracking methods
  • Reverse proxy: PostHog ingestion through Vite dev server proxy

Getting started

1. Install dependencies
npm install
2. Configure environment variables

Create a .env file in the root directory:

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your PostHog project settings.

3. Run the development server
npm run dev

Open http://localhost:3000 with your browser to see the app.

Project structure

src/
├── components/
│   └── Header.tsx           # Navigation header with auth state
├── contexts/
│   └── AuthContext.tsx      # Authentication context with PostHog integration
├── utils/
│   └── posthog-server.ts   # Server-side PostHog client
├── routes/
│   ├── __root.tsx           # Root route with PostHogProvider
│   ├── index.tsx            # Home/login page
│   ├── burrito.tsx          # Demo feature page with event tracking
│   ├── profile.tsx          # User profile with error tracking demo
│   └── api/
│       ├── auth/
│       │   └── login.ts     # Login API with server-side tracking
│       └── burrito/
│           └── consider.ts  # Burrito API with server-side tracking
└── styles.css               # Global styles

vite.config.ts               # Vite config with PostHog proxy
.env                         # Environment variables

Key integration points

Client-side initialization (routes/__root.tsx)

PostHog is initialized using PostHogProvider from @posthog/react. The provider wraps the entire app in the root shell component and handles calling posthog.init() automatically:

import { PostHogProvider } from '@posthog/react'

<PostHogProvider
  apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
  options={{
    api_host: '/ingest',
    ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
    defaults: '2025-05-24',
    capture_exceptions: true,
    debug: import.meta.env.DEV,
  }}
>
  {children}
</PostHogProvider>
Server-side setup (utils/posthog-server.ts)

For server-side tracking, we use the posthog-node SDK with a singleton pattern:

import { PostHog } from 'posthog-node'

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(
      process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN || import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!,
      {
        host: process.env.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
        flushAt: 1,
        flushInterval: 0,
      }
    )
  }
  return posthogClient
}

This client is used in API routes to track server-side events.

Server-side capture (routes/api/*)

Server-side events include the client's $session_id so they appear in the same session in PostHog. The tracing_headers option on the PostHogProvider adds the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to same-origin requests automatically, so the frontend fetch needs no PostHog headers of its own:

// Client: tracing_headers is configured once on the PostHogProvider
options={{
  api_host: '/ingest',
  // ...
  // Guarded for SSR, where `window` is undefined.
  tracing_headers: typeof window !== 'undefined' ? [window.location.hostname] : [],
}}

// Frontend fetch — no manual header needed
await fetch('/api/burrito/consider', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ... }),
})
// Server: read session ID from header and include in capture
import { getPostHogClient } from '../../utils/posthog-server'

const sessionId = request.headers.get('X-PostHog-Session-Id')

const posthog = getPostHogClient()
posthog.capture({
  distinctId: username,
  event: 'burrito_considered',
  properties: {
    $session_id: sessionId || undefined,
    username: username,
    source: 'api',
  },
})
Reverse proxy configuration

The Vite dev server is configured to proxy PostHog requests to avoid CORS issues and improve reliability:

server: {
  proxy: {
    '/ingest': {
      target: 'https://us.i.posthog.com',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/ingest/, ''),
      secure: false,
    },
  },
}
User identification (contexts/AuthContext.tsx)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.identify(username, {
  username: username,
})
Event tracking (routes/burrito.tsx)
import { usePostHog } from '@posthog/react'

const posthog = usePostHog()

posthog.capture('burrito_considered', {
  total_considerations: user.burritoConsiderations + 1,
  username: user.username,
})
Error tracking (routes/profile.tsx)
posthog.captureException(error)

Learn more


.env.example

VITE_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>

.prettierignore

package-lock.json
pnpm-lock.yaml
yarn.lock

prettier.config.js

//  @ts-check

/** @type {import('prettier').Config} */
const config = {
  semi: false,
  singleQuote: true,
  trailingComma: "all",
};

export default config;

public/robots.txt

# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

src/components/Header.tsx

import { Link } from '@tanstack/react-router'
import { useAuth } from '../contexts/AuthContext'

export default function Header() {
  const { user, logout } = useAuth()

  return (
    <header className="header">
      <div className="header-container">
        <nav>
          <Link to="/">Home</Link>
          {user && (
            <>
              <Link to="/burrito">Burrito Consideration</Link>
              <Link to="/profile">Profile</Link>
            </>
          )}
        </nav>
        <div className="user-section">
          {user ? (
            <>
              <span>Welcome, {user.username}!</span>
              <button onClick={logout} className="btn-logout">
                Logout
              </button>
            </>
          ) : (
            <span>Not logged in</span>
          )}
        </div>
      </div>
    </header>
  )
}

src/contexts/AuthContext.tsx

import {
  createContext,
  useContext,
  useState,
  ReactNode,
} from 'react'
import { usePostHog } from '@posthog/react'

interface User {
  username: string
  burritoConsiderations: number
}

interface AuthContextType {
  user: User | null
  login: (username: string, password: string) => Promise<boolean>
  logout: () => void
  incrementBurritoConsiderations: () => void
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

const users: Map<string, User> = new Map()

export function AuthProvider({ children }: { children: ReactNode }) {
  const posthog = usePostHog()

  // Use lazy initializer to read from localStorage only once on mount
  const [user, setUser] = useState<User | null>(() => {
    if (typeof window === 'undefined') return null

    const storedUsername = localStorage.getItem('currentUser')
    if (storedUsername) {
      const existingUser = users.get(storedUsername)
      if (existingUser) {
        return existingUser
      }
    }
    return null
  })

  const login = async (
    username: string,
    password: string,
  ): Promise<boolean> => {
    try {
      // The session and distinct ID are added automatically by the
      // tracing_headers option configured on the PostHogProvider.
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username, password }),
      })

      if (response.ok) {
        const { user: userData } = await response.json()

        // Get or create user in local map
        let localUser = users.get(username)
        if (!localUser) {
          localUser = userData as User
          users.set(username, localUser)
        }

        setUser(localUser)
        if (typeof window !== 'undefined') {
          localStorage.setItem('currentUser', username)
        }

        // Identify user in PostHog using username as distinct ID
        posthog.identify(username, {
          username: username,
        })

        // Capture login event
        posthog.capture('user_logged_in', {
          username: username,
        })

        return true
      }
      return false
    } catch (error) {
      console.error('Login error:', error)
      return false
    }
  }

  const logout = () => {
    // Capture logout event before resetting
    posthog.capture('user_logged_out')
    posthog.reset()

    setUser(null)
    if (typeof window !== 'undefined') {
      localStorage.removeItem('currentUser')
    }
  }

  const incrementBurritoConsiderations = () => {
    if (user) {
      user.burritoConsiderations++
      users.set(user.username, user)
      setUser({ ...user })
    }
  }

  return (
    <AuthContext.Provider
      value={{ user, login, logout, incrementBurritoConsiderations }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

src/router.tsx

import { createRouter } from '@tanstack/react-router'

// Import the generated route tree
import { routeTree } from './routeTree.gen'

// Create a new router instance
export const getRouter = () => {
  return createRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreloadStaleTime: 0,
  })
}

src/routes/__root.tsx

import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { PostHogProvider } from '@posthog/react'

import Header from '../components/Header'
import { AuthProvider } from '../contexts/AuthContext'

import appCss from '../styles.css?url'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      {
        title: 'TanStack Start Starter',
      },
    ],
    links: [
      {
        rel: 'stylesheet',
        href: appCss,
      },
    ],
  }),

  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <PostHogProvider
          apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!}
          options={{
            api_host: '/ingest',
            ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
            defaults: '2025-05-24',
            capture_exceptions: true,
            debug: import.meta.env.DEV,
            // Automatically add X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID
            // headers to same-origin requests so server-side events join the
            // same session. Guarded for SSR, where `window` is undefined.
            tracing_headers:
              typeof window !== 'undefined' ? [window.location.hostname] : [],
          }}
        >
          <AuthProvider>
            <Header />
            {children}
          <TanStackDevtools
            config={{
              position: 'bottom-right',
            }}
            plugins={[
              {
                name: 'Tanstack Router',
                render: <TanStackRouterDevtoolsPanel />,
              },
            ]}
          />
          </AuthProvider>
        </PostHogProvider>
        <Scripts />
      </body>
    </html>
  )
}

src/routes/api/auth/login.ts

import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../../utils/posthog-server'

export const Route = createFileRoute('/api/auth/login')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const body = await request.json()
        const { username, password } = body

        // Simple validation (in production, you'd verify against a real database)
        if (!username || !password) {
          return json(
            { error: 'Username and password required' },
            { status: 400 },
          )
        }

        // Check if this is a new user (simplified - in production use a database)
        const isNewUser = !username

        // Create or get user
        const user = {
          username,
          burritoConsiderations: 0,
        }

        const sessionId = request.headers.get('X-PostHog-Session-Id')

        // Capture server-side login event
        const posthog = getPostHogClient()
        posthog.capture({
          distinctId: username,
          event: 'server_login',
          properties: {
            $session_id: sessionId || undefined,
            username: username,
            isNewUser: isNewUser,
            source: 'api',
          },
        })

        // Identify user on server side
        posthog.identify({
          distinctId: username,
          properties: {
            username: username,
            createdAt: isNewUser ? new Date().toISOString() : undefined,
          },
        })

        // This handler is short-lived; flush so the enqueued events send before it returns
        await posthog.flush()

        return json({ success: true, user })
      },
    },
  },
})

src/routes/api/burrito/consider.ts

import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../../utils/posthog-server'

export const Route = createFileRoute('/api/burrito/consider')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const body = await request.json()
        const { username, totalConsiderations } = body

        if (!username) {
          return json(
            { error: 'Username is required' },
            { status: 400 },
          )
        }

        const sessionId = request.headers.get('X-PostHog-Session-Id')

        const posthog = getPostHogClient()
        posthog.capture({
          distinctId: username,
          event: 'burrito_considered',
          properties: {
            $session_id: sessionId || undefined,
            total_considerations: totalConsiderations,
            username: username,
            source: 'api',
          },
        })

        // This handler is short-lived; flush so the enqueued event sends before it returns
        await posthog.flush()

        return json({ success: true })
      },
    },
  },
})

src/routes/burrito.tsx

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/burrito')({
  component: BurritoPage,
  head: () => ({
    meta: [
      {
        title: 'Burrito Consideration - Burrito Consideration App',
      },
      {
        name: 'description',
        content: 'Consider the potential of burritos',
      },
    ],
  }),
})

function BurritoPage() {
  const { user, incrementBurritoConsiderations } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()
  const [hasConsidered, setHasConsidered] = useState(false)

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const handleClientConsideration = () => {
    incrementBurritoConsiderations()
    setHasConsidered(true)
    setTimeout(() => setHasConsidered(false), 2000)

    posthog.capture('burrito_considered', {
      total_considerations: user.burritoConsiderations + 1,
      username: user.username,
    })
  }

  const handleServerConsideration = async () => {
    incrementBurritoConsiderations()
    setHasConsidered(true)
    setTimeout(() => setHasConsidered(false), 2000)

    // The session and distinct ID are added automatically by the
    // tracing_headers option configured on the PostHogProvider.
    await fetch('/api/burrito/consider', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        username: user.username,
        totalConsiderations: user.burritoConsiderations + 1,
      }),
    })
  }

  return (
    <main>
      <div className="container">
        <h1>Burrito consideration zone</h1>
        <p>Take a moment to truly consider the potential of burritos.</p>

        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.125rem' }}>
          <button
            onClick={handleClientConsideration}
            className="btn-burrito"
            style={{ backgroundColor: '#e07c24', color: '#fff' }}
          >
            Consider burrito (client)
          </button>
          <button
            onClick={handleServerConsideration}
            className="btn-burrito"
            style={{ backgroundColor: '#4a90d9', color: '#fff' }}
          >
            Consider burrito (server)
          </button>

          {hasConsidered && (
            <p className="success">
              Thank you for your consideration! Count:{' '}
              {user.burritoConsiderations}
            </p>
          )}
        </div>

        <div className="stats">
          <h3>Consideration stats</h3>
          <p>Total considerations: {user.burritoConsiderations}</p>
        </div>
      </div>
    </main>
  )
}

src/routes/index.tsx

import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/')({
  component: Home,
  head: () => ({
    meta: [
      {
        title: 'Burrito Consideration App',
      },
      {
        name: 'description',
        content: 'Consider the potential of burritos',
      },
    ],
  }),
})

function Home() {
  const { user, login } = useAuth()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const success = await login(username, password)
      if (success) {
        setUsername('')
        setPassword('')
      } else {
        setError('Please provide both username and password')
      }
    } catch (err) {
      console.error('Login failed:', err)
      setError('An error occurred during login')
    }
  }

  return (
    <main>
      {user ? (
        <div className="container">
          <h1>Welcome back, {user.username}!</h1>
          <p>You are logged in. Feel free to explore:</p>
          <ul>
            <li>Consider the potential of burritos</li>
            <li>View your profile and statistics</li>
          </ul>
        </div>
      ) : (
        <div className="container">
          <h1>Welcome to Burrito Consideration App</h1>
          <p>Please sign in to begin your burrito journey</p>

          <form onSubmit={handleSubmit} className="form">
            <div className="form-group">
              <label htmlFor="username">Username:</label>
              <input
                type="text"
                id="username"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
                placeholder="Enter any username"
              />
            </div>

            <div className="form-group">
              <label htmlFor="password">Password:</label>
              <input
                type="password"
                id="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                placeholder="Enter any password"
              />
            </div>

            {error && <p className="error">{error}</p>}

            <button type="submit" className="btn-primary">
              Sign In
            </button>
          </form>

          <p className="note">
            Note: This is a demo app. Use any username and password to sign in.
          </p>
        </div>
      )}
    </main>
  )
}

src/routes/profile.tsx

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { usePostHog } from '@posthog/react'
import { useAuth } from '../contexts/AuthContext'

export const Route = createFileRoute('/profile')({
  component: ProfilePage,
  head: () => ({
    meta: [
      {
        title: 'Profile - Burrito Consideration App',
      },
      {
        name: 'description',
        content: 'Your burrito consideration profile',
      },
    ],
  }),
})

function ProfilePage() {
  const { user } = useAuth()
  const navigate = useNavigate()
  const posthog = usePostHog()

  // Redirect to home if not logged in
  if (!user) {
    navigate({ to: '/' })
    return null
  }

  const triggerTestError = () => {
    try {
      throw new Error('Test error for PostHog error tracking')
    } catch (err) {
      posthog.captureException(err)
      console.error('Captured error:', err)
      alert('Error captured and sent to PostHog!')
    }
  }

  return (
    <main>
      <div className="container">
        <h1>User Profile</h1>

        <div className="stats">
          <h2>Your Information</h2>
          <p>
            <strong>Username:</strong> {user.username}
          </p>
          <p>
            <strong>Burrito Considerations:</strong>{' '}
            {user.burritoConsiderations}
          </p>
        </div>

        <div style={{ marginTop: '2rem' }}>
          <button
            onClick={triggerTestError}
            className="btn-primary"
            style={{ backgroundColor: '#dc3545' }}
          >
            Trigger Test Error (for PostHog)
          </button>
        </div>

        <div style={{ marginTop: '2rem' }}>
          <h3>Your Burrito Journey</h3>
          {user.burritoConsiderations === 0 ? (
            <p>
              You haven't considered any burritos yet. Visit the Burrito
              Consideration page to start!
            </p>
          ) : user.burritoConsiderations === 1 ? (
            <p>You've considered the burrito potential once. Keep going!</p>
          ) : user.burritoConsiderations < 5 ? (
            <p>You're getting the hang of burrito consideration!</p>
          ) : user.burritoConsiderations < 10 ? (
            <p>You're becoming a burrito consideration expert!</p>
          ) : (
            <p>You are a true burrito consideration master! 🌯</p>
          )}
        </div>
      </div>
    </main>
  )
}

src/routeTree.gen.ts

/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as ProfileRouteImport } from './routes/profile'
import { Route as BurritoRouteImport } from './routes/burrito'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiBurritoConsiderRouteImport } from './routes/api/burrito/consider'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'

const ProfileRoute = ProfileRouteImport.update({
  id: '/profile',
  path: '/profile',
  getParentRoute: () => rootRouteImport,
} as any)
const BurritoRoute = BurritoRouteImport.update({
  id: '/burrito',
  path: '/burrito',
  getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
  id: '/',
  path: '/',
  getParentRoute: () => rootRouteImport,
} as any)
const ApiBurritoConsiderRoute = ApiBurritoConsiderRouteImport.update({
  id: '/api/burrito/consider',
  path: '/api/burrito/consider',
  getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
  id: '/api/auth/login',
  path: '/api/auth/login',
  getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/burrito': typeof BurritoRoute
  '/profile': typeof ProfileRoute
  '/api/auth/login': typeof ApiAuthLoginRoute
  '/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRoutesByTo {
  '/': typeof IndexRoute
  '/burrito': typeof BurritoRoute
  '/profile': typeof ProfileRoute
  '/api/auth/login': typeof ApiAuthLoginRoute
  '/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRoutesById {
  __root__: typeof rootRouteImport
  '/': typeof IndexRoute
  '/burrito': typeof BurritoRoute
  '/profile': typeof ProfileRoute
  '/api/auth/login': typeof ApiAuthLoginRoute
  '/api/burrito/consider': typeof ApiBurritoConsiderRoute
}
export interface FileRouteTypes {
  fileRoutesByFullPath: FileRoutesByFullPath
  fullPaths:
    | '/'
    | '/burrito'
    | '/profile'
    | '/api/auth/login'
    | '/api/burrito/consider'
  fileRoutesByTo: FileRoutesByTo
  to:
    | '/'
    | '/burrito'
    | '/profile'
    | '/api/auth/login'
    | '/api/burrito/consider'
  id:
    | '__root__'
    | '/'
    | '/burrito'
    | '/profile'
    | '/api/auth/login'
    | '/api/burrito/consider'
  fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  BurritoRoute: typeof BurritoRoute
  ProfileRoute: typeof ProfileRoute
  ApiAuthLoginRoute: typeof ApiAuthLoginRoute
  ApiBurritoConsiderRoute: typeof ApiBurritoConsiderRoute
}

declare module '@tanstack/react-router' {
  interface FileRoutesByPath {
    '/profile': {
      id: '/profile'
      path: '/profile'
      fullPath: '/profile'
      preLoaderRoute: typeof ProfileRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/burrito': {
      id: '/burrito'
      path: '/burrito'
      fullPath: '/burrito'
      preLoaderRoute: typeof BurritoRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/': {
      id: '/'
      path: '/'
      fullPath: '/'
      preLoaderRoute: typeof IndexRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/api/burrito/consider': {
      id: '/api/burrito/consider'
      path: '/api/burrito/consider'
      fullPath: '/api/burrito/consider'
      preLoaderRoute: typeof ApiBurritoConsiderRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/api/auth/login': {
      id: '/api/auth/login'
      path: '/api/auth/login'
      fullPath: '/api/auth/login'
      preLoaderRoute: typeof ApiAuthLoginRouteImport
      parentRoute: typeof rootRouteImport
    }
  }
}

const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  BurritoRoute: BurritoRoute,
  ProfileRoute: ProfileRoute,
  ApiAuthLoginRoute: ApiAuthLoginRoute,
  ApiBurritoConsiderRoute: ApiBurritoConsiderRoute,
}
export const routeTree = rootRouteImport
  ._addFileChildren(rootRouteChildren)
  ._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
  interface Register {
    ssr: true
    router: Awaited<ReturnType<typeof getRouter>>
  }
}

src/utils/posthog-server.ts

import { PostHog } from 'posthog-node'

let posthogClient: PostHog | null = null

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(
      process.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN || import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN!,
      {
        host: process.env.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
        flushAt: 1,
        flushInterval: 0,
      },
    )
  }
  return posthogClient
}


vite.config.ts

import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'

const config = defineConfig({
  plugins: [
    // this is the plugin that enables path aliases
    viteTsConfigPaths({
      projects: ['./tsconfig.json'],
    }),
    tanstackStart(),
    viteReact(),
  ],
  server: {
    proxy: {
      '/ingest/static': {
        target: 'https://us-assets.i.posthog.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/ingest/, ''),
        secure: false,
      },
      '/ingest/array': {
        target: 'https://us-assets.i.posthog.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/ingest/, ''),
        secure: false,
      },
      '/ingest': {
        target: 'https://us.i.posthog.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/ingest/, ''),
        secure: false,
      },
    },
  },
})

export default config

references/EXAMPLE-vue-3.md

PostHog vue-3 Example Project

Repository: https://github.com/PostHog/context-mill Path: example-apps/vue-3


README.md

PostHog Vue 3 + Vite example

This is a Vue 3 + Vite example demonstrating PostHog integration with product analytics, session replay, and error tracking.

It uses the posthog-js browser SDK directly and shows how to:

  • Initialize PostHog in a Vue 3 SPA
  • Identify users after login
  • Track custom events from components
  • Capture errors via Vue’s global errorHandler
  • Reset PostHog state on logout

Features

  • Product analytics: Track login and burrito consideration events
  • Session replay: Enabled via posthog-js configuration
  • Error tracking: Global Vue error handler sends exceptions to PostHog
  • Simple auth flow: Demo login + protected routes using Pinia + Vue Router

Getting started

1. Install dependencies
npm install
# or
pnpm install
2. Configure environment variables

Create a .env file in the project root:

VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.i.posthog.com

Get your PostHog project token from your project settings in PostHog.

3. Run the development server
npm run dev
# or
pnpm dev

Open http://localhost:5173 (or whatever Vite prints) in your browser.

Project structure

src/
  main.ts            # Vue app entrypoint, PostHog init + global errorHandler
  router/
    index.ts         # Routes + simple auth guard
  stores/
    auth.ts          # Pinia auth store (login, logout, user state)
  components/
    Header.vue       # Navigation + logout, calls posthog.reset()
  views/
    Home.vue         # Login form, identifies user + captures 'user_logged_in'
    Burrito.vue      # Burrito consideration demo, captures 'burrito_considered'
    Profile.vue      # Profile + error tracking demo (if implemented)
  App.vue            # Root layout

Key integration points

PostHog initialization (src/main.ts)

posthog-js is initialized once when the app boots:

import posthog from 'posthog-js'

posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '', {
  api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
})

app.config.errorHandler = (err) => {
  posthog.captureException(err)
}

This ensures:

  • The SDK is configured with your project key and host
  • The singleton instance is initialized only once and before the app mounts
  • Any uncaught Vue errors are sent to PostHog
User identification (src/views/Home.vue)

After a successful “login”, the app identifies the user and captures a login event:

const success = await authStore.login(username.value, password.value)
if (success) {
  posthog.identify(username.value)
  posthog.capture('user_logged_in')
}

Identification happens only on login, all further requests will automatically use the same distinct ID.

Event tracking (src/views/Burrito.vue)

The burrito page tracks a custom event when a user “considers” the burrito:

posthog.capture('burrito_considered', {
  total_considerations: updatedUser.burritoConsiderations,
  username: updatedUser.username,
})

This shows how to attach useful properties to events (e.g. counts, usernames).

Logout and session reset (src/components/Header.vue)

On logout, both the local auth state and PostHog state are cleared:

authStore.logout()
posthog.reset()
router.push({ name: 'home' })

posthog.reset() clears the current distinct ID and session so the next login starts a fresh identity.

Scripts

# Run dev server
npm run dev

# Type-check, compile, and minify for production
npm run build

# Lint
npm run lint

Learn more


.editorconfig

[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

.env.example

VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
VITE_POSTHOG_HOST=https://us.i.posthog.com

env.d.ts

/// <reference types="vite/client" />

index.html

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

src/App.vue

<script setup lang="ts">
import Header from '@/components/Header.vue'
</script>

<template>
  <Header />
  <main>
    <RouterView />
  </main>
</template>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  line-height: 1.6;
  color: #333;
  background: #f5f5f5;
}

#app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

main {
  flex: 1;
  max-width: 1200px;
  width: 100%;
  margin: 2rem auto;
  padding: 0 1rem;
}

h1 {
  margin-bottom: 1rem;
}

p {
  margin-bottom: 1rem;
}
</style>

src/components/Header.vue

<template>
  <header class="header">
    <div class="header-container">
      <nav>
        <RouterLink to="/">Home</RouterLink>
        <template v-if="authStore.user">
          <RouterLink to="/burrito">Burrito Consideration</RouterLink>
          <RouterLink to="/profile">Profile</RouterLink>
        </template>
      </nav>
      <div class="user-section">
        <template v-if="authStore.user && authStore.user.username">
          <span>Welcome, {{ authStore.user.username }}!</span>
          <button @click="handleLogout" class="btn-logout">
            Logout
          </button>
        </template>
        <template v-else>
          <span>Not logged in</span>
          <button v-if="authStore.user" @click="handleLogout" class="btn-logout">
            Clear Session
          </button>
        </template>
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'

const authStore = useAuthStore()
const router = useRouter()

const handleLogout = () => {
  authStore.logout()
  // IMPORTANT: Reset the PostHog instance to clear the user session
  posthog.reset()
  router.push({ name: 'home' })
}
</script>

<style scoped>
.header {
  background-color: #333;
  color: white;
  padding: 1rem;
}

.header-container {
  max-width: 1200px;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.header nav {
  display: flex;
  gap: 1rem;
}

.header a {
  color: white;
  text-decoration: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  transition: background-color 0.2s;
}

.header a:hover {
  background-color: #555;
  text-decoration: none;
}

.user-section {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.btn-logout {
  background-color: #dc3545;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.btn-logout:hover {
  background-color: #c82333;
}
</style>

src/main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'
import posthog from "posthog-js";

const app = createApp(App);

posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '', {
  api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
  defaults: '2026-01-30',
});

app.use(createPinia())
app.use(router)

app.config.errorHandler = (err, instance, info) => {
  // report error to tracking services
  posthog.captureException(err)
}

app.mount('#app')

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Home from '@/views/Home.vue'
import Burrito from '@/views/Burrito.vue'
import Profile from '@/views/Profile.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/burrito',
      name: 'burrito',
      component: Burrito,
      meta: { requiresAuth: true }
    },
    {
      path: '/profile',
      name: 'profile',
      component: Profile,
      meta: { requiresAuth: true }
    }
  ]
})

router.beforeEach((to, from, next) => {
  const authStore = useAuthStore()
  
  // Check if user exists and has a valid username
  const isValidUser = authStore.user && authStore.user.username
  
  if (to.meta.requiresAuth && !isValidUser) {
    // Clear invalid state
    if (authStore.user && !authStore.user.username) {
      authStore.logout()
    }
    next({ name: 'home' })
  } else {
    next()
  }
})

export default router

src/stores/auth.ts

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  username: string
  burritoConsiderations: number
}

const users = new Map<string, User>()

export const useAuthStore = defineStore('auth', () => {

  const getInitialUser = (): User | null => {
    if (typeof window === 'undefined') return null

    const storedUsername = localStorage.getItem('currentUser')
    if (storedUsername) {
      const existingUser = users.get(storedUsername)
      if (existingUser && existingUser.username) {
        return existingUser
      } else {
        // Clean up invalid state
        localStorage.removeItem('currentUser')
      }
    }
    return null
  }

  const user = ref<User | null>(getInitialUser())

  const isAuthenticated = computed(() => user.value !== null)

  const login = async (username: string, password: string): Promise<boolean> => {
    // Client-side only fake auth - no server calls
    if (!username || !password) {
      return false
    }

    let localUser = users.get(username)
    if (!localUser) {
      localUser = {
        username,
        burritoConsiderations: 0
      }
      users.set(username, localUser)
    }

    user.value = localUser
    localStorage.setItem('currentUser', username)

    return true
  }

  const logout = () => {
    user.value = null
    localStorage.removeItem('currentUser')
  }

  const setUser = (newUser: User) => {
    user.value = newUser
    users.set(newUser.username, newUser)
  }

  return {
    user,
    isAuthenticated,
    login,
    logout,
    setUser
  }
})

src/views/Burrito.vue

<template>
  <div class="container">
    <h1>Burrito consideration zone</h1>
    <p>Take a moment to truly consider the potential of burritos.</p>

    <div style="text-align: center">
      <button @click="handleConsideration" class="btn-burrito">
        I have considered the burrito potential
      </button>

      <p v-if="hasConsidered" class="success">
        Thank you for your consideration! Count: {{ authStore.user?.burritoConsiderations }}
      </p>
    </div>

    <div class="stats">
      <h3>Consideration stats</h3>
      <p>Total considerations: {{ authStore.user?.burritoConsiderations }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'

const authStore = useAuthStore()
const hasConsidered = ref(false)

const handleConsideration = () => {
  if (!authStore.user) return

  // Client-side only - no server calls
  const updatedUser = {
    ...authStore.user,
    burritoConsiderations: authStore.user.burritoConsiderations + 1
  }
  authStore.setUser(updatedUser)
  hasConsidered.value = true
  setTimeout(() => {
    hasConsidered.value = false
  }, 2000)

  // Capture burrito consideration event
  posthog.capture('burrito_considered', {
    total_considerations: updatedUser.burritoConsiderations,
    username: updatedUser.username
  })
}
</script>

<style scoped>
.container {
  padding: 2rem;
  max-width: 600px;
  margin: 0 auto;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.btn-burrito {
  background-color: #28a745;
  color: white;
  border: none;
  padding: 1rem 2rem;
  border-radius: 4px;
  font-size: 18px;
  cursor: pointer;
  margin: 2rem 0;
}

.btn-burrito:hover {
  background-color: #218838;
}

.success {
  color: #28a745;
  margin-top: 0.5rem;
}

.stats {
  background-color: #f8f9fa;
  padding: 1rem;
  border-radius: 4px;
  margin-top: 1rem;
}

h3 {
  margin-top: 1rem;
  margin-bottom: 0.5rem;
}
</style>

src/views/Home.vue

<template>
  <div class="container">
    <template v-if="authStore.user && authStore.user.username">
      <h1>Welcome back, {{ authStore.user.username }}!</h1>
      <p>You are logged in. Feel free to explore:</p>
      <ul>
        <li>Consider the potential of burritos</li>
        <li>View your profile and statistics</li>
      </ul>
    </template>
    <template v-else>
      <h1>Welcome to Burrito Consideration App</h1>
      <p>Please sign in to begin your burrito journey</p>

      <form @submit.prevent="handleSubmit" class="form">
        <div class="form-group">
          <label for="username">Username:</label>
          <input
            type="text"
            id="username"
            v-model="username"
            placeholder="Enter any username"
          />
        </div>

        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            id="password"
            v-model="password"
            placeholder="Enter any password"
          />
        </div>

        <p v-if="error" class="error">{{ error }}</p>

        <button type="submit" class="btn-primary">Sign In</button>
      </form>

      <p class="note">
        Note: This is a demo app. Use any username and password to sign in.
      </p>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import posthog from 'posthog-js'

const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')

// Clean up invalid user state on mount
onMounted(() => {
  if (authStore.user && !authStore.user.username) {
    authStore.logout()
  }
})

const handleSubmit = async () => {
  error.value = ''

  const success = await authStore.login(username.value, password.value)
  if (success) {
    // Identifying the user once on login/sign up is enough.
    posthog.identify(username.value)
    posthog.capture('user_logged_in')
    
    username.value = ''
    password.value = ''
  } else {
    error.value = 'Please provide both username and password'
  }
}
</script>

<style scoped>
.container {
  padding: 2rem;
  max-width: 600px;
  margin: 0 auto;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.form {
  margin-top: 2rem;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.form-group input:focus {
  outline: none;
  border-color: #0070f3;
}

.btn-primary {
  background-color: #0070f3;
  color: white;
  border: none;
  padding: 0.75rem 2rem;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  width: 100%;
  margin-top: 1rem;
}

.btn-primary:hover {
  background-color: #0051cc;
}

.error {
  color: #dc3545;
  margin-top: 0.5rem;
}

.note {
  margin-top: 2rem;
  color: #666;
  font-size: 14px;
  text-align: center;
}

ul {
  margin-top: 1rem;
  padding-left: 1.5rem;
}

li {
  margin-bottom: 0.5rem;
}
</style>

src/views/Profile.vue

<template>
  <div class="container">
    <h1>User Profile</h1>

    <div class="stats">
      <h2>Your Information</h2>
      <p><strong>Username:</strong> {{ authStore.user?.username }}</p>
      <p><strong>Burrito Considerations:</strong> {{ authStore.user?.burritoConsiderations }}</p>
    </div>

    <div style="margin-top: 2rem">
      <h3>Your Burrito Journey</h3>
      <template v-if="authStore.user">
        <p v-if="authStore.user.burritoConsiderations === 0">
          You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
        </p>
        <p v-else-if="authStore.user.burritoConsiderations === 1">
          You've considered the burrito potential once. Keep going!
        </p>
        <p v-else-if="authStore.user.burritoConsiderations < 5">
          You're getting the hang of burrito consideration!
        </p>
        <p v-else-if="authStore.user.burritoConsiderations < 10">
          You're becoming a burrito consideration expert!
        </p>
        <p v-else>
          You are a true burrito consideration master! 🌯
        </p>
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'

const authStore = useAuthStore()
</script>

<style scoped>
.container {
  padding: 2rem;
  max-width: 600px;
  margin: 0 auto;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.stats {
  background-color: #f8f9fa;
  padding: 1rem;
  border-radius: 4px;
  margin-top: 1rem;
}

h2 {
  margin-top: 1rem;
  margin-bottom: 0.5rem;
}

h3 {
  margin-top: 1rem;
  margin-bottom: 0.5rem;
}
</style>

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

references/android.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Android

It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.

Installation

The best way to install the PostHog Android library is with a build system like Gradle. This ensures you can easily upgrade to the latest versions.

All you need to do is add the posthog-android module to your App's build.gradle or build.gradle.kts:

app/build.gradle
dependencies {
  implementation 'com.posthog:posthog-android:3.+'
}
app/build.gradle.kts
dependencies {
  implementation("com.posthog:posthog-android:3.+")
}
Configuration

The best place to initialize the client is in your Application subclass.

Kotlin

import android.app.Application
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig

class SampleApp : Application() {

    companion object {
        const val POSTHOG_API_KEY = "<ph_project_token>"
        // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
        const val POSTHOG_HOST = "https://us.i.posthog.com"
    }

    override fun onCreate() {
        super.onCreate()

        val config = PostHogAndroidConfig(
            apiKey = POSTHOG_API_KEY,
            host = POSTHOG_HOST
        )
        PostHogAndroid.setup(this, config)
    }
}

Capturing events

You can send custom events using capture:

Kotlin

import com.posthog.PostHog

PostHog.capture(event = "user_signed_up")

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Kotlin

import com.posthog.PostHog

PostHog.capture(
    event = "user_signed_up",
    properties = mapOf(
        "login_type" to "email",
        "is_free_trial" to true
    )
)
Autocapture

PostHog autocapture automatically tracks the following events for you:

  • Application Opened - when the app is opened from a closed state or when the app comes to the foreground. (e.g. from the app switcher)
  • Deep Link Opened - when the app is opened from a deep link.
  • Application Backgrounded - when the app is sent to the background by the user.
  • Application Installed - when the app is installed.
  • Application Updated - when the app is updated.
  • $screen - when the user navigates. (if using android.app.Activity)
  • $exception - when uncaught exception autocapture is enabled. To use this, enable Android error tracking (/docs/error-tracking/installation/android.md) and exception autocapture in the SDK config.
Capturing screen views

With captureScreenViews = true (/docs/libraries/android.md#all-configuration-options), PostHog will try to record all screen changes automatically.

The screenTitle will be the <activity>'s android:label, if not set it'll fallback to the <application>'s android:label or the <activity>'s android:name.

XML

<activity
    android:name="com.example.app.ChildActivity"
    android:label="@string/title_child_activity"
    ...
</activity>

If you want to manually send a new screen capture event, use the screen function.

This function requires a screenTitle. You may also pass in an optional properties object.

Kotlin

import com.posthog.PostHog

PostHog.screen(
    screenTitle = "Dashboard",
    properties = mapOf(
        "background" to "blue",
        "hero" to "superhog"
    )
)

Identifying users

We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.

Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.

An identify call has the following arguments:

  • distinctId: Required. A unique identifier for your user. Typically either their email or database ID.
  • userProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
  • userPropertiesSetOnce: Optional. Similar to userProperties. See the difference between userProperties and userPropertiesSetOnce (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)

Kotlin

import com.posthog.PostHog

PostHog.identify(
    distinctId = distinctID,
    userProperties = mapOf(
        "name" to "Max Hedgehog",
        "email" to "max@hedgehogmail.com"
    ),
    userPropertiesSetOnce = mapOf(
        "date_of_first_log_in" to "2024-03-01"
    ),
)

You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.

When you call identify, all previously tracked anonymous events will be linked to the user.

Get the current user's distinct ID

You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.

To do this, call distinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().

Tracing headers

Use tracingHeaders to connect Android network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK. Tracing headers are added by the PostHogOkHttpInterceptor, so install the interceptor on each OkHttpClient whose requests should include PostHog context.

Kotlin

import com.posthog.PostHogOkHttpInterceptor
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
import okhttp3.OkHttpClient

val config = PostHogAndroidConfig(
    apiKey = POSTHOG_API_KEY,
    host = POSTHOG_HOST,
).apply {
    tracingHeaders = listOf("api.example.com")
}

PostHogAndroid.setup(this, config)

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(PostHogOkHttpInterceptor())
    .build()

Hostnames are matched exactly and should not include protocols, paths, ports, or wildcard subdomains. Matching OkHttp requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID when those values are available.

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Kotlin

/**
 * Create an alias for the current user.
 */
PostHog.alias("distinct_id")

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Anonymous and identified events

PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)

Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.

Scenarios where you want to capture identified events are:

  • Tracking logged-in users in B2B and B2C SaaS apps
  • Doing user segmented product analysis
  • Growth and marketing teams wanting to analyze the complete conversion lifecycle

Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.

Scenarios where you want to capture anonymous events are:

  • Tracking a marketing website
  • Content-focused sites
  • B2C apps where users don't sign up or log in

Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.

Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.

How to capture anonymous events

The Android SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/android.md#all-configuration-options) when initializing PostHog:

  1. personProfiles = PersonProfiles.IDENTIFIED_ONLY (recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.

  2. personProfiles = PersonProfiles.ALWAYS - Capture identified events for all events.

  3. personProfiles = PersonProfiles.NEVER - Capture anonymous events for all events.

For example:

Kotlin

val config = PostHogAndroidConfig(
   apiKey = POSTHOG_API_KEY,
   host = POSTHOG_HOST,
).apply {
   personProfiles = PersonProfiles.IDENTIFIED_ONLY
}
How to capture identified events

If you've set the personProfiles config (/docs/libraries/android.md#all-configuration-options) to IDENTIFIED_ONLY (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:

  • identify() (/docs/product-analytics/identify.md)
  • alias() (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)
  • group() (/docs/product-analytics/group-analytics.md)

When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.

Alternatively, you can set personProfiles to ALWAYS to capture identified events by default.

Setting person properties

To set properties (/docs/product-analytics/person-properties.md) on your users via an event, you can leverage the event properties userProperties and userPropertiesSetOnce.

When capturing an event, you can pass a property called userProperties as an event property, and specify its value to be an object with properties to be set on the user that will be associated with the user who triggered the event.

Kotlin

import com.posthog.PostHog

PostHog.capture(
    event = "button_b_clicked",
    properties = mapOf("color" to "blue"),
    userProperties = mapOf(
        "string" to "value1",
        "integer" to 2
    )
)

userPropertiesSetOnce works just like userProperties, except that it will only set the property if the user doesn't already have that property set.

Kotlin

import com.posthog.PostHog

PostHog.capture(
    event = "button_b_clicked",
    properties = mapOf("color" to "blue"),
    userPropertiesSetOnce = mapOf(
        "string" to "value1",
        "integer" to 2
    )
)

Super Properties

Super Properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.

They are set using PostHog.register, which takes a key and value, and they persist across sessions.

For example, take a look at the following call:

Kotlin

import com.posthog.PostHog

PostHog.register("team_id", 22)

The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the PostHog.register call, since they all include the specified Super Property.

However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use PostHog.identify. More information on this can be found on the Sending User Information section (#sending-user-information).

Removing stored Super Properties

Super Properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a Super Property with events, you can use PostHog.unregister, like so:

Kotlin

import com.posthog.PostHog

PostHog.unregister("team_id")

This will remove the Super Property and subsequent events will not include it.

If you are doing this as part of a user logging out you can instead simply use PostHog.reset which takes care of clearing all stored Super Properties and more.

Opt out of data capture

You can completely opt-out users from data capture. To do this, there are two options:

  1. Opt users out by default by setting optOut to true in your PostHog config:

Kotlin

val config = PostHogAndroidConfig(
    apiKey = "<ph_project_token>",
    host = "https://us.i.posthog.com"
)
config.optOut = true
PostHogAndroid.setup(this, config)
  1. Opt users out on a per-person basis by calling optOut():

Kotlin

PostHog.optOut()

Similarly, you can opt users in:

Kotlin

PostHog.optIn()

To check if a user is opted out:

Kotlin

PostHog.isOptOut()

Flush

You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.

You can also configure the flush interval with flushIntervalSeconds (default 30), after which queued events are sent regardless of how many have been gathered:

Kotlin

import com.posthog.android.PostHogAndroidConfig

val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST).apply {
    flushAt = 20
    flushIntervalSeconds = 30
}

You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:

Kotlin

import com.posthog.PostHog

PostHog.flush()

Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.

Reset after logout

To reset the user's ID and anonymous ID, call reset. Usually you would do this right after the user logs out.

Kotlin

import com.posthog.PostHog

PostHog.reset()

Feature Flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

Boolean feature flags

Kotlin

import com.posthog.PostHog

val result = PostHog.getFeatureFlagResult("flag-key")
if (result?.enabled == true) {
    // Do something differently for this user

    // Optional: fetch the payload from the same evaluation result
    val matchedFlagPayload = result.payload
}
Multivariate feature flags

Kotlin

import com.posthog.PostHog

val result = PostHog.getFeatureFlagResult("flag-key")
if (result?.variant == "variant-key") { // replace "variant-key" with the key of your variant
    // Do something differently for this user

    // Optional: fetch the payload from the same evaluation result
    val matchedFlagPayload = result.payload
}
Inspecting all feature flags

You can inspect all currently loaded feature flags with PostHog.getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:

Kotlin

import com.posthog.PostHog

PostHog.getAllFeatureFlags()?.forEach { flag ->
    println("${flag.key} ${flag.enabled} ${flag.variant} ${flag.payload}")
}
Ensuring flags are loaded before usage

Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.

This means that for most screens, the feature flags are available immediately – except for the first time a user visits.

To handle this, you can use the onFeatureFlags callback to wait for the feature flag request to finish:

Kotlin

import com.posthog.PostHog
import com.posthog.android.PostHogAndroidConfig
import com.posthog.PostHogOnFeatureFlags

// During SDK initialization
val config = PostHogAndroidConfig(apiKey = "<ph_project_token>").apply {
    onFeatureFlags = PostHogOnFeatureFlags {
        if (PostHog.isFeatureEnabled("flag-key")) {
            // do something
        }
    }
}

// And/or after the SDK is initialized
PostHog.reloadFeatureFlags {
    if (PostHog.isFeatureEnabled("flag-key")) {
        // do something
    }
}
Reloading feature flags

Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:

Kotlin

import com.posthog.PostHog

PostHog.reloadFeatureFlags()
Tracking feature usage

To track when someone sees or interacts with a feature, use captureFeatureView and captureFeatureInteraction.

Kotlin

import com.posthog.PostHog

PostHog.captureFeatureView("flag-key", flagVariant = "variant-key")
PostHog.captureFeatureInteraction("flag-key", flagVariant = "variant-key")
Bootstrapping flags

Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.

To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.

Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires Android SDK 3.55.0+):

Kotlin

import com.posthog.PostHogBootstrapConfig

val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST)
config.bootstrap = PostHogBootstrapConfig(
    distinctId = "distinct_id_of_your_user",
    isIdentifiedId = true,
    featureFlags = mapOf(
        "flag-1" to true,
        "variant-flag" to "control"
    )
)
PostHogAndroid.setup(this, config)
  • Bootstrapped identity applies during setup. On a fresh install, setting it before setup() means events captured synchronously during initialization (like Application Installed) carry your distinct ID instead of the SDK-generated UUID.
    • An anonymous bootstrap (isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it.
    • An identified bootstrap (isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting $identify; a different anonymous ID is merged via identify() when person profiles are enabled. This emits $identify unless capturing is opted out. A different, already-identified person is left untouched.
  • Bootstrapped flags are served until the first /flags response, then replaced. A complete /flags response takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: a true boolean or a non-empty variant string. A false or empty value is dropped, matching posthog-js. Seed payloads with the separate featureFlagPayloads option. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared on reset().

The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.

See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:

Kotlin

import com.posthog.PostHog

if (PostHog.getFeatureFlag("experiment-feature-flag-key") == "variant-name") {
    // do something
}

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Group analytics

Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

  • Associate the events for this session with a group

Kotlin

import com.posthog.PostHog

// organization is the group type, company_id_in_your_db is the group ID
PostHog.group(
    type = "company",
    key = "company_id_in_your_db"
)
  • Associate the events for this session with a group AND update the properties of that group

Kotlin

import com.posthog.PostHog

PostHog.group(
    type = "company",
    key = "company_id_in_your_db",
    groupProperties = mapOf("name" to "Awesome Inc.")
)

The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.

Error tracking

To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).

Logs

To set up logs (/docs/logs.md) in your Android app, follow the Android logs installation guide (/docs/logs/installation/android.md). The SDK exposes PostHog.logger.{trace,debug,info,warn,error,fatal} for sending structured records to PostHog Logs, with batching, offline persistence, and a rate cap built in.

Minimum version: com.posthog:posthog-android@3.46.0 or later.

Session replay

To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the Android SDK, enable "Record user sessions" in your project settings and enable the sessionReplay option.

Surveys

To set up surveys, follow the additional installation instructions for Android (/docs/surveys/installation/android.md). Surveys launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.

Offline behavior

The PostHog Android SDK will continue to capture events when the device is offline. The events are stored in a queue in the device's file storage and are flushed when the device is online.

  • The queue has a maximum size defined by maxQueueSize in the configuration.
  • When the queue is full, the oldest event is deleted first.
  • The queue is flushed when the app is restarted and the device is online.
  • When you call flush() (#flush) while the device is offline, it aborts early and the events are not flushed.

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, surveys being shown, or session replay/error tracking behavior, you can enable debug mode to see what's happening.

You can enable debug mode by setting the debug option to true in the PostHogAndroidConfig object. This will enable verbose logs about the inner workings of the SDK.

Kotlin

val config = PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST).apply {
    debug = true
    // ... other config options
}

All configuration options

When creating the PostHog client, pass a PostHogAndroidConfig. It inherits the core PostHogConfig options and adds Android-specific options.

Kotlin

import com.posthog.PersonProfiles
import com.posthog.android.PostHogAndroidConfig

val config = PostHogAndroidConfig(
    apiKey = POSTHOG_API_KEY,
    host = POSTHOG_HOST
).apply {
    captureApplicationLifecycleEvents = true
    captureScreenViews = true
    captureDeepLinks = true

    flushAt = 20
    maxQueueSize = 1000
    maxBatchSize = 50
    maxRetries = 3
    flushIntervalSeconds = 30

    debug = false
    optOut = false

    sendFeatureFlagEvent = true
    featureFlagCalledCacheSize = 1000
    preloadFeatureFlags = true
    evaluationContexts = listOf("production", "android", "mobile")

    setDefaultPersonProperties = true
    personProfiles = PersonProfiles.IDENTIFIED_ONLY
    reuseAnonymousId = false

    sessionReplay = false
    errorTrackingConfig.autoCapture = false
}
Android-specific options
Option Default Description
captureApplicationLifecycleEvents true Captures Application Installed, Application Updated, Application Opened, and Application Backgrounded.
captureScreenViews true Captures $screen for foreground android.app.Activity screens.
captureDeepLinks true Captures Deep Link Opened with URL/query/referrer properties.
Core options
Option Default Description
debug false Enables verbose SDK logs in Logcat. You can also call PostHog.debug(true).
optOut false Prevents data capture when enabled. You can also call PostHog.optOut() and PostHog.optIn().
flushAt 20 Number of queued events that triggers a flush.
maxQueueSize 1000 Maximum number of events kept across memory and disk before FIFO eviction.
maxBatchSize 50 Maximum number of events sent in one batch request.
maxRetries 3 Maximum retry attempts for failed requests.
flushIntervalSeconds 30 Maximum delay before queued data is flushed.
encryption null Optional PostHogEncryption implementation for encrypting persisted queued events.
proxy null Optional java.net.Proxy for PostHog API requests.
getAnonymousId generated UUID Optional hook to customize anonymous ID generation.
reuseAnonymousId false Reuses one anonymous ID across user changes on the same device.
personProfiles PersonProfiles.IDENTIFIED_ONLY Controls when person profiles are processed: IDENTIFIED_ONLY, ALWAYS, or NEVER.
setDefaultPersonProperties true Includes default device and app properties in feature flag evaluation requests.
releaseIdentifier app/version fallback Release identifier used by error tracking and uploaded ProGuard/R8 mappings. The Android Gradle plugin can inject this automatically.
tracingHeaders null Exact hostnames that should receive PostHog tracing headers when using PostHogOkHttpInterceptor.
Feature flag options
Option Default Description
sendFeatureFlagEvent true Sends $feature_flag_called when a feature flag is evaluated.
featureFlagCalledCacheSize 1000 Number of feature flag calls cached for deduplicating $feature_flag_called events.
preloadFeatureFlags true Fetches feature flags automatically during setup.
evaluationContexts null Context tags that constrain which feature flags are evaluated. Available in version 3.29.1+. The legacy evaluationEnvironments option is available in version 3.24.0+.
onFeatureFlags null Callback invoked when feature flags are loaded.
Product configuration objects
Option Default Description
sessionReplay false Enables session replay when project settings also allow recording.
sessionReplayConfig PostHogSessionReplayConfig() Configures masking, screenshots, Logcat capture, sampling, and custom drawable conversion.
logs PostHogLogsConfig() Configures Android logs (/docs/logs/installation/android.md).
errorTrackingConfig PostHogErrorTrackingConfig() Configures error tracking. autoCapture defaults to false; set it to true to autocapture uncaught exceptions when project settings also enable error tracking.
surveys false Internal/experimental native Android survey support. Native Android survey UI is not fully supported or documented yet.
surveysConfig PostHogSurveysConfig() Internal/experimental survey display delegate configuration, primarily for hybrid SDKs.
bootstrap null Seeds identity (distinctId, isIdentifiedId) and feature-flag state (featureFlags, featureFlagPayloads) before the first /flags response. Bootstrapped identity applies to the first session; only enabled flags are served, until the first /flags response replaces them. See SDK bootstrapping (/docs/libraries/bootstrapping.md#behavior-on-mobile-sdks).
Event filtering with beforeSend

Use addBeforeSend to redact, modify, or drop events before they are queued. Return null to drop an event.

Kotlin

config.addBeforeSend { event ->
    event.properties?.remove("password")

    if (event.event == "internal_debug_event") {
        null
    } else {
        event
    }
}
Filtering autocaptured screens

You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.

Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.

Kotlin

val ignoredScreens = setOf("Splash", "Debug")

config.addBeforeSend { event ->
    val screenName = event.properties?.get("$screen_name") as? String
    if (event.event == "$screen" && screenName in ignoredScreens) {
        null
    } else {
        event
    }
}

Push notifications

The Android SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).

FAQ

What Android API level is required?

The Android SDK supports Android API 23 and newer.

Do I need to declare permissions in the AndroidManifest.xml?

Usually, no. The SDK declares android.permission.INTERNET and android.permission.ACCESS_NETWORK_STATE, and Android's manifest merger adds them to your app. The SDK does not declare or require an Android Service.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/angular.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Angular

PostHog makes it easy to get data about traffic and usage of your Angular app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.

This guide walks you through integrating PostHog into your Angular app using the JavaScript Web SDK (/docs/libraries/js.md).

Installation

Install posthog-js using your package manager:

npm
npm install --save posthog-js
Yarn
yarn add posthog-js
pnpm
pnpm add posthog-js
Bun
bun add posthog-js

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Initialize the PostHog client

Generate environment files for your project with ng g environments. Configure the following environment variables:

  • posthogKey: Your project token from your project settings.
  • posthogHost: Your project's client API host. Usually https://us.i.posthog.com for US-based projects and https://eu.i.posthog.com for EU-based projects.

Angular v17+

For Angular v17 and above, you can set up PostHog as a singleton service. To do this, start by creating and injecting a PosthogService instance.

Create a service by running ng g service services/posthog. The service should look like this:

posthog.service.ts

// src/app/services/posthog.service.ts
import { Injectable, NgZone } from "@angular/core";
import posthog from "posthog-js";
import { environment } from "../../environments/environment";

@Injectable({ providedIn: "root" })
export class PosthogService {
  constructor(
    private ngZone: NgZone,
  ) {
    this.initPostHog();
  }
  private initPostHog() {
    this.ngZone.runOutsideAngular(() => {
      posthog.init(environment.posthogKey, {
        api_host: environment.posthogHost,
        defaults: '2026-05-30',
      });
    });
  }
}

The service is initialized outside of the Angular zone to reduce change detection cycles. This is important to avoid performance issues with session recording.

Then, inject the service in your app's root component app.component.ts. This will make sure PostHog is initialized before any other component is rendered.

app.component.ts

// src/app/app.component.ts
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { PosthogService } from "./services/posthog.service";

@Component({
  selector: "app-root",
  styleUrls: ["./app.component.scss"],
  template: `
    <router-outlet />`,
  imports: [RouterOutlet],
})
export class AppComponent {
  title = "angular-app";

  constructor(posthogService: PosthogService) {}
}

Angular v16 and below

In your src/main.ts, initialize PostHog using your project token and instance address. You can find both in your project settings.

main.ts

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { environment } from "./environments/environment";
import posthog from 'posthog-js'

posthog.init(environment.posthogKey, {
  api_host: environment.posthogHost,
  defaults: '2026-05-30'
})

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Note: If you're using Typescript, you might have some trouble getting your types to compile because we depend on rrweb but don't ship all of their types. To accommodate that, you'll need to add @rrweb/types@2.0.0-alpha.17 and rrweb-snapshot@2.0.0-alpha.17 as a dependency if you want your Angular compiler to typecheck correctly.

Given the nature of this library, you might need to completely clear your .npm cache to get this to work as expected. Make sure your clear your CI's cache as well.

In the rare case the versions above get out-of-date, you can check our JavaScript SDK's package.json to understand what's the exact version you need to depend on.

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Tracking pageviews

PostHog automatically tracks your pageviews by hooking up to the browser's navigator API as long as you initialize PostHog with the defaults config option set after 2026-01-30.

Capture custom events

To capture custom events (/docs/product-analytics/capture-events.md), import posthog and call posthog.capture(). Below is an example of how to do this in a component:

app.component.ts

import { Component } from '@angular/core';
import posthog from 'posthog-js'

@Component({
 // existing component code
})

export class AppComponent {
  handleClick() {
    posthog.capture(
      'home_button_clicked',
    )
  }
}

Session replay

Session replay uses change detection to record the DOM. This can clash with Angular's change detection.

The recorder tool attempts to detect when an Angular zone is present and avoid the clash but might not always succeed.

  • If you followed the installation instructions for Angular v17 and above, you don't need to do anything.
  • If you followed the installation instructions for Angular v16 and below and you see performance impact from recording in an Angular project, ensure that you use ngZone.runOutsideAngular.

posthog.service.ts

import { Injectable } from '@angular/core';
import posthog from 'posthog-js'

@Injectable({ providedIn: 'root' })
export class PostHogSessionRecordingService {
  constructor(private ngZone: NgZone) {}
initPostHog() {
    this.ngZone.runOutsideAngular(() => {
      posthog.init(
        /* your config */
      )
    })
  }
}

Angular with SSR

To use PostHog with Angular server-side rendering (SSR), you need to:

  1. Update the PostHog web JS client to only initialize on the client-side.
  2. Initialize PostHog Node on the server-side.
1. Update the PostHog web JS client

Update your posthog.service.ts to restrict the initialization of the PostHog web JS client to the client-side. The web SDK uses methods that are not available on the server side, so we need to check if we're on the client side before initializing PostHog.

posthog.service.ts

import { PLATFORM_ID } from "@angular/core";

@Injectable({ providedIn: "root" })
export class PosthogService {
  constructor(
    private ngZone: NgZone,
    @Inject(PLATFORM_ID) private platformId: Object
  ) {
    // Only initialize PostHog in browser environment
    if (isPlatformBrowser(this.platformId)) {
      this.initPostHog(); //+
    }
  }

  private initPostHog() {
    this.ngZone.runOutsideAngular(() => {
      posthog.init(environment.posthogKey, {
2. Add server-side initialization

Angular SSR uses a server.ts file to handle requests. We can add any server-side initialization code to this file.

First, install the posthog-node package to run on the server side.

npm
npm install posthog-node --save
Yarn
yarn add posthog-node
pnpm
pnpm add posthog-node
Bun
bun add posthog-node

Then, add the following code to the server.ts file:

server.ts

// src/server.ts

import { environment } from './environments/environment';
import { PostHog } from 'posthog-node'

/**
 * Extract distinct ID from PostHog cookie
 */
function getDistinctIdFromCookie(cookieHeader: string | undefined): string | null {
  if (!cookieHeader) return null;

  const cookieMatch = cookieHeader.match(`ph_${environment.posthogKey}_posthog=([^;]+)`);
  if (cookieMatch) {
    try {
      const parsed = JSON.parse(decodeURIComponent(cookieMatch[1]));
      return parsed?.distinct_id || null;
    } catch (error) {
      console.error('Error parsing PostHog cookie:', error);
      return null;
    }
  }
  return null;
}

/**
 * Handle all other requests by rendering the Angular application.
 */
app.get('**', async (req, res, next) => {
  const { protocol, originalUrl, baseUrl, headers } = req;

  const distinctId = getDistinctIdFromCookie(headers.cookie);
  let isFeatureEnabled = false;

  const client = new PostHog(
      environment.posthogKey,
      { host: environment.posthogHost }
  );

  if (distinctId) {
    client.capture({
      distinctId: distinctId,
      event: 'test_ssr_event',
      properties: {
        message: 'Hello from Angular SSR!'
      }
    })

    isFeatureEnabled = await client.isFeatureEnabled(
      'your_feature_flag_key', distinctId) || false;
  }

  commonEngine
    .render({
      bootstrap,
      documentFilePath: indexHtml,
      url: `${protocol}://${headers.host}${originalUrl}`,
      publicPath: browserDistFolder,
      providers: [
        { provide: APP_BASE_HREF, useValue: baseUrl },
        { provide: 'FEATURE_FLAG_ENABLED', useValue: isFeatureEnabled }
      ],
    })
    .then((html) => res.send(html))
    .catch((err) => next(err));

  await client.shutdown()
});

This code does the following:

  • Extracts the distinct ID from the cookie header. This is set by the web JS client.
  • Captures an event on the server side.
  • Evaluates a feature flag on the server side. This can be passed as a provider to the Angular application.
  • Calls shutdown on the PostHog Node client to ensure all events are flushed.

Using PostHog in server-side code

Angular SSR does not allow Node.js code to be bundled into client-side components. Even though resolvers and other server-side code can be written along with client-side components, you cannot use PostHog Node in those components.

Next steps

For any technical questions for how to integrate specific PostHog features into Angular (such as feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web SDK docs (/docs/libraries/js/usage.md).

Alternatively, the following tutorials can help you get started:

  • How to set up Angular analytics, feature flags, and more (/tutorials/angular-analytics.md)
  • How to set up A/B tests in Angular (/tutorials/angular-ab-tests.md)
  • How to set up surveys in Angular (/tutorials/angular-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/astro.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Astro

PostHog makes it easy to get data about traffic and usage of your Astro app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.

This guide walks you through integrating PostHog into your Astro app using the JavaScript Web SDK (/docs/libraries/js.md).

Beta: integration via LLM

Install PostHog for Astro in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.

npx @posthog/wizard

Learn more (/wizard.md)

Or, to integrate manually, continue with the rest of this guide.

Installation

In your src/components folder, create a posthog.astro file:

Terminal

cd ./src/components
# or 'cd ./src && mkdir components && cd ./components' if your components folder doesnt exist
touch posthog.astro

In this file, add your Web snippet which you can find in your project settings. Be sure to include the is:inline directive to prevent Astro from processing it, or you will get Typescript and build errors that property 'posthog' does not exist on type 'Window & typeof globalThis'.

posthog.astro

---
// src/components/posthog.astro
---
<script is:inline>
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
  posthog.init('<ph_project_token>', {
    api_host:'https://us.i.posthog.com',
    defaults: '2026-05-30'
  })
</script>

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Using with Astro's view transitions (ClientRouter)

If you've opted in to Astro's <ClientRouter> component for client-side navigation, you'll need to add an initialization guard to prevent PostHog from running multiple times during page transitions.

Update your posthog.astro file to wrap the snippet with a check:

posthog.astro

---
// src/components/posthog.astro
---
<script is:inline>
  if (!window.__posthog_initialized) {
    window.__posthog_initialized = true;

    !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);

    posthog.init('<ph_project_token>', {
      api_host: 'https://us.i.posthog.com',
      defaults: '2026-05-30',
      capture_pageview: 'history_change'
    })
  }
</script>

Without this guard, ClientRouter's soft navigation can re-execute the inline script during page transitions, causing a stack overflow error. The capture_pageview: 'history_change' option ensures pageviews are tracked automatically as users navigate.

The next step is to a create a Layout where we will use posthog.astro. Create a new file PostHogLayout.astro in your src/layouts folder:

Terminal

cd .. && cd .. # move back to your base directory if you're still in src/components/posthog.astro
cd ./src/layouts
# or 'cd ./src && mkdir layouts && cd ./layouts' if your layouts folder doesn't exist yet
touch PostHogLayout.astro

Add the following code to PostHogLayout.astro:

PostHogLayout.astro

---
import PostHog from '../components/posthog.astro'
---
<head>
    <PostHog />
</head>

Lastly, update index.astro to wrap your existing app components with the new Layout:

index.astro

---
import PostHogLayout from '../layouts/PostHogLayout.astro';
---
<PostHogLayout>
  <!-- your existing app components -->
</PostHogLayout>

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Next steps

For any technical questions for how to integrate specific PostHog features into Astro (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web SDK docs (/docs/libraries/js/usage.md).

Alternatively, the following tutorials can help you get started:

  • How to set up Astro analytics, feature flags, and more (/tutorials/astro-analytics.md)
  • How to set up A/B tests in Astro (/tutorials/astro-ab-tests.md)
  • How to set up surveys in Astro (/tutorials/astro-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/configuration.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

iOS SDK configuration

Autocapture configuration

You can enable or disable autocapture through the PostHogConfig object.

Tracing headers

Use tracingHeaders to connect iOS network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK:

Swift

let configuration = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
configuration.tracingHeaders = ["api.example.com"]
PostHogSDK.shared.setup(configuration)

Hostnames are matched exactly and should not include protocols, paths, ports, or wildcard subdomains. Matching URLSession requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID when those values are available.

Tracing headers require method swizzling, so configuration.enableSwizzling must remain true.

Flush configuration

The iOS SDK uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.

You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.

You can also configure the flush interval with flushIntervalSeconds (default 30), after which queued events are sent regardless of how many have been gathered:

Swift

configuration.flushAt = 1
configuration.flushIntervalSeconds = 30

You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:

Swift

PostHogSDK.shared.capture("logged_out")
PostHogSDK.shared.flush()

Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.

Amending, dropping or sampling events

Since version 3.28.0, you can provide a BeforeSendBlock function when initializing the SDK to amend, drop or sample events before they are sent to PostHog.

⚠️ Note: This replaces the deprecated propertiesSanitizer option and provides more flexibility in modifying events. You can achieve the same functionality as propertiesSanitizer by using a BeforeSendBlock that mutates the event's properties in place.

🚨 Warning: Amending and sampling events is advanced functionality that requires careful implementation. Core PostHog features may require 100% of unmodified events to function properly. We recommend only modifying or sampling your own custom events if possible, and preserving all PostHog internal events in their original form.

Redacting information in events

BeforeSendBlock gives you one place to edit or redact information before it is sent to PostHog. For example:

Redact URLs in event properties

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

config.setBeforeSend { event in
    // Redact URLs
    if let url = event.properties["url"] as? String {
        event.properties["url"] = url.map { _ in "*" }.joined()
    }
    return event
}

Redact sensitive information from event properties

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

config.setBeforeSend { event in
    // Redact sensitive information
    if let email = event.properties["email"] as? String {
        event.properties["email"] = email.map { _ in "*" }.joined()
    }

    return event
}

Drop events by event name

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

config.setBeforeSend { event in
    // Drop all events named "Stale Event"
    if event.event == "Stale Event" {
        return nil
    }
    return event
}

Filter autocaptured screen views

You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.

Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

let ignoredScreens: Set<String> = ["Splash", "Debug"]

config.setBeforeSend { event in
    if event.event == "$screen",
       let screenName = event.properties["$screen_name"] as? String,
       ignoredScreens.contains(screenName) {
        return nil
    }
    return event
}
Sampling events

Sampling lets you choose to send only a percentage of events to PostHog. It is a good way to control your costs without having to completely turn off features of the SDK.

Sample events by event name

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

config.setBeforeSend { event in
    // Sample 10% of Sampled Event events
    if event.event == "Sampled Event" {
        if Double.random(in: 0...1) < 0.1 {
            event.properties["$sample_type"] = ["sampleByEvent"]
            event.properties["$sample_threshold"] = 0.1
            event.properties["$sampled_events"] = ["Sampled Event"]
            return event
        }
        return nil
    }
    return event
}
Chaining multiple BeforeSendBlocks

You can provide an array of BeforeSendBlock functions to be called one after the other:

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")

config.setBeforeSend(
    // First block: Drop all events named "Stale Event"
    { event in
        if event.event == "Stale Event" {
            return nil
        }
        return event
    },
    // Second block: Redact sensitive information
    { event in
        if let email = event.properties["email"] as? String {
            event.properties["email"] = email.map { _ in "*" }.joined()
        }
        return event
    }
)

Note: When chaining beforeSend blocks, order is important. The first block is executed first and the mutated event is passed along to the second block, and so on. If at any point in the chain the event is dropped, any subsequent blocks will not be executed.

Setting up app groups

  1. Configure App Groups: Set up an App Group in Xcode for your main app and extension targets
  2. Configure PostHog: Use the same App Group identifier in all targets:

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.appGroupIdentifier = "group.com.yourcompany.yourapp"
PostHogSDK.shared.setup(config)

Method swizzling

Method swizzling is a technique that enables the SDK to intercept and modify method calls at runtime to provide advanced features like screen view tracking, element interactions, session replay, surveys, and more.

Method swizzling is enabled by default, but can be disabled by setting the relevant config option to false in the PostHogConfig object:

Feature Description Config option
Screen view tracking Automatically captures when view controllers are presented config.captureScreenViews
Element interactions Automatically tracks user interactions with UI elements config.captureElementInteractions
Rage clicks Automatically captures $rageclick events for rapid repeated taps in the same area (iOS/macCatalyst, UIKit) config.rageClickConfig.enabled
Session replay Records user sessions config.sessionReplay
Surveys Displays surveys at appropriate times config.surveys
Advanced metrics tracking Provides more precise session ID calculation and rotation by detecting user activity and idleness N/A
Disabling all method swizzling

Since version 3.34.0, you can opt out of all swizzling using the enableSwizzling configuration option. When you disable swizzling, the SDK disables the features listed above.

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "<ph_api_client_host>")
config.enableSwizzling = false
PostHogSDK.shared.setup(config)

Note: When method swizzling is disabled, features that depend on it will not work even if they are individually enabled in the config. For example, if you set config.sessionReplay = true and config.enableSwizzling = false, session replay will not be enabled.

Session metrics management

Method swizzling is particularly important for accurate session metrics tracking (/tutorials/session-metrics.md). With swizzling enabled, the SDK can better detect user activity and idle times to provide a better session rotation.

With swizzling disabled, the SDK only uses application open/backgrounded events to detect user activity, which can lead to a sub-optimal session calculation.

Custom keyboard extensions

Custom keyboard extensions have stricter security rules than other extension types. To use PostHog in a custom keyboard, the keyboard must have Open Access permission enabled. This permission is required for network requests and write access to shared containers.

Users must explicitly grant Open Access in Settings > General > Keyboard > Keyboards > [Your Keyboard] > Allow Full Access.

All configuration options

The PostHogConfig object contains several other settings you can toggle:

Attribute Description
flushAt Type: Integer Default: 20 (5 on tvOS) The number of queued events that the posthog client should flush at. Setting this to 1 will not queue any events and will use more battery.
flushIntervalSeconds Type: TimeInterval Default: 30 The amount of time to wait before each tick of the flush timer, in seconds. Smaller values will make events delivered in a more real-time manner and also use more battery. A value smaller than 10 seconds will seriously degrade overall performance.
maxQueueSize Type: Integer Default: 1000 (100 on tvOS) The maximum number of items to queue before starting to drop old ones. This should be a value greater than zero, the behavior is undefined otherwise.
maxBatchSize Type: Integer Default: 50 Number of maximum events in a batch call.
maxRetries Type: Integer Default: 3 Maximum number of consecutive flush attempts before the entire queue is dropped to avoid infinite retries against a permanently-broken backend (e.g. wrong API key, exhausted quota, deterministic 5xx). Increments on every retriable failure including HTTP 413 cap halving; resets on a successful 2xx response.
captureApplicationLifecycleEvents Type: Boolean Default: true Whether the posthog client should automatically make a capture call for application lifecycle events, such as "Application Installed", "Application Updated" and "Application Opened".
captureScreenViews Type: Boolean Default: true Whether the posthog client should automatically make a screen call when a view controller is added to a view hierarchy. Because the underlying implementation uses method swizzling, we recommend initializing the posthog client as early as possible (before any screens are displayed), ideally during the Application delegate's applicationDidFinishLaunching method.
enableSwizzling Type: Boolean Default: true Enable method swizzling for SDK functionality that depends on it. When disabled, functionality that requires swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
captureElementInteractions Type: Boolean Default: false (UIKit only) Whether the posthog client should automatically make a capture call when the user interacts with an element in a screen.
rageClickConfig Type: Object Default: .init() (iOS/macCatalyst, UIKit) Rage click detection configuration. Includes enabled (default true), minimumTapCount (default 3), thresholdPoints (default 30), and timeoutInterval (default 1.0). Works independently of captureElementInteractions. Available in version 3.51.0+.
sendFeatureFlagEvent Type: Boolean Default: true Send a $feature_flag_called event when a feature flag is used automatically.
preloadFeatureFlags Type: Boolean Default: true Preload feature flags automatically.
evaluationContexts Type: Array of Strings Default: undefined Evaluation context tags that constrain which feature flags are evaluated. When set, only flags with matching evaluation context tags (or no evaluation context tags) will be returned. See evaluation contexts documentation (/docs/feature-flags/evaluation-contexts.md) for more details. Available in version 3.38.0+. The legacy parameter evaluationEnvironments (version 3.33.0+) is also supported for backward compatibility.
debug Type: Boolean Default: false Logs the SDK messages to the Xcode console.
optOut Type: Boolean Default: false Prevents capturing any data if enabled.
getAnonymousId Type: Function Default: undefined Hook that allows for modification of the default mechanism for generating anonymous id (which as of now is just random UUID v7).
dataMode Type: Enum Default: .any Controls when queued data is flushed. Use .wifi to flush only on Wi-Fi; .cellular is a legacy value and behaves like .any.
personProfiles Type: Enum Default: .identifiedOnly Determines the behavior for processing user profiles.
setDefaultPersonProperties Type: Boolean Default: true Automatically set common device and app properties (such as $app_version, $os_name, and $device_type) as person properties for feature flag evaluation. See property overrides (/docs/feature-flags/property-overrides.md) for more details.
sessionReplay Type: Boolean Default: false Enable Recording of Session Replays.
sessionReplayConfig Type: Object Default: .init() Session Replay configuration. See Session Replay installation (/docs/session-replay/installation/ios.md) for more details.
tracingHeaders Type: Array of Strings Default: nil Exact hostnames that should receive PostHog tracing headers when the SDK instruments URLSession requests.
errorTrackingConfig Type: Object Default: .init() Error Tracking configuration. See the error tracking docs (/docs/error-tracking.md) for more details.
logs Type: Object Default: .init() Structured Logs configuration. See Logs installation (/docs/logs/installation/ios.md) for more details.
surveysConfig Type: Object Default: .init() Surveys configuration, including custom survey delegates and display language overrides.
urlSessionConfiguration Type: URLSessionConfiguration Default: .default Custom URLSessionConfiguration used by the SDK for PostHog API requests.
appGroupIdentifier Type: String Default: nil The identifier of the App Group that should be used to store shared analytics data. PostHog will try to get the physical location of the App Group's shared container, otherwise fallback to the default location.
reuseAnonymousId Type: Boolean Default: false Whether the SDK should reuse the anonymous Id between user changes. When enabled, a single Id will be used for all anonymous users on this device.
surveys Type: Boolean Default: true Enable Surveys.
setBeforeSend Type: Function Default: undefined Hook that allows for amending, sampling, or dropping events before they are sent to PostHog.
bootstrap Type: PostHogBootstrapConfig Default: nil Seeds identity (distinctId, isIdentifiedId) and feature-flag state (featureFlags, featureFlagPayloads) before the first /flags response. Bootstrapped identity applies to the first session; only enabled flags are served, until the first /flags response replaces them. See SDK bootstrapping (/docs/libraries/bootstrapping.md#behavior-on-mobile-sdks).
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/django.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Django

PostHog makes it easy to get data about traffic and usage of your Django app. Integrating PostHog enables analytics, custom events capture, feature flags, error tracking, and more.

This guide walks you through integrating PostHog into your Django app using the Python SDK (/docs/libraries/python.md).

Beta: integration via LLM

Install PostHog for Django in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.

npx @posthog/wizard

Learn more (/wizard.md)

Or, to integrate manually, continue with the rest of this guide.

These docs cover version 7.x of the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).

Installation

To start, run pip install posthog to install PostHog’s Python SDK.

Then, configure PostHog in your app config so it's initialized when Django starts:

your_app/apps.py

from django.apps import AppConfig
import posthog

class YourAppConfig(AppConfig):
    name = 'your_app_name'

    def ready(self):
        posthog.api_key = '<ph_project_token>'
        posthog.host = 'https://us.i.posthog.com'

Next, if you haven't done so already, add your AppConfig to INSTALLED_APPS in settings.py:

settings.py

INSTALLED_APPS = [
    # ... other apps
    'your_app_name.apps.YourAppConfig',
]

You can find your project token and instance address in your project settings.

To capture events from any file, import posthog and call the method you need. For example:

Python

import posthog
from posthog import identify_context

def some_request(request):
    with posthog.new_context():
        # Django includes request.user for anonymous visitors too. Only identify
        # the context when the visitor is logged in.
        if request.user.is_authenticated:
            identify_context(str(request.user.pk))

        posthog.capture('event_name')

Events captured without a context or explicit distinct_id are sent as anonymous events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated distinct_id. See the Python SDK docs (/docs/libraries/python.md#person-profiles-and-properties) for more details.

Identifying users

Identifying users is required. Backend events need a distinct_id to associate events with the correct user.

In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct distinct_id. Typically, you would set a fresh context and identify at the top of each route.

Python

from posthog import new_context, identify_context, capture

@app.get("/foo")
def foo(current_user: User = Depends(get_current_user)):
    with new_context(): # Set context at the top of a route
        identify_context(current_user.id)
        capture("foo_viewed")
    return {"status": "ok"}

When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every capture() downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.

Django contexts middleware

The Python SDK provides a Django middleware that automatically wraps all requests with a context (/docs/libraries/python.md#contexts). This middleware extracts session and user information from each request and tags all events captured during that request with relevant metadata.

Basic setup

Add the middleware to your Django settings. If your app uses Django authentication, place it after django.contrib.auth.middleware.AuthenticationMiddleware so the middleware can use the authenticated Django user as a distinct ID fallback and capture the user's email.

Python

MIDDLEWARE = [
    # ... other middleware
    'posthog.integrations.django.PosthogContextMiddleware',
    # ... other middleware
]

The middleware uses the globally configured posthog client by default, so you don't need to create or pass it a separate client instance.

The middleware automatically extracts and uses:

  • Session ID from the X-POSTHOG-SESSION-ID header, if present
  • Distinct ID from the X-POSTHOG-DISTINCT-ID header, if present, falling back to the authenticated Django user's pk (Django's primary-key alias, which works with custom user models)
  • User email from the authenticated Django user's email as email
  • Current URL as $current_url
  • Request method as $request_method
  • Request path as $request_path
  • Forwarded IP address from X-Forwarded-For as $ip
  • User agent from User-Agent as $user_agent

The session and distinct ID headers are sanitized before use. Empty values are ignored, control characters are removed, values are trimmed, and values are capped at 1000 characters.

All events captured during the request (including exceptions) include these properties and are associated with the extracted session and distinct ID.

Login and signup views

The middleware reads request.user once, before your view runs. On a login or signup request the visitor is still anonymous at that point, so the request's context has no distinct ID. Calling login() inside the view doesn't change that. Everything captured during that request stays anonymous, including the login event itself.

Identify the context from inside the request once you know who the user is. Django's auth signals are the natural place:

Python

from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from posthog import identify_context

@receiver(user_logged_in)
def identify_posthog_user(sender, request, user, **kwargs):
    identify_context(str(user.pk))

Every capture later in that request is then attributed to the user who just logged in. Requests made after login don't need this. The middleware sees the authenticated user from the start.

If you're using PostHog JavaScript Web (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Django backend hostname so browser requests include the session and distinct ID headers.

Exception capture

By default, the middleware captures exceptions and sends them to PostHog's error tracking using the globally configured posthog client. This includes Django view exceptions that Django converts into error responses.

Disable this by setting:

Python

# settings.py
POSTHOG_MW_CAPTURE_EXCEPTIONS = False
Adding custom tags

Use POSTHOG_MW_EXTRA_TAGS to add custom properties to all requests:

Python

# settings.py
def add_user_tags(request):
    # type: (HttpRequest) -> Dict[str, Any]
    tags = {}
    if hasattr(request, 'user') and request.user.is_authenticated:
        # Use pk instead of id so this works with custom User primary keys.
        tags['user_id'] = str(request.user.pk)
        tags['email'] = request.user.email
    return tags

POSTHOG_MW_EXTRA_TAGS = add_user_tags
Filtering requests

Skip tracking for certain requests using POSTHOG_MW_REQUEST_FILTER:

Python

# settings.py
def should_track_request(request):
    # type: (HttpRequest) -> bool
    # Don't track health checks or admin requests
    if request.path.startswith('/health') or request.path.startswith('/admin'):
        return False
    return True

POSTHOG_MW_REQUEST_FILTER = should_track_request
Modifying default tags

Use POSTHOG_MW_TAG_MAP to modify or remove default tags:

Python

# settings.py
def customize_tags(tags):
    # type: (Dict[str, Any]) -> Dict[str, Any]
    # Remove URL for privacy
    tags.pop('$current_url', None)
    # Add custom prefix to method
    if '$request_method' in tags:
        tags['http_method'] = tags.pop('$request_method')
    return tags

POSTHOG_MW_TAG_MAP = customize_tags
Complete configuration example

Python

# settings.py
def add_request_context(request):
    # type: (HttpRequest) -> Dict[str, Any]
    tags = {}
    if hasattr(request, 'user') and request.user.is_authenticated:
        tags['user_type'] = 'authenticated'
        # Use pk instead of id so this works with custom User primary keys.
        tags['user_id'] = str(request.user.pk)
    else:
        tags['user_type'] = 'anonymous'

    # Add request info
    tags['user_agent'] = request.META.get('HTTP_USER_AGENT', '')
    return tags

def filter_tracking(request):
    # type: (HttpRequest) -> bool
    # Skip internal endpoints
    return not request.path.startswith(('/health', '/metrics', '/admin'))

def clean_tags(tags):
    # type: (Dict[str, Any]) -> Dict[str, Any]
    # Remove sensitive data
    tags.pop('user_agent', None)
    return tags

POSTHOG_MW_EXTRA_TAGS = add_request_context
POSTHOG_MW_REQUEST_FILTER = filter_tracking
POSTHOG_MW_TAG_MAP = clean_tags
POSTHOG_MW_CAPTURE_EXCEPTIONS = True

All events captured within the request context automatically include the configured tags and are associated with the session and user identified from the request headers or Django authentication.

The middleware supports both sync (WSGI) and async (ASGI) Django applications. In async mode, it uses Django's request.auser() API when available to avoid synchronous user access.

Next steps

For any technical questions for how to integrate specific PostHog features into Django (such as analytics, feature flags, A/B testing, etc.), have a look at our Python SDK docs (/docs/libraries/python.md).

Alternatively, the following tutorials can help you get started:

  • Setting up Django analytics, feature flags, and more (/tutorials/django-analytics.md)
  • How to set up A/B tests in Django (/tutorials/django-ab-tests.md)

Supported versions

These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.

Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.

Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/dotnet.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

.NET

This is an optional library you can install if you're working with .NET Core. It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server side application that needs performance.

Installation

The PostHog package supports any .NET platform that targets .NET Standard 2.1 or .NET 8+, including MAUI, Blazor, and console applications. The PostHog.AspNetCore package provides additional conveniences for ASP.NET Core applications such as streamlined registration, request-scoped caching, and integration with .NET Feature Management.

Note: We actively test with ASP.NET Core. Other platforms should work but haven't been specifically tested. If you encounter issues, please report them on GitHub.

Not supported: Classic UWP (requires .NET Standard 2.0 only). Microsoft has deprecated UWP in favor of the Windows App SDK. For Unity projects, see our dedicated Unity SDK (/docs/libraries/unity.md).

Terminal

dotnet add package PostHog.AspNetCore

In your Program.cs (or Startup.cs for ASP.NET Core 2.x) file, add the following code:

C#

using PostHog;

var builder = WebApplication.CreateBuilder(args);

// Add PostHog to the dependency injection container as a singleton.
builder.AddPostHog();

Make sure to configure PostHog with your project token, instance address, and optional personal API key. For example, in appsettings.json:

JSON

{
  "PostHog": {
    "ProjectToken": "<ph_project_token>",
    "HostUrl": "https://us.i.posthog.com"
  }
}

Note: If the host is not specified, the default host https://us.i.posthog.com is used.

Use a secrets manager to store your personal API key. For example, when developing locally you can use the UserSecrets feature of the dotnet CLI:

Terminal

dotnet user-secrets init
dotnet user-secrets set "PostHog:PersonalApiKey" "phx_..."

You can find your project token and instance address in the project settings page in PostHog.

Working with .NET Feature Management

PostHog.AspNetCore supports .NET Feature Management. This enables you to use the <feature /> tag helper and the FeatureGateAttribute in your ASP.NET Core applications to gate access to certain features using PostHog feature flags.

To use feature flags with the .NET Feature Management library, you'll need to implement the IPostHogFeatureFlagContextProvider interface. The quickest way to do that is to inherit from the PostHogFeatureFlagContextProvider class and override the GetDistinctId and GetFeatureFlagOptionsAsync methods.

C#

public class MyFeatureFlagContextProvider(IHttpContextAccessor httpContextAccessor)
    : PostHogFeatureFlagContextProvider
{
    protected override string? GetDistinctId()
        => httpContextAccessor.HttpContext?.User.Identity?.Name;

    protected override ValueTask<FeatureFlagOptions> GetFeatureFlagOptionsAsync()
    {
        // In a real app, you might get this information from a
        // database or other source for the current user.
        return ValueTask.FromResult(
            new FeatureFlagOptions
            {
                PersonProperties = new Dictionary<string, object?>
                {
                    ["email"] = "some-test@example.com"
                },
                OnlyEvaluateLocally = true
            });
    }
}

Then, register your implementation in Program.cs (or Startup.cs):

C#

var builder = WebApplication.CreateBuilder(args);
builder.AddPostHog(options => {
    options.UseFeatureManagement<MyFeatureFlagContextProvider>();
});

With this in place, you can now use feature tag helpers in your Razor views:

HTML

<feature name="awesome-new-feature">
    <p>This is the new feature!</p>
</feature>
<feature name="awesome-new-feature" negate="true">
    <p>Sorry, no awesome new feature for you.</p>
</feature>

Multivariate feature flags are also supported:

HTML

<feature name="awesome-new-feature" value="variant-a">
    <p>This is the new feature variant A!</p>
</feature>
<feature name="awesome-new-feature" value="variant-b">
    <p>This is the new feature variant B!</p>
</feature>

You can also use the FeatureGateAttribute to gate access to controllers or actions:

C#

[FeatureGate("awesome-new-feature")]
public class NewFeatureController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

Using the core package without ASP.NET Core

If you're not using ASP.NET Core (for example, in a console application, MAUI app, or Blazor WebAssembly), install the PostHog package instead of PostHog.AspNetCore. This package has no ASP.NET Core dependencies and can be used in any .NET project targeting .NET Standard 2.1 or .NET 8+.

Terminal

dotnet add package PostHog

The PostHogClient class must be implemented as a singleton in your project. For PostHog.AspNetCore, this is handled by the builder.AddPostHog(); method. For the PostHog package, you can do the following if you're using dependency injection:

C#

builder.Services.AddPostHog();

If you're not using a builder (such as in a console application), you can do the following:

C#

using PostHog;

var services = new ServiceCollection();
services.AddPostHog();
var serviceProvider = services.BuildServiceProvider();
var posthog = serviceProvider.GetRequiredService<IPostHogClient>();

The AddPostHog methods accept an optional Action<PostHogOptions> parameter that you can use to configure the client.

If you're not using dependency injection, you can create a static instance of the PostHogClient class and use that everywhere in your project:

C#

using PostHog;

public static readonly PostHogClient PostHog = new(new PostHogOptions {
    ProjectToken = "<ph_project_token>",
    HostUrl = new Uri("https://us.i.posthog.com"),
    PersonalApiKey = Environment.GetEnvironmentVariable(
      "PostHog__PersonalApiKey")
});

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.

To see detailed logging, set the log level to Debug or Trace in appsettings.json:

JSON

{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "PostHog": "Trace"
    }
  },
  ...
}

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Capturing events

You can send custom events using capture:

C#

posthog.Capture("distinct_id_of_the_user", "user_signed_up");

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

C#

posthog.Capture(
    "distinct_id_of_the_user",
    "user_signed_up",
    properties: new() {
        ["login_type"] = "email",
        ["is_free_trial"] = "true"
    }
);
Sending page views

If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send $pageview events from your backend like so:

C#

using PostHog;
using Microsoft.AspNetCore.Http.Extensions;

posthog.CapturePageView(
    "distinct_id_of_the_user",
    HttpContext.Request.GetDisplayUrl());

Request context

For ASP.NET Core apps using PostHog.AspNetCore, add request context middleware before routes that call PostHog. This reads incoming PostHog tracing headers and attaches request metadata to captures, exceptions, and feature flag evaluation inside the request.

Program.cs

using PostHog;
using PostHog.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.AddPostHog();

var app = builder.Build();

app.UsePostHogRequestContext();

If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your ASP.NET Core backend hostname so browser requests include the session and distinct ID headers.

The middleware reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped analytics context. It also adds request metadata such as $current_url, $request_method, $request_path, $user_agent, and $ip. Explicit distinct IDs and event properties always override request context.

Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side decisions, pass an authenticated distinct ID explicitly. You can ignore tracing headers while still collecting request metadata:

C#

app.UsePostHogRequestContext(options =>
{
    options.UseTracingHeaders = false;
});

Request-context overloads like posthog.Capture("checkout started") and posthog.EvaluateFlagsAsync() use the current request distinct ID when one is available.

Error tracking

You can manually capture exceptions using CaptureException. This sends a $exception event with stack frames, inner exceptions, aggregate exceptions, source context when available, and .NET runtime metadata.

File names, line numbers, and source context depend on debug information already available from the captured .NET stack trace. PostHog doesn't support uploading .NET PDB files yet, so production builds without runtime-accessible debug information may show less detailed stack frames.

C#

try
{
    ProcessOrder(orderId);
}
catch (Exception exception)
{
    posthog.CaptureException(exception, "user_distinct_id");
}

Add custom properties to include request, tenant, or domain context:

C#

posthog.CaptureException(
    exception,
    "user_distinct_id",
    new Dictionary<string, object>
    {
        ["order_id"] = orderId,
        ["environment"] = "production",
    }
);

For the full setup guide, see the .NET error tracking installation docs (/docs/error-tracking/installation/dotnet.md).

Automatic exception capture is not available in the .NET SDK yet.

Logs

PostHog Logs (/docs/logs.md) doesn't use this SDK. Logs are ingested over OpenTelemetry, so you attach an OTLP exporter to the standard ILogger pipeline instead — see the .NET logs installation guide (/docs/logs/installation/dotnet.md).

Person profiles and properties

The .NET SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:

C#

posthog.Capture(
    "distinct_id",
    "event_name",
    personPropertiesToSet: new() { ["name"] = "Max Hedgehog" },
    personPropertiesToSetOnce: new() { ["initial_url"] = "/blog" }
);

For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).

To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:

C#

posthog.Capture(
    "distinct_id",
    "event_name",
    properties: new() {
        ["$process_person_profile"] = false
    }
)

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

C#

await posthog.AliasAsync("current_distinct_id", "new_distinct_id");

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Group analytics

Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the group analytics (/docs/product-analytics/group-analytics.md) guide for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our pricing page (/pricing.md).

To capture an event and associate it with a group, add the groups argument to your Capture call:

C#

posthog.Capture(
    "user_distinct_id",
    "some_event",
    groups: [new Group("company", "company_id_in_your_db")]);

Update properties on a group, use the GroupIdentifyAsync method:

C#

await posthog.GroupIdentifyAsync(
    type: "company",
    key: "company_id_in_your_db",
    name: "Awesome Inc.",
    properties: new()
    {
        ["employees"] = 11
    }
);

The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in .NET:

Step 1: Evaluate flags once

Call EvaluateFlagsAsync() once for the user, then read values from the returned snapshot.

Boolean feature flags

C#

var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");

if (flags.IsEnabled("flag-key"))
{
    // Do something differently for this user
    // Optional: fetch the payload
    var matchedPayload = flags.GetFlagPayload("flag-key");
}
Multivariate feature flags

C#

var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");

var enabledVariant = flags.GetFlag("flag-key")?.VariantKey;

if (enabledVariant == "variant-key") // replace "variant-key" with the key of your variant
{
    // Do something differently for this user
    // Optional: fetch the payload
    var matchedPayload = flags.GetFlagPayload("flag-key");
}

flags.GetFlag() returns a nullable FeatureFlag object. Check VariantKey for multivariate flags and IsEnabled for boolean flags. It returns null when the flag wasn't returned by the evaluation.

Note: posthog.IsFeatureEnabledAsync(), posthog.GetFeatureFlagAsync(), and Capture(..., sendFeatureFlags: true, ...) still work during the migration period, but they're deprecated. Prefer EvaluateFlagsAsync() for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Pass the evaluated flags snapshot to Capture()

Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.

C#

var flags = await posthog.EvaluateFlagsAsync("distinct_id_of_your_user");

if (flags.IsEnabled("flag-key"))
{
    // Do something differently for this user
}

posthog.Capture(
    "distinct_id_of_your_user",
    "event_name",
    properties: null,
    groups: null,
    flags: flags
);

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, pass a filtered snapshot:

C#

// Attach only flags accessed with IsEnabled() or GetFlag() before this call
posthog.Capture(
    "distinct_id_of_your_user",
    "event_name",
    properties: null,
    groups: null,
    flags: flags.OnlyAccessed()
);

// Attach only specific flags
posthog.Capture(
    "distinct_id_of_your_user",
    "event_name",
    properties: null,
    groups: null,
    flags: flags.Only("checkout-flow", "new-dashboard")
);
Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

C#

posthog.Capture(
    "distinct_id_of_your_user",
    "event_name",
    properties: new()
    {
        // Replace feature-flag-key with your flag key and "variant-key" with the key of your variant
        ["$feature/feature-flag-key"] = "variant-key",
    }
);
Evaluating only specific flags

By default, EvaluateFlagsAsync() evaluates every flag for the user. If you only need a few flags, pass FlagKeysToEvaluate to request only those flags:

C#

var flags = await posthog.EvaluateFlagsAsync(
    "distinct_id_of_your_user",
    options: new AllFeatureFlagsOptions
    {
        FlagKeysToEvaluate = new[] { "checkout-flow", "new-dashboard" },
    }
);
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With EvaluateFlagsAsync(), the SDK sends this event when you call flags.IsEnabled() or flags.GetFlag() for a flag.

The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.

flags.GetFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for OnlyAccessed().

Advanced: Overriding server properties

Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.

You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.

For example:

C#

var flags = await posthog.EvaluateFlagsAsync(
    "distinct_id_of_the_user",
    options: new AllFeatureFlagsOptions
    {
        PersonProperties = new()
        {
            ["property_name"] = "value",
        },
        Groups = new()
        {
            new Group("your_group_type", "your_group_id")
            {
                ["group_property_name"] = "value",
            },
            new Group("another_group_type", "another_group_id")
            {
                ["group_property_name"] = "another value",
            },
        },
    }
);

if (flags.IsEnabled("flag-key"))
{
    // Do something differently for this user
}
Overriding GeoIP properties

By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.

You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.

The following GeoIP properties can be overridden:

  • $geoip_country_code
  • $geoip_country_name
  • $geoip_city_name
  • $geoip_city_confidence
  • $geoip_continent_code
  • $geoip_continent_name
  • $geoip_latitude
  • $geoip_longitude
  • $geoip_postal_code
  • $geoip_subdivision_1_code
  • $geoip_subdivision_1_name
  • $geoip_subdivision_2_code
  • $geoip_subdivision_2_name
  • $geoip_subdivision_3_code
  • $geoip_subdivision_3_name
  • $geoip_time_zone

Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.

Evaluation contexts

Configure evaluation contexts so this SDK only evaluates flags intended for the matching application, platform, or product area. For ASP.NET Core apps using PostHog.AspNetCore, add them to the PostHog configuration section:

JSON

{
    "PostHog": {
        "ProjectToken": "<ph_project_token>",
        "HostUrl": "https://us.i.posthog.com",
        "EvaluationContexts": ["main-app", "api", "backend"]
    }
}

For code-based configuration, set EvaluationContexts on PostHogOptions:

C#

var posthog = new PostHogClient(new PostHogOptions
{
    ProjectToken = "<ph_project_token>",
    HostUrl = new Uri("https://us.i.posthog.com"),
    EvaluationContexts = ["main-app", "api", "backend"],
});

Remote /flags requests from EvaluateFlagsAsync() include evaluation_contexts when configured.

For more details, see the evaluation contexts guide (/docs/feature-flags/evaluation-contexts.md).

Local evaluation

Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.

It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.

For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:

C#

var flags = await posthog.EvaluateFlagsAsync("user_distinct_id");
var variant = flags.GetFlag("experiment-feature-flag-key")?.VariantKey;

if (variant == "variant-name")
{
    // Do something
}

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

AI observability

PostHog.AI adds AI observability (/docs/ai-observability.md) for .NET applications using OpenAI or Azure OpenAI. It is currently pre-release, so expect breaking changes before a stable release.

For installation instructions, see the OpenAI guide for .NET (/docs/ai-observability/installation/openai.md#net-support) or the Azure OpenAI guide for .NET (/docs/ai-observability/installation/azure-openai.md#net-support).

GeoIP properties

The posthog-dotnet library disregards the server IP, does not add the GeoIP properties, and does not use the values for feature flag evaluations.

Serverless environments (Azure Functions/Render/Lambda/...)

By default, the library buffers events before sending them to the /batch endpoint for better performance. This can lead to lost events in serverless environments if the .NET process is terminated by the platform before the buffer is fully flushed.

To avoid this, call await posthog.FlushAsync() after processing every request by adding it as a middleware to your server. This allows posthog.Capture() to remain asynchronous for better performance.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/elixir.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Elixir

This library provides an Elixir HTTP client for PostHog. See the repository for more information.

Installation

This library was built by the community but it's being maintained by the PostHog core team since v1.0.0. Thank you to Nick Kezhaya for building it originally. Thank you to Alex Martsinovich for contributing v2.0.0.

The package can be installed by adding posthog to your list of dependencies in mix.exs:

Elixir

def deps do
  [
    {:posthog, "~> 2.0"}
  ]
end
Configuration

config/config.exs

config :posthog,
  enable: true,
  api_host: "https://us.i.posthog.com",
  api_key: "<ph_project_token>",
  in_app_otp_apps: [:my_app]

You can see all the available configuration options in the PostHog.Config module.

Optionally, you might want to enable the Plug integration to attach request metadata and tracing context in Plug-based applications including Phoenix. You still need to capture events explicitly with PostHog.capture/2 or PostHog.capture/3.

Development/Test mode

For a test environment, you can pass in test_mode: true value to the config. This causes events to be dropped instead of sent to PostHog.

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Capturing events

To capture an event, use PostHog.capture/2:

Elixir

PostHog.capture("user_signed_up", %{distinct_id: "distinct_id_of_the_user"})

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Elixir

PostHog.capture("user_signed_up", %{
  distinct_id: "distinct_id_of_the_user",
  login_type: "email",
  is_free_trial: true
})
Context

Carrying distinct_id around all the time might not be the most convenient approach, so PostHog lets you store it and other properties in a context.

The context is stored in the Logger metadata and PostHog automatically attaches these properties to any events you capture with PostHog.capture/2, as long as they happen in the same process.

Elixir

PostHog.set_context(%{distinct_id: "distinct_id_of_the_user"})
PostHog.capture("page_opened")

You can also scope the context to a specific event name:

Elixir

PostHog.set_event_context("sensitive_event", %{"$process_person_profile": false})
Batching events

Events are automatically batched and sent to PostHog via a background job.

Special events

PostHog.capture/2 is very powerful and enables you to send events that have special meaning.

In other libraries you'll usually find helpers for these special events, but they must be explicitly sent in Elixir.

For example:

Create alias

Elixir

PostHog.capture("$create_alias", %{distinct_id: "frontend_id", alias: "backend_id"})
Group analytics

Elixir

PostHog.capture("$groupidentify", %{
  distinct_id: "static_string_used_for_all_group_events",
  "$group_type": "company",
  "$group_key": "company_id_in_your_db"
})

Request context

For Phoenix or Plug apps, add PostHog.Integrations.Plug before your router to attach request metadata and PostHog tracing headers to events captured during the request.

lib/my_app_web/endpoint.ex

plug PostHog.Integrations.Plug
plug MyAppWeb.Router

For plain Plug routers, add it before :match and :dispatch:

Elixir

defmodule MyRouter do
  use Plug.Router

  plug PostHog.Integrations.Plug
  plug :match
  plug :dispatch

  # ... routes
end

The plug adds request metadata such as $current_url, $host, $pathname, $request_method, $user_agent, and $ip. It also reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as analytics context so backend events and errors can be linked to frontend users and sessions.

If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Phoenix or Plug backend hostname so browser requests include these headers.

Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinct_id explicitly for security-sensitive server-side decisions.

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in Elixir:

Step 1: Evaluate flags once

Call PostHog.FeatureFlags.evaluate_flags/1 once for the user, then read values from the returned snapshot.

Boolean feature flags

Elixir

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")

if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key") do
  # Do something differently for this user
  # Optional: fetch the payload
  payload = PostHog.FeatureFlags.Evaluations.get_flag_payload(snapshot, "flag-key")
end
Multivariate feature flags

Elixir

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")

enabled_variant = PostHog.FeatureFlags.Evaluations.get_flag(snapshot, "flag-key")

if enabled_variant == "variant-key" do
  # Do something differently for this user
  # Optional: fetch the payload
  payload = PostHog.FeatureFlags.Evaluations.get_flag_payload(snapshot, "flag-key")
end

PostHog.FeatureFlags.Evaluations.get_flag/2 returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.

Note: PostHog.FeatureFlags.check/2, PostHog.FeatureFlags.check!/2, PostHog.FeatureFlags.get_feature_flag_result/2, and PostHog.FeatureFlags.get_feature_flag_result!/2 still work during the migration period, but they're deprecated. Prefer evaluate_flags/1 for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Put the evaluated flags snapshot in context

Put the same snapshot object that you used for branching into context. Subsequent captures from the same process attach the exact flag values from that evaluation and don't make another /flags request.

Elixir

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")

if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key") do
  # Do something differently for this user
end

PostHog.FeatureFlags.set_in_context(snapshot)
PostHog.capture("event_name", %{distinct_id: "distinct_id_of_your_user"})

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, put a filtered snapshot in context:

Elixir

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("distinct_id_of_your_user")

# Attach only flags accessed with enabled?/2 or get_flag/2 before this call
PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "flag-key")
PostHog.FeatureFlags.set_in_context(
  PostHog.FeatureFlags.Evaluations.only_accessed(snapshot)
)

# Or attach only specific flags
PostHog.FeatureFlags.set_in_context(
  PostHog.FeatureFlags.Evaluations.only(snapshot, ["checkout-flow", "new-dashboard"])
)

only_accessed/1 is order-dependent. If you call it before accessing any flags with enabled?/2 or get_flag/2, no feature flag properties are attached.

Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

Elixir

PostHog.capture("event_name", %{
  "$feature/feature-flag-key" => "variant-key",
  distinct_id: "distinct_id_of_your_user"
})
Evaluating only specific flags

By default, evaluate_flags/1 evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:

Elixir

{:ok, snapshot} =
  PostHog.FeatureFlags.evaluate_flags(%{
    distinct_id: "distinct_id_of_your_user",
    flag_keys: ["checkout-flow", "new-dashboard"]
  })
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluate_flags/1, the SDK sends this event when you call PostHog.FeatureFlags.Evaluations.enabled?/2 or PostHog.FeatureFlags.Evaluations.get_flag/2 for a flag.

PostHog.FeatureFlags.Evaluations.get_flag_payload/2 doesn't send $feature_flag_called events.

Local feature flag evaluation

Local evaluation is available in version 2.15.0 and later. Follow the server-side local evaluation guide (/docs/feature-flags/local-evaluation.md) to find your secure key and see how to pass the person properties, groups, and group properties that your flag conditions require.

Store the secure key in a server-side environment variable. Don't expose it to client-side applications:

config/runtime.exs

config :posthog,
  api_host: "https://us.i.posthog.com",
  api_key: "<ph_project_token>",
  secret_key: System.fetch_env!("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY")

When secret_key is set, the SDK fetches definitions when it starts and polls for updates every 30 seconds. PostHog.FeatureFlags.evaluate_flags/1 evaluates each flag locally first. If a flag can't be evaluated locally, the SDK makes one /flags request to resolve the remaining flags. Set only_evaluate_locally: true in the evaluation map to prevent this remote fallback. The SDK then omits unresolved flags from the snapshot.

Use these configuration options to control local evaluation:

Option Default Purpose
enable_local_evaluation true Starts local evaluation when secret_key is set.
feature_flags_poll_interval_ms 30_000 Sets the interval between definition refreshes.
flag_definition_request_timeout_ms 10_000 Sets the timeout for each definition request.
flag_definition_cache_provider_timeout_ms 5_000 Sets the timeout for each shared cache provider callback.

For multiple server instances, you can implement PostHog.FeatureFlags.FlagDefinitionCacheProvider and set flag_definition_cache_provider: {module, state}. This optional provider shares definitions and coordinates which instance polls PostHog. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=Elixir.md) for the callback contract and configuration.

Error tracking

Error tracking is enabled by default. It will automatically captures exceptions thrown by the application.

As a matter of fact, since this is built on top of Elixir's Logger module, it automatically captures any Logger.error calls.

You can always disable it by setting enable_error_tracking to false:

Elixir

config :posthog,
  enable_error_tracking: false

Advanced configuration

By default, PostHog starts its own supervision tree and attaches a logger handler.

In certain cases, you might want to run this supervision tree yourself. You can do this by disabling the default supervisor and adding PostHog.Supervisor to your application tree with its own configuration:

config.exs

config :posthog, enable: false

config :my_app, :posthog,
  api_host: "https://us.i.posthog.com",
  api_key: "<ph_project_token>"

application.ex

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    posthog_config = Application.fetch_env!(:my_app, :posthog) |> PostHog.Config.validate!()

    :logger.add_handler(:posthog, PostHog.Handler, %{config: posthog_config})

    children = [
      {PostHog.Supervisor, posthog_config}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end
Multiple instances

In even more advanced cases, you might want to interact with more than one PostHog project. In this case, you can run multiple PostHog supervision trees, one of which can be the default one:

config.exs

config :posthog,
  api_host: "https://us.i.posthog.com",
  api_key: "<ph_project_token>"

config :my_app, :another_posthog,
  api_host: "https://us.i.posthog.com",
  api_key: "a_different_project_api_key",
  supervisor_name: AnotherPostHog

application.ex

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    posthog_config = Application.fetch_env!(:my_app, :another_posthog) |> PostHog.Config.validate!()

    children = [
      {PostHog.Supervisor, posthog_config}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Then, each function in the PostHog module accepts an optional first argument with the name of the PostHog supervisor tree that will process the capture:

Elixir

PostHog.capture(AnotherPostHog, "user_signed_up", %{distinct_id: "user123"})

Thanks

The library is maintained by the PostHog team since February 2025. Thanks to nkezhaya for contributing v0.1.0. Thanks to martosaur for contributing v2.0.0.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/flask.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Flask

PostHog makes it easy to get data about traffic and usage of your Flask app. Integrating PostHog enables analytics, custom events capture, feature flags, error tracking, and more.

This guide walks you through integrating PostHog into your Flask app using the Python SDK (/docs/libraries/python.md).

These docs cover version 7.x of the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).

Installation

To start, run pip install posthog to install PostHog’s Python SDK.

Then, initialize PostHog where you'd like to use it. For example, here's how to capture an event in a simple route:

app.py

from flask import Flask
from posthog import Posthog

app = Flask(__name__)

posthog = Posthog(
    '<ph_project_token>',
    host='https://us.i.posthog.com',
)

@app.route('/api/dashboard', methods=['POST'])
def api_dashboard():
    posthog.capture(
        'dashboard_api_called',
        distinct_id='distinct_id_of_your_user',
    )
    return '', 204

You can find your project token and instance address in your project settings.

Identifying users

Identifying users is required. Backend events need a distinct_id to associate events with the correct user.

In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct distinct_id. Typically, you would set a fresh context and identify at the top of each route.

Python

from posthog import new_context, identify_context, capture

@app.get("/foo")
def foo(current_user: User = Depends(get_current_user)):
    with new_context(): # Set context at the top of a route
        identify_context(current_user.id)
        capture("foo_viewed")
    return {"status": "ok"}

When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every capture() downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.

Request contexts

Use contexts (/docs/libraries/python.md#contexts) to share identity, session IDs, and tags across multiple captures during a request.

If you're using PostHog JavaScript Web (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Flask backend hostname so browser requests include the session and distinct ID headers.

Then read the incoming headers in your Flask request handler. Tracing headers are client-controlled analytics context, not authentication or authorization, so prefer your authenticated user ID when one is available:

Python

from flask import request, session
from posthog import identify_context, set_context_session, tag

@app.route('/api/dashboard', methods=['POST'])
def api_dashboard():
    with posthog.new_context(fresh=True):
        distinct_id = session.get('user_id') or request.headers.get('X-POSTHOG-DISTINCT-ID')
        if distinct_id:
            identify_context(str(distinct_id))

        session_id = request.headers.get('X-POSTHOG-SESSION-ID')
        if session_id:
            set_context_session(session_id)

        tag('$current_url', request.url)
        tag('$request_method', request.method)
        tag('$request_path', request.path)

        posthog.capture('dashboard_api_called')

    return '', 204

Events captured without a context or explicit distinct_id are sent as anonymous events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated distinct_id. See the Python SDK docs (/docs/libraries/python.md#person-profiles-and-properties) for more details.

Error tracking

Flask has built-in error handlers. This means PostHog’s default exception autocapture won’t work and we need to manually capture errors instead using capture_exception():

Python

from flask import Flask, jsonify
from posthog import Posthog

app = Flask(__name__)
posthog = Posthog('<ph_project_token>', host='https://us.i.posthog.com')

@app.errorhandler(Exception)
def handle_exception(e):
    # Capture methods, including capture_exception, return the UUID of the captured event,
    # which you can use to find specific errors users encountered
    event_id = posthog.capture_exception(e)

    # You can show the event ID to your user, and ask them to include it in bug reports
    response = jsonify({'message': str(e), 'error_id': event_id})
    response.status_code = 500
    return response

Next steps

For any technical questions for how to integrate specific PostHog features into Flask (such as analytics, feature flags, A/B testing, etc.), have a look at our Python SDK docs (/docs/libraries/python.md).

Alternatively, the following tutorials can help you get started:

  • How to set up analytics in Python and Flask (/tutorials/python-analytics.md)
  • How to set up feature flags in Python and Flask (/tutorials/python-feature-flags.md)
  • How to set up A/B tests in Python and Flask (/tutorials/python-ab-testing.md)

Supported versions

These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.

Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.

Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/flutter.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Flutter

This is an optional library you can install if you're working with Flutter. It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your mobile app.

PostHog supports the iOS, macOS, Android, and Web platforms.

Installation

PostHog is available for install via Pub.

Configuration

Set your PostHog project token and enable automatic event tracking if you want the library to capture lifecycle events for you.

Remember that the application lifecycle events won't have any special context set for you by the time it is initialized. If you are using a self-hosted instance of PostHog you will need to have the public hostname or IP for your instance as well.

To start, add posthog_flutter to your pubspec.yaml:

pubspec.yaml

# rest of your code

dependencies:
  flutter:
    sdk: flutter
  posthog_flutter: ^5.26.0

# rest of your code

Then complete the setup for each platform:

For Session Replay and Surveys, you must set up the SDK manually by disabling the com.posthog.posthog.AUTO_INIT mode.

Android setup

There are 2 ways of initializing the SDK, automatically and manually.

Automatically:

Add your PostHog configuration to your AndroidManifest.xml file located in the android/app/src/main:

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="your.package.name">
    <application>
        <!-- ... other configuration ... -->
        <meta-data android:name="com.posthog.posthog.PROJECT_TOKEN" android:value="<ph_project_token>" />
        <meta-data android:name="com.posthog.posthog.POSTHOG_HOST" android:value="https://us.i.posthog.com" />  <!-- usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com' -->
        <!-- com.posthog.posthog.CAPTURE_APPLICATION_LIFECYCLE_EVENTS is enabled by default since version 5.23.0 (previously named TRACK_APPLICATION_LIFECYCLE_EVENTS, which still works as an alias) -->
        <meta-data android:name="com.posthog.posthog.DEBUG" android:value="true" />
    </application>
</manifest>

Or manually (more control and more configurations available):

Add your PostHog configuration to your AndroidManifest.xml file located in the android/app/src/main:

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="your.package.name">
    <application>
        <!-- ... other configuration ... -->
        <meta-data android:name="com.posthog.posthog.AUTO_INIT" android:value="false" />
    </application>
</manifest>

In both cases, you'll also need to update the minimum Android SDK version to 23 in android/app/build.gradle:

android/app/build.gradle

// rest of your config

    defaultConfig {
        minSdkVersion 23
        // rest of your config
    }

// rest of your config
iOS setup

There are 2 ways of initializing the SDK, automatically and manually.

The SDK supports both CocoaPods and Swift Package Manager (SPM). Flutter 3.44 and later enable SPM by default. On earlier versions, or if you disabled SPM, enable it with flutter config --enable-swift-package-manager.

Automatically:

Add your PostHog configuration to the Info.plist file located in the ios/Runner directory:

ios/Runner/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- rest of your configuration -->
    <key>com.posthog.posthog.PROJECT_TOKEN</key>
    <string><ph_project_token></string>
    <key>com.posthog.posthog.POSTHOG_HOST</key>
    <string>https://us.i.posthog.com</string>
    <!-- com.posthog.posthog.CAPTURE_APPLICATION_LIFECYCLE_EVENTS is enabled by default since version 5.23.0 -->
    <key>com.posthog.posthog.DEBUG</key>
    <true/>
</dict>
</plist>

Or manually (more control and more configurations available):

Add your PostHog configuration to the Info.plist file located in the ios/Runner directory:

ios/Runner/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- rest of your configuration -->
    <key>com.posthog.posthog.AUTO_INIT</key>
    <false/>
</dict>
</plist>

In both cases, you'll need to set the minimum platform version to iOS 13.0.

For CocoaPods projects, set it in your Podfile:

ios/Podfile

platform :ios, '13.0'
# rest of your config

For Swift Package Manager projects without a Podfile, set the Minimum Deployments version to iOS 13.0 for the Runner target in Xcode (Runner > General > Minimum Deployments). After you change Minimum Deployments, regenerate the iOS project's configuration files:

Terminal

flutter build ios --config-only
Dart setup (For manual step only)

If you followed the automatic SDK setup, then there's no more configuration needed in Dart.

If you followed the manual SDK setup:

Dart

import 'package:flutter/material.dart';

import 'package:posthog_flutter/posthog_flutter.dart';

Future<void> main() async {
  // init WidgetsFlutterBinding if not yet
  WidgetsFlutterBinding.ensureInitialized();
  final config = PostHogConfig('<ph_project_token>');
  config.debug = true;
  // captureApplicationLifecycleEvents is enabled by default since version 5.23.0
  config.host = 'https://us.i.posthog.com';
  await Posthog().setup(config);
  runApp(MyApp());
}
Web setup

If your project has a web/ directory, this step is required. Posthog().setup() is a no-op on web, so a web build without the snippet below captures nothing.

Add your Web snippet (which you can find in your project settings) in the <header> of your web/index.html file. Write your project token into the snippet as a literal string. It's public, the same token ships to every visitor, and it needs no build-time or deploy-time injection:

web/index.html

<!DOCTYPE html>
<html>
  <head>
    <!-- ... other head elements ... -->

    <script async>
      !(function (t, e) {
        var o, n, p, r;
        e.__SV ||
          ((window.posthog = e),
          (e._i = []),
          (e.init = function (i, s, a) {
            function g(t, e) {
              var o = e.split(".");
              (2 == o.length && ((t = t[o[0]]), (e = o[1])),
                (t[e] = function () {
                  t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
                }));
            }
            (((p = t.createElement("script")).type = "text/javascript"),
              (p.crossOrigin = "anonymous"),
              (p.async = !0),
              (p.src = s.api_host + "/static/array.js"),
              (r = t.getElementsByTagName("script")[0]).parentNode.insertBefore(p, r));
            var u = e;
            for (
              void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
                u.people = u.people || [],
                Object.defineProperty(u, "toString", {
                  configurable: !0,
                  enumerable: !0,
                  writable: !0,
                  value: function (t) {
                    var e = "posthog";
                    return ("posthog" !== a && (e += "." + a), t || (e += " (stub)"), e);
                  },
                }),
                Object.defineProperty(u.people, "toString", {
                  configurable: !0,
                  enumerable: !0,
                  writable: !0,
                  value: function () {
                    return u.toString(1) + ".people (stub)";
                  },
                }),
                o =
                  "capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagResult reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(
                    " ",
                  ),
                n = 0;
              n < o.length;
              n++
            )
              g(u, o[n]);
            e._i.push([i, s, a]);
          }),
          (e.__SV = 1));
      })(document, window.posthog || []);
      posthog.init("<ph_project_token>", {
        api_host: "https://us.i.posthog.com", // 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
        defaults: "2026-05-30",
      });
    </script>
  </head>

  <!-- other elements -->
</html>

For more information please check: /docs/libraries/js

Capturing events

You can send custom events using capture:

Dart

await Posthog().capture(
  eventName: 'user_signed_up',
);

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Dart

await Posthog().capture(
  eventName: 'user_signed_up',
  properties: {
    'login_type': 'email',
    'is_free_trial': true
  }
);
Autocapture

PostHog autocapture automatically tracks the following events for you:

  • Application Opened - when the app is opened from a closed state or when the app comes to the foreground (e.g. from the app switcher)
  • Application Backgrounded - when the app is sent to the background by the user
  • Application Installed - when the app is installed.
  • Application Updated - when the app is updated.
  • $screen - when the user navigates, once you add the PosthogObserver
  • $exception - when the app throws exceptions.
Capturing screen views

Screen views aren't captured automatically. Add the PosthogObserver to your app yourself. Without it, your app sends no $screen events at all.

This works with any routing package, not just the plain Navigator API. Add the observer wherever your router takes navigator observers, as shown below for MaterialApp and go_router.

Note: Screen names come from each route's RouteSettings.name. Most routing packages set this for you. If yours doesn't, name your routes so $screen events are readable.

Using navigatorObservers

Add the PosthogObserver to record screen views automatically:

Dart

import 'package:flutter/material.dart';
import 'package:posthog_flutter/posthog_flutter.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // If you're using session replay, `PostHogWidget` has to be the root, and `MaterialApp` must be the child.
    return MaterialApp(
      navigatorObservers: [
        // The PosthogObserver records screen views automatically
        PosthogObserver(),
      ],
      ...
    );
  }
}

Name your routes:

Dart

...
MaterialPageRoute(builder: (context) => const HomeScreenRoute(),
  settings: const RouteSettings(name: 'Home Screen'),
),
...
Using go_router

Add the PosthogObserver to record screen views automatically:

Dart

import 'package:flutter/material.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
import 'package:go_router/go_router.dart';

// GoRouter configuration
final _router = GoRouter(
  routes: [
    ...
  ],
  // The PosthogObserver records screen views automatically
  observers: [PosthogObserver()],
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // If you're using session replay, `PostHogWidget` has to be the root, and `MaterialApp` must be the child.
    return MaterialApp.router(
      routerConfig: _router,
    );
  }
}

Name your routes:

Dart

...
GoRoute(
  name: 'Home Screen',
  ...
),
...

Identifying users

We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.

Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.

An identify call has the following arguments:

  • userId: Required. A unique identifier for your user. Typically either their email or database ID.
  • userProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)
  • userPropertiesSetOnce: Optional. Similar to userProperties. See the difference between userProperties and userPropertiesSetOnce (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)

Dart

await Posthog().identify(
  userId: emailController.text,
  userProperties: {"name": "Peter Griffin", "email": "peter@familyguy.com"},
  userPropertiesSetOnce: {"date_of_first_log_in": "2024-03-01"}
);

You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.

When you call identify, all previously tracked anonymous events will be linked to the user.

Get the current user's distinct ID

You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.

To do this, call Posthog().getDistinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Dart

await Posthog().alias(
  alias: 'distinct_id',
);

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Anonymous vs identified events

PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)

Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.

Scenarios where you want to capture identified events are:

  • Tracking logged-in users in B2B and B2C SaaS apps
  • Doing user segmented product analysis
  • Growth and marketing teams wanting to analyze the complete conversion lifecycle

Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.

Scenarios where you want to capture anonymous events are:

  • Tracking a marketing website
  • Content-focused sites
  • B2C apps where users don't sign up or log in

Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.

Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.

How to capture anonymous events

The Flutter SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/flutter.md#person-profiles-anonymous-vs-identified-persons) when initializing PostHog:

  1. personProfiles: PostHogPersonProfiles.identifiedOnly (recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.

  2. personProfiles: PostHogPersonProfiles.always - Capture identified events for all events.

  3. personProfiles: PostHogPersonProfiles.never - Capture anonymous events for all events.

For example:

Dart

final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.personProfiles = PostHogPersonProfiles.identifiedOnly;
How to capture identified events

If you've set the personProfiles config (/docs/libraries/flutter.md#person-profiles-anonymous-vs-identified-persons) to PostHogPersonProfiles.identifiedOnly (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:

  • identify() (/docs/product-analytics/identify.md)
  • alias() (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)
  • group() (/docs/product-analytics/group-analytics.md)

When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.

Alternatively, you can set personProfiles to PostHogPersonProfiles.always to capture identified events by default.

Super properties

Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.

They are set using Posthog().register, which takes a key and value, and they persist across sessions.

For example, take a look at the following call:

Dart

import 'package:posthog_flutter/posthog_flutter.dart';

await Posthog().register("team_id", 22);

The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the Posthog().register call, since they all include the specified super property.

However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use Posthog().identify. More information on this can be found on the Sending User Information section (#sending-user-information).

Removing stored super properties

Super properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a super property with events, you can use Posthog().unregister, like so:

Dart

import 'package:posthog_flutter/posthog_flutter.dart';

await Posthog().unregister("team_id");

This will remove the super property and subsequent events will not include it.

If you are doing this as part of a user logging out you can instead simply use Posthog().reset() which takes care of clearing all stored super properties and more.

Group analytics

Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for Flutter examples and implementation details.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

Boolean feature flags

Dart

final result = await Posthog().getFeatureFlagResult('flag-key');
if (result != null && result.enabled) {
  // Do something differently for this user

  // Optional: fetch the payload from the same evaluation result
  final matchedFlagPayload = result.payload;
}
Multivariate feature flags

Dart

final result = await Posthog().getFeatureFlagResult('flag-key');
if (result != null && result.variant == 'variant-key') { // replace 'variant-key' with the key of your variant
  // Do something differently for this user

  // Optional: fetch the payload from the same evaluation result
  final matchedFlagPayload = result.payload;
}
Ensuring flags are loaded before usage

To use the onFeatureFlags callback, you must set up the SDK manually (#installation). On Android and iOS, disable com.posthog.posthog.AUTO_INIT first.

Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.

This means that for most screens, the feature flags are available immediately – except for the first time a user visits.

To handle this, you can use the onFeatureFlags callback in your config to be notified when flags are loaded:

Dart

final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.onFeatureFlags = () async {
  if (await Posthog().isFeatureEnabled('flag-key')) {
    // do something
  }
};
await Posthog().setup(config);
Reloading feature flags

Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:

Dart

await Posthog().reloadFeatureFlags();
Bootstrapping flags

Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.

To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.

Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires the Flutter SDK 5.31.0+):

Dart

final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.bootstrap = PostHogBootstrapConfig(
  distinctId: 'distinct_id_of_your_user',
  isIdentifiedId: true,
  featureFlags: {
    'flag-1': true,
    'variant-flag': 'control',
  },
);
await Posthog().setup(config);

The values are forwarded to the native iOS and Android SDKs:

  • Bootstrapped identity applies during setup. On a fresh install, setting it before setup() means events captured synchronously during initialization (like Application Installed) carry your distinct ID instead of the SDK-generated UUID.
    • An anonymous bootstrap (isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it.
    • An identified bootstrap (isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting $identify; a different anonymous ID is merged via identify() when person profiles are enabled. This emits $identify unless capturing is opted out. A different, already-identified person is left untouched.
  • Bootstrapped flags are served until the first /flags response, then replaced. A complete /flags response takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: a true boolean or a non-empty variant string. A false or empty value is dropped, matching posthog-js. Seed payloads with the separate featureFlagPayloads option. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared on reset().

The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.

On Flutter web, bootstrap is not applied, so configure it in your posthog.init({...}) snippet instead. See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.

Setting properties for flag evaluation

If a flag targets person or group properties, you can send those properties inline with the next flag evaluation request instead of waiting for a $set event to be ingested. This avoids the race where a flag returns a stale value right after you set a property.

Dart

// Person properties — included in the next flag evaluation request
await Posthog().setPersonPropertiesForFlags({
  'storefront_country': 'US',
  'is_beta_user': true,
});

// Group properties
await Posthog().setGroupPropertiesForFlags('company', {'plan': 'enterprise'});

By default these reload feature flags, and the returned Future completes once the reload finishes, so the next getFeatureFlag reflects the new properties. Pass reloadFeatureFlags: false to set several properties before reloading. Use resetPersonPropertiesForFlags() and resetGroupPropertiesForFlags() to clear them. See property overrides for flag evaluation (/docs/feature-flags/property-overrides.md) for details.

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See feature flag code examples (/docs/feature-flags/adding-feature-flag-code?tab=Flutter.md) for Flutter implementation details.

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Error tracking

To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).

Logs

To set up logs (/docs/logs.md) in your Flutter app, follow the Flutter logs installation guide (/docs/logs/installation/flutter.md). The SDK exposes Posthog().logger.{trace,debug,info,warn,error,fatal} (and Posthog().captureLog for full control) for sending structured records to PostHog Logs, with batching, offline persistence, and a rate cap built in.

Session replay

Note: Session replay is supported on Flutter Web, Android, and iOS.

To set up session replay web (/docs/session-replay.md) or mobile session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the Flutter SDK, follow the additional installation instructions (/docs/session-replay/installation/flutter.md), and enable "Record user sessions" in your project settings and enable the sessionReplay option.

If you're using Flutter Web, also enable the Canvas capture (/docs/session-replay/canvas-recording.md) in your project settings. This is needed as Flutter renders your app using a browser canvas element.

On Flutter Web, masking (maskAllTexts, maskAllImages, PostHogMaskWidget) applies inside that canvas too — declare session_recording.canvasCapture.maskRegionsFn in the posthog.init call in your web/index.html to enable it (requires PostHog Flutter SDK 5.34.0+ and posthog-js 1.408.0+). See masking on Flutter Web (/docs/session-replay/privacy.md) under the Flutter tab.

Surveys

Note: Surveys are supported in Flutter for Web, iOS, and Android platforms.

Surveys (/docs/surveys.md) launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.

Push notifications

The Flutter SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).

Flush

You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.

You can also configure the flush interval with flushInterval (default 30 seconds), after which queued events are sent regardless of how many have been gathered:

Dart

final config = PostHogConfig('<ph_project_token>');
config.flushAt = 20;
config.flushInterval = const Duration(seconds: 30);

You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:

Dart

await Posthog().flush();

Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.

Offline behavior

The PostHog Flutter SDK will continue to capture events when the device is offline for Android and Apple platforms. The events are stored in a queue in the device's file storage and are flushed when the device is online.

  • The queue has a maximum size defined by maxQueueSize in the configuration.
  • When the queue is full, the oldest event is deleted first.
  • The queue is flushed when the app is restarted and the device is online.

Opt out of data capture

You can disable data collection for a user at any time using the disable() method:

Dart

await Posthog().disable();

This prevents any future events from being sent. It doesn't remove events already captured for the user. To opt the user back in:

Dart

await Posthog().enable();

To check if a user is opted out:

Dart

await Posthog().isOptOut();

Amending or dropping events

Since version 5.13.0, you can provide beforeSend callbacks when initializing the SDK to amend or drop events before they are sent to PostHog.

Redacting information in events

beforeSend gives you one place to edit or redact information before it is sent to PostHog. For example:

Dart

final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';

config.beforeSend = [
  (event) {
    // Redact email from properties
    if (event.properties?['email'] != null) {
      event.properties?['email'] = '***@***.***';
    }
    return event;
  },
];

await Posthog().setup(config);
Dropping events

Return null from the callback to drop the event:

Dart

config.beforeSend = [
  (event) {
    // Drop events you don't want to send
    if (event.event == 'ignored_event') {
      return null;
    }
    return event;
  },
];
Filtering autocaptured screens

You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.

Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.

Dart

const ignoredScreens = {'Splash', 'Debug'};

config.beforeSend = [
  (event) {
    final screenName = event.properties?['$screen_name'];
    if (event.event == '$screen' && ignoredScreens.contains(screenName)) {
      return null;
    }
    return event;
  },
];
Limitations

The beforeSend callbacks only apply to events captured via Dart APIs:

  • Posthog().capture() - custom events
  • Posthog().screen() - screen events (event name is $screen)
  • Posthog().captureException() - exception events (event name is $exception)

They do not intercept native-initiated events such as:

  • Session replay events ($snapshot)
  • Application lifecycle events (Application Opened, etc.)

Additionally, only user-provided properties are available in the callback. System properties (like $device_type, $session_id) are added by the native SDK at a later stage.

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.

You can enable debug mode during initialization by setting the debug option to true in the PostHogConfig object. A common pattern is to set this to true in development environments only using environment variables.

Dart

final config = PostHogConfig('<ph_project_token>');
config.host = 'https://us.i.posthog.com';
config.debug = true;
await Posthog().setup(config);

This will enable verbose logs about the inner workings of the SDK.

You can also enable debug by calling the Posthog().debug() method in your code.

Dart

await Posthog().debug(true);
await Posthog().debug(false);
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/go.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Go

This library uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server-side application that needs performance.

Installation

Terminal

go get github.com/posthog/posthog-go

Go

package main

import (
    "os"
    "github.com/posthog/posthog-go"
)

func main() {
    client, _ := posthog.NewWithConfig(
        os.Getenv("POSTHOG_API_KEY"),
        posthog.Config{
            PersonalApiKey: "your personal API key", // Optional, but much more performant.  If this token is not supplied, then fetching feature flag values will be slower.
            Endpoint:       "https://us.i.posthog.com",
        },
    )
    defer client.Close()
    // run commands
}

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Capturing events

You can send custom events using capture:

Go

client.Enqueue(posthog.Capture{
  DistinctId: "distinct_id_of_the_user",
  Event: "user_signed_up",
})

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Tip: You can define event schemas with typed properties and generate type-safe code using schema management (/docs/product-analytics/schema-management.md).

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Go

client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id_of_the_user",
    Event:      "user_signed_up",
    Properties: posthog.NewProperties().
      Set("login_type", "email").
      Set("is_free_trial", true),
  })
Capturing pageviews

If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:

Go

client.Enqueue(posthog.Capture{
  DistinctId: "distinct_id_of_the_user",
  Event:      "$pageview",
  Properties: posthog.NewProperties().
    Set("$current_url", "https://example.com"),
})

Person profiles and properties

For backward compatibility, the Go SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:

Go

client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id",
    Event:      "event_name",
    Properties: map[string]interface{}{
        "$set": map[string]interface{}{
            "name": "Max Hedgehog",
        },
        "$set_once": map[string]interface{}{
            "initial_url": "/blog",
        },
    },
})

For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).

To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:

Go

client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id",
    Event:      "event_name",
    Properties: map[string]interface{}{
        "$process_person_profile": false,
    },
})

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Go

client.Enqueue(posthog.Alias{
  DistinctId: "distinct_id",
  Alias: "alias_id",
})

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Request context

Use request context to apply a distinct ID, session ID, and common request properties to capture and exception events inside a net/http request. This is useful when connecting frontend activity to backend events, session replay, error tracking, and feature flag evaluation.

If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Go backend hostname so browser requests include the session and distinct ID headers. Then wrap your handler with NewRequestContextMiddleware and use the context-aware helpers:

Go

handler := posthog.NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    flags, err := posthog.EvaluateFlagsWithContext(r.Context(), client, posthog.EvaluateFlagsPayload{})
    if err != nil {
        // If neither the request context nor payload has a distinct ID,
        // err is posthog.ErrNoDistinctID.
    }

    _ = posthog.EnqueueWithContext(r.Context(), client, posthog.Capture{
        Event: "checkout started",
        Flags: flags,
    })
}))

The middleware adds $current_url, $request_method, $request_path, $user_agent, and $ip properties. By default, it also reads X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped defaults. Explicit DistinctId values and $session_id properties passed to captures take precedence over request context.

If request context is attached but no distinct ID is available, capture and exception events are sent as personless events (/docs/data/anonymous-vs-identified-events.md) with an auto-generated UUID and $process_person_profile: false. Calls to Enqueue without request context still require DistinctId. EvaluateFlagsWithContext uses the request-scoped distinct ID when EvaluateFlagsPayload.DistinctId is empty, but it never generates personless IDs and returns ErrNoDistinctID when no distinct ID is available.

Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side decisions, pass an authenticated DistinctId explicitly or attach one to the request context:

Go

ctx := posthog.WithRequestContext(r.Context(), posthog.RequestContext{
    DistinctId: user.ID,
})

To ignore tracing headers while keeping request metadata, disable tracing header capture:

Go

handler := posthog.NewRequestContextMiddleware(
    next,
    posthog.WithCaptureTracingHeaders(false),
)

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in Go:

Step 1: Evaluate flags once

Call client.EvaluateFlags() once for the user, then read values from the returned snapshot.

Boolean feature flags

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "distinct_id_of_your_user",
})
if err != nil {
    // Handle error (e.g. capture error and fallback to default behavior)
}

if flags.IsEnabled("flag-key") {
    // Do something differently for this user
    // Optional: fetch the payload
    matchedFlagPayload := flags.GetFlagPayload("flag-key")
}
Multivariate feature flags

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "distinct_id_of_your_user",
})
if err != nil {
    // Handle error (e.g. capture error and fallback to default behavior)
}

enabledVariant := flags.GetFlag("flag-key")

if enabledVariant == "variant-key" { // replace "variant-key" with the key of your variant
    // Do something differently for this user
    // Optional: fetch the payload
    matchedFlagPayload := flags.GetFlagPayload("flag-key")
}

flags.GetFlag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.

Note: client.IsFeatureEnabled(), client.GetFeatureFlag(), client.GetFeatureFlagPayload(), and Capture.SendFeatureFlags still work during the migration period, but they're deprecated. Prefer EvaluateFlags() for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Pass the evaluated flags snapshot to Capture

Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "distinct_id_of_your_user",
})
if err != nil {
    // Handle error
}

if flags.IsEnabled("flag-key") {
    // Do something differently for this user
}

client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id_of_your_user",
    Event:      "event_name",
    Flags:      flags,
})

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, pass a filtered snapshot:

Go

// Attach only flags accessed with IsEnabled() or GetFlag() before this call
client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id_of_your_user",
    Event:      "event_name",
    Flags:      flags.OnlyAccessed(),
})

// Attach only specific flags
client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id_of_your_user",
    Event:      "event_name",
    Flags:      flags.Only([]string{"checkout-flow", "new-dashboard"}),
})

OnlyAccessed() is order-dependent. If you call it before accessing any flags with IsEnabled() or GetFlag(), no feature flag properties are attached.

Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

Go

client.Enqueue(posthog.Capture{
    DistinctId: "distinct_id_of_your_user",
    Event:      "event_name",
    Properties: posthog.NewProperties().
        Set("$feature/feature-flag-key", "variant-key"), // replace feature-flag-key with your flag key. Replace "variant-key" with the key of your variant
})
Evaluating only specific flags

By default, EvaluateFlags() evaluates every flag for the user. If you only need a few flags, pass FlagKeys to request only those flags:

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "distinct_id_of_your_user",
    FlagKeys:   []string{"checkout-flow", "new-dashboard"},
})
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With EvaluateFlags(), the SDK sends this event when you call flags.IsEnabled() or flags.GetFlag() for a flag.

The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.

flags.GetFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for OnlyAccessed().

Advanced: Overriding server properties

Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.

You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.

For example:

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "distinct_id_of_the_user",
    Groups: posthog.NewGroups().
        Set("your_group_type", "your_group_id").
        Set("another_group_type", "your_group_id"),
    PersonProperties: posthog.NewProperties().
        Set("property_name", "value"),
    GroupProperties: map[string]posthog.Properties{
        "your_group_type": posthog.NewProperties().
            Set("group_property_name", "value"),
        "another_group_type": posthog.NewProperties().
            Set("group_property_name", "value"),
    },
})
if err != nil {
    // Handle error
}

if flags.IsEnabled("flag-key") {
    // Do something differently for this user
}
Overriding GeoIP properties

By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.

You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.

The following GeoIP properties can be overridden:

  • $geoip_country_code
  • $geoip_country_name
  • $geoip_city_name
  • $geoip_city_confidence
  • $geoip_continent_code
  • $geoip_continent_name
  • $geoip_latitude
  • $geoip_longitude
  • $geoip_postal_code
  • $geoip_subdivision_1_code
  • $geoip_subdivision_1_name
  • $geoip_subdivision_2_code
  • $geoip_subdivision_2_name
  • $geoip_subdivision_3_code
  • $geoip_subdivision_3_name
  • $geoip_time_zone

Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.

Request timeout

You can configure the FeatureFlagRequestTimeout parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.

Go

// import "time"

client, _ := posthog.NewWithConfig(
    os.Getenv("<ph_project_token>"),
    posthog.Config{
        PersonalApiKey:            "your personal API key", // Optional, but much more performant. If this token is not supplied, then fetching feature flag values will be slower.
        Endpoint:                  "https://us.i.posthog.com",
        FeatureFlagRequestTimeout: 3 * time.Second, // Defaults to 3 seconds.
    },
)
Local Evaluation

Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.

It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.

For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:

Go

flags, err := client.EvaluateFlags(posthog.EvaluateFlagsPayload{
    DistinctId: "user_distinct_id",
})
if err != nil {
    // Handle error (e.g. capture error and fallback to default behavior)
}
variant := flags.GetFlag("experiment-feature-flag-key")

if variant == "variant-name" {
    // Do something
}

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Error tracking

You can capture exceptions and errors using the Go SDK. There are two approaches:

Direct capture using NewDefaultException, which automatically generates a stack trace:

Go

exception := posthog.NewDefaultException(
    time.Now(),
    "user_distinct_id",
    "DatabaseError",      // type - rendered as title in the UI
    "connection refused",  // value - rendered as description in the UI
)
client.Enqueue(exception)

Automatic capture using the SlogCaptureHandler, which wraps Go's log/slog and sends log records at warning level and above as exceptions:

Go

baseHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})
logger := slog.New(posthog.NewSlogCaptureHandler(baseHandler, client,
    posthog.WithDistinctIDFn(func(ctx context.Context, r slog.Record) string {
        return "user_distinct_id"
    }),
))

// Automatically captured as an exception in PostHog
logger.Warn("Something broke", "error", fmt.Errorf("connection refused"))

For the full setup guide, see the Go error tracking installation docs (/docs/error-tracking/installation/go.md).

Group analytics

Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

  • Send an event associated with a group

Go

client.Enqueue(posthog.Capture{
    DistinctId: "user_distinct_id",
    Event:      "some_event",
    Groups: posthog.NewGroups().
        Set("company", "company_id_in_your_db"),
})
  • Update properties on a group

Go

client.Enqueue(posthog.GroupIdentify{
    Type: "company",
    Key:  "company_id_in_your_db",
    Properties: posthog.NewProperties().
        Set("name", "Awesome Inc.").
        Set("employees", 11),
})

The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.

Thank you

This library is largely based on the analytics-go package.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/identify-users.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Identify users

Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.

This is straightforward to do when capturing backend events (/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a distinct_id, which is a required argument.

However, in the frontend of a web (/docs/libraries/js/usage.md#capturing-events) or mobile app (/docs/libraries/ios.md#capturing-events), a distinct_id is not a required argument — PostHog's SDKs will generate an anonymous distinct_id for you automatically and you can capture events anonymously, provided you use the appropriate configuration (/docs/libraries/js/usage.md#capturing-anonymous-events).

To link events to specific users, call identify:

Web
posthog.identify(
  'distinct_id',  // Replace 'distinct_id' with your user's unique identifier
  { email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
);
Android
PostHog.identify(
    distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
    // optional: set additional person properties
    userProperties = mapOf(
        "name" to "Max Hedgehog",
        "email" to "max@hedgehogmail.com"
    )
)
iOS
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
                           userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
React Native
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
    email: 'max@hedgehogmail.com', // optional: set additional person properties
    name: 'Max Hedgehog'
})
Dart
await Posthog().identify(
  userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
  userProperties: {
    'email': 'max@hedgehogmail.com', // optional: set additional person properties
    'name': 'Max Hedgehog',
  },
);

Events captured after calling identify are identified events and this creates a person profile if one doesn't exist already.

Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.

How identify works

When a user starts browsing your website or app, PostHog automatically assigns them an anonymous ID, which is stored locally.

Provided you've configured persistence (/docs/libraries/js/persistence.md) to use cookies or localStorage, this enables us to track anonymous users – even across different sessions.

By calling identify with a distinct_id of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.

Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.

This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.

Using identify in the backend

Although you can call identify using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling identify only updates person profiles.

Best practices when using identify

1. Call identify as soon as you're able to

In your frontend, you should call identify as soon as you're able to.

Typically, this is every time your app loads for the first time, and directly after your users log in.

This ensures that events sent during your users' sessions are correctly associated with them.

You only need to call identify once per session, and you should avoid calling it multiple times unnecessarily.

If you call identify multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.

Identify users when the web SDK loads

If your app already knows the signed-in user when you initialize the JavaScript web SDK, the loaded callback (/docs/libraries/js/config.md) is a convenient place to call identify. This identifies the user as soon as the SDK has loaded:

Web

posthog.init('<ph_project_token>', {
    api_host: 'https://us.i.posthog.com',
    defaults: '2026-05-30',
    loaded: (posthog) => {
        if (currentUser?.id) {
            posthog.identify(currentUser.id, {
                email: currentUser.email,
                name: currentUser.name,
            })
        }
    },
})

In this example, currentUser represents user data already available from your authentication system. If your app loads the user asynchronously, call posthog.identify() as soon as that data becomes available instead.

2. Use unique strings for distinct IDs

If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:

  • Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
  • There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like null, true, or distinctId.

PostHog also has built-in protections to stop the most common distinct ID mistakes.

3. Reset after logout

If a user logs out on your frontend, you should call reset() to unlink any future events made on that device with that user.

This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.

We strongly recommend you call reset on logout even if you don't expect users to share a computer.

You can do that like so:

Web
posthog.reset()
iOS
PostHogSDK.shared.reset()
Android
PostHog.reset()
React Native
posthog.reset()
Dart
await Posthog().reset();

If you also want to reset the device_id so that the device will be considered a new device in future events, you can pass true as an argument:

Web

posthog.reset(true)
4. Person profiles and properties

You'll notice that one of the parameters in the identify method is a properties object.

This enables you to set person properties (/docs/product-analytics/person-properties.md).

Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.

Person properties can also be set being adding a $set property to a event capture call.

`$set` and `$set_once` aren't stored on events

These properties only tell PostHog how to update person data during ingestion — they aren't kept on the stored event, so you can't filter, break down, or query events by them. To query the values you set, use person properties (/docs/product-analytics/person-properties.md) instead.

See our person properties docs (/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.

We recommend you call identify as soon as you're able (#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.

This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:

  • Onboarding and signup flows before authentication.
  • Unauthenticated web pages redirecting to authenticated mobile apps.
  • Authenticated web apps prompting an app download.

In these cases, you can use a deep link on Android and universal links on iOS to identify users.

  1. Use posthog.get_distinct_id() to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
  2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
  3. When the user is redirected to the app, parse the deep link and handle the following cases:
  • The mobile app is already authenticated. In this case, call posthog.alias() (/docs/libraries/js/usage.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person.
  • The mobile app is unauthenticated. In this case, call posthog.identify() (/docs/libraries/js/usage.md#identifying-users) with the distinct ID from the web so pre-login mobile events stay connected to the web session. When the user later logs in on mobile, call identify() again with your canonical user ID.

As long as you associate the distinct IDs with posthog.identify() or posthog.alias(), you can track events generated across platforms.

Here's an example implementation for handling deep links from web to mobile:

iOS
import PostHog

class DeepLinkIdentityManager {
    static let shared = DeepLinkIdentityManager()

    // MARK: - Deep Link Received

    func handleDeepLink(_ url: URL, isAuthenticatedOnMobile: Bool) {
        guard let webDistinctId = URLComponents(url: url, resolvingAgainstBaseURL: true)?
            .queryItems?.first(where: { $0.name == "ph_distinct_id" })?.value else {
            return
        }

        if isAuthenticatedOnMobile {
            // The mobile app already knows the current user.
            // Alias the incoming web distinct ID to that user.
            PostHogSDK.shared.alias(webDistinctId)
        } else {
            // Reuse the web distinct ID until login on mobile.
            PostHogSDK.shared.identify(webDistinctId)
        }
    }

    // MARK: - Login/Signup

    func handleLogin(canonicalUserId: String) {
        // Switch from the web distinct ID (or a mobile anon ID)
        // to your canonical user ID.
        PostHogSDK.shared.identify(canonicalUserId)
        // Set user properties, track signup event, etc.
    }

    func handleLogout() {
        PostHogSDK.shared.reset()
    }
}
Android
import android.net.Uri
import com.posthog.PostHog

object DeepLinkIdentityManager {

    // Deep Link Received

    fun handleDeepLink(uri: Uri, isAuthenticatedOnMobile: Boolean) {
        val webDistinctId = uri.getQueryParameter("ph_distinct_id") ?: return

        if (isAuthenticatedOnMobile) {
            // The mobile app already knows the current user.
            // Alias the incoming web distinct ID to that user.
            PostHog.alias(webDistinctId)
        } else {
            // Reuse the web distinct ID until login on mobile.
            PostHog.identify(webDistinctId)
        }
    }

    // Login/Signup

    fun handleLogin(canonicalUserId: String) {
        // Switch from the web distinct ID (or a mobile anon ID)
        // to your canonical user ID.
        PostHog.identify(canonicalUserId)
        // Set user properties, track signup event, etc.
    }

    fun handleLogout() {
        PostHog.reset()
    }
}

Further reading

  • Identifying users docs (/docs/product-analytics/identify.md)
  • How person processing works (/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
  • An introductory guide to identifying users in PostHog (/tutorials/identifying-users-guide.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/ios.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

iOS

The PostHog iOS SDK is a library that you can use to track events, identify users, record session replays, evaluate feature flags, run experiments, build surveys, and more.

This page shows you how to install the SDK and get started with it. If you've already installed the SDK, you can skip ahead to learn about using the features (/docs/libraries/ios/usage.md) and configuring the SDK (/docs/libraries/ios/configuration.md).

Installation

PostHog is available through CocoaPods or you can add it as a Swift Package Manager based dependency.

CocoaPods

Podfile

pod "PostHog", "~> 3.59.3"
Swift Package Manager

Add PostHog as a dependency in your Xcode project "Package Dependencies" and select the project target for your app, as appropriate.

For a Swift Package Manager based project, add PostHog as a dependency in your Package.swift file's Package dependencies section:

Package.swift

dependencies: [
  .package(url: "https://github.com/PostHog/posthog-ios.git", from: "3.59.3")
],

and then as a dependency for the Package target utilizing PostHog:

Package.swift

.target(
    name: "myApp",
    dependencies: [.product(name: "PostHog", package: "posthog-ios")]),
Configuration

Configuration is done through the PostHogConfig object. Here's a basic configuration example to get you started.

You can find more advanced configuration options in the configuration page (/docs/libraries/ios/configuration.md).

UIKit

Swift

import Foundation
import PostHog
import UIKit

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
        // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
        let POSTHOG_HOST = "https://us.i.posthog.com"

        let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)
        PostHogSDK.shared.setup(config)

        return true
    }
}

SwiftUI

Swift

import SwiftUI
import PostHog

@main
struct YourGreatApp: App {

    // Add PostHog to your app's initializer.
    // If using UIApplicationDelegateAdaptor, see the UIKit tab.

    init() {

        let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
        // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
        let POSTHOG_HOST = "https://us.i.posthog.com"

        let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)
        PostHogSDK.shared.setup(config)

    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Offline behavior

The PostHog iOS SDK will continue to capture events when the device is offline. The events are stored in a queue in the device's file storage and are flushed when the device is online.

  • The queue has a maximum size defined by maxQueueSize in the configuration.
  • When the queue is full, the oldest event is deleted first.
  • The queue is flushed only when the device is online.

You can find the options for configuring the offline behavior in the configuration page (/docs/libraries/ios/configuration.md#all-configuration-options).

Using PostHog with application extensions

PostHog supports sharing analytics data between your main app and application extensions (such as widgets, app clips, share extensions, and custom keyboards) through App Groups. This ensures that users maintain the same identity across all parts of your app ecosystem.

By default, each iOS app target stores its data in its own sandboxed directory. This means that if a user interacts with your main app and then uses a widget or extension, PostHog would treat them as two different anonymous users. This can lead to:

  • Inflated user counts in your analytics
  • Fragmented user journeys
  • Difficulty tracking feature adoption across your app ecosystem

Learn more about setting up app groups (/docs/libraries/ios/configuration.md#setting-up-app-groups).

Method swizzling

The PostHog iOS SDK uses method swizzling to intercept and modify method calls at runtime to provide advanced features like screen view tracking, element interactions, session replay, surveys, and more.

Method swizzling is particularly important for accurate session metrics tracking. When disabled, the SDK cannot capture optimal session metrics.

You can learn more about configuring method swizzling in the configuration page (/docs/libraries/ios/configuration.md#method-swizzling).

Push notifications

The iOS SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).

Next steps

Now that you've installed the SDK, explore the configuration and usage options:

  • Learn about using all of the features of PostHog with iOS SDK (/docs/libraries/ios/usage.md)
  • Learn about configuration options for the iOS SDK (/docs/libraries/ios/configuration.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/laravel.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Laravel

PostHog integrates with Laravel through the PostHog PHP SDK (/docs/libraries/php.md). This page covers Laravel-specific setup. For SDK features such as event capture, identifying users, feature flags, group analytics, and configuration options, see the PHP SDK docs (/docs/libraries/php.md).

Installation

Install the PHP SDK as described in the PHP installation guide (/docs/libraries/php.md#installation), then add your project token and host to .env:

.env

POSTHOG_API_KEY=<ph_project_token>
POSTHOG_HOST=https://us.i.posthog.com

Add PostHog to Laravel's services config:

config/services.php

'posthog' => [
    'api_key' => env('POSTHOG_API_KEY'),
    'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
],

Initialize PostHog in the boot method of app/Providers/AppServiceProvider.php:

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use PostHog\PostHog;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if (! config('services.posthog.api_key')) {
            return;
        }

        PostHog::init(
            config('services.posthog.api_key'),
            [
                'host' => config('services.posthog.host'),
            ]
        );
    }
}

Request context middleware

Client SDKs such as PostHog JS (/docs/libraries/js.md) can send tracing headers to your Laravel backend. Configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Laravel backend hostname so browser requests include the session and distinct ID headers.

The PHP SDK can read X-PostHog-Distinct-Id and X-PostHog-Session-Id headers and apply them to events captured during the request. Tracing headers are client-controlled analytics context, not authentication or authorization. For security-sensitive server-side events or decisions, pass an authenticated distinctId explicitly, such as auth()->id(). For the lower-level context APIs, see the PHP request context docs (/docs/libraries/php.md#request-context).

Add middleware like this:

app/Http/Middleware/PostHogRequestContext.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use PostHog\PostHog;
use Symfony\Component\HttpFoundation\Response;

final class PostHogRequestContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! config('services.posthog.api_key')) {
            return $next($request);
        }

        $context = PostHog::contextFromHeaders($request->headers->all());

        $context['properties'] = array_merge(
            $context['properties'] ?? [],
            array_filter([
                '$current_url' => $request->fullUrl(),
                '$request_method' => $request->method(),
                '$request_path' => $request->getPathInfo(),
                '$user_agent' => $request->userAgent(),
                '$ip' => $request->ip(),
            ], static fn ($value): bool => $value !== null && $value !== '')
        );

        return PostHog::withContext(
            $context,
            static fn (): Response => $next($request),
            ['fresh' => true]
        );
    }
}

Register this middleware using your Laravel version's normal middleware registration.

Error tracking in Laravel

The PHP SDK supports error tracking (/docs/libraries/php.md#error-tracking), but Laravel handles most request exceptions before they become uncaught PHP exceptions. Capture Laravel-reported exceptions explicitly.

In Laravel 11 and later, add a report callback in bootstrap/app.php:

bootstrap/app.php

use Illuminate\Foundation\Configuration\Exceptions;
use PostHog\PostHog;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->report(function (Throwable $e): void {
        if (! config('services.posthog.api_key')) {
            return;
        }

        PostHog::captureException(
            $e,
            auth()->id() !== null ? (string) auth()->id() : null,
            [
                '$current_url' => request()->fullUrl(),
                '$request_method' => request()->method(),
            ]
        );
    });
})

For older Laravel versions, call PostHog::captureException() from your exception handler's report method.

Long-running processes

In normal PHP request lifecycles, queued events flush when the client is destroyed. In long-running Laravel processes such as queue workers, Horizon, or Octane, call PostHog::flush() after capturing important events or at the end of a job/request.

If you prefer immediate delivery in queue workers, configure the PHP SDK with batch_size set to 1 for those workers:

PHP

PostHog::init(
    '<ph_project_token>',
    [
        'host' => config('services.posthog.host'),
        'batch_size' => 1,
    ]
);

Next steps

See the PHP SDK docs (/docs/libraries/php.md) for usage examples and the full API reference.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/next-js.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Next.js

PostHog makes it easy to get data about traffic and usage of your Next.js app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.

This guide walks you through integrating PostHog into your Next.js app using the React (/docs/libraries/react.md) and the Node.js (/docs/libraries/node.md) SDKs.

You can see a working example of this integration in our Next.js demo app.

Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.

Try @posthog/next (pre-release): A simplified Next.js integration with synchronized client/server identity, server-side flag bootstrapping, and a built-in API proxy. Read the setup guide → (/docs/libraries/next-js/posthog-next.md)

Prerequisites

To follow this guide along, you need:

  1. A PostHog instance (either Cloud or self-hosted (/docs/self-host.md))
  2. A Next.js application

Beta: integration via LLM

Install PostHog for Next.js in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.

npx @posthog/wizard

Learn more (/wizard.md)

Or, to integrate manually, continue with the rest of this guide.

Client-side setup

Install posthog-js using your package manager:

npm
npm install --save posthog-js
Yarn
yarn add posthog-js
pnpm
pnpm add posthog-js
Bun
bun add posthog-js

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your project settings.

.env.local

NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

These values need to start with NEXT_PUBLIC_ to be accessible on the client-side.

Integration

Next.js provides the instrumentation-client.ts|js file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:

instrumentation-client.js
import posthog from 'posthog-js'

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  defaults: '2026-05-30'
});
instrumentation-client.ts
import posthog from 'posthog-js'

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
  api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  defaults: '2026-05-30'
});

Bootstrapping with instrumentation-client

When using instrumentation-client, the values you pass to posthog.init remain fixed for the entire session. This means bootstrapping only works if you evaluate flags before your app renders (for example, on the server).

If you need flag values after the app has rendered, you’ll want to:

  • Evaluate the flag on the server and pass the value into your app, or
  • Evaluate the flag in an earlier page/state, then store and re-use it when needed.

Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same distinct_id across client and server.

See the bootstrapping guide (/docs/feature-flags/bootstrapping.md) for more information.

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Linking client and server events

Next.js apps usually capture on both sides. To keep them on the same person, use the same distinct ID in both, and let the browser tell your server which one that is.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Accessing PostHog

Once initialized in instrumentation-client.js|ts, import posthog from posthog-js anywhere and call the methods you need on the posthog object.

JavaScript

"use client";
import posthog from "posthog-js";

export default function Home() {
  return (
    <div>
      <button onClick={() => posthog.capture("test_event")}>Click me for an event</button>
    </div>
  );
}
Using React hooks

The React feature flag hooks (/docs/libraries/react.md#feature-flags) work automatically when PostHog is initialized via instrumentation-client.ts. The hooks use the initialized posthog-js singleton:

JavaScript

"use client";
import { useFeatureFlagEnabled } from "@posthog/react";

export default function FeatureComponent() {
  const showNewFeature = useFeatureFlagEnabled("new-feature");

  return showNewFeature ? <NewFeature /> : <OldFeature />;
}
Usage

See the React SDK docs (/docs/libraries/react.md) for examples of how to use:

  • posthog-js functions like custom event capture, user identification, and more. (/docs/libraries/react.md#using-posthog-js-functions)
  • Feature flags including variants and payloads. (/docs/libraries/react.md#feature-flags)

You can also read the full posthog-js documentation (/docs/libraries/js/usage.md) for all the usable functions.

Server-side analytics

Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the Node SDK (/docs/libraries/node.md).

First, install the posthog-node library:

npm
npm install posthog-node --save
Yarn
yarn add posthog-node
pnpm
pnpm add posthog-node
Bun
bun add posthog-node
Router-specific instructions

App router

For the app router, we can initialize the posthog-node SDK once with a PostHogClient function, and import it into files.

This enables us to send events and fetch data from PostHog on the server – without making client-side requests.

JavaScript

// app/posthog.js
import { PostHog } from 'posthog-node'

export default function PostHogClient() {
  const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
    host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
    flushAt: 1,
    flushInterval: 0
  })
  return posthogClient
}

Note: Because server-side functions in Next.js can be short-lived, we set flushAt to 1 and flushInterval to 0.

  • flushAt sets how many capture calls we should flush the queue (in one batch).
  • flushInterval sets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to call await posthog.shutdown() once done.

To use this client, we import it into our pages and call it with the PostHogClient function:

JavaScript

import Link from 'next/link'
import PostHogClient from '../posthog'

export default async function About() {

  const posthog = PostHogClient()
  const flags = await posthog.getAllFlags(
    'user_distinct_id' // replace with a user's distinct ID
  );
  await posthog.shutdown()

  return (
    <main>
      <h1>About</h1>
      <Link href="/">Go home</Link>
      { flags['main-cta'] &&
        <Link href="http://posthog.com/">Go to PostHog</Link>
      }
    </main>
  )
}

Pages router

For the pages router, we can use the getServerSideProps function to access PostHog on the server-side, send events, evaluate feature flags, and more.

This looks like this:

JavaScript

// pages/posts/[id].js
import { useContext, useEffect, useState } from 'react'
import { getServerSession } from "next-auth/next"
import { authOptions } from '@/lib/auth'
import { PostHog } from 'posthog-node'

export default function Post({ post, flags }) {
  const [ctaState, setCtaState] = useState()

  useEffect(() => {
    if (flags) {
      setCtaState(flags['blog-cta'])
    }
  })

  return (
    <div>
      <h1>{post.title}</h1>
      <p>By: {post.author}</p>
      <p>{post.content}</p>
      {ctaState &&
        <p><a href="/">Go to PostHog</a></p>
      }
      <button onClick={likePost}>Like</button>
    </div>
  )
}

export async function getServerSideProps(ctx) {

  // Pass authOptions, or your session callbacks don't run.
  const session = await getServerSession(ctx.req, ctx.res, authOptions)
  let flags = null

  if (session) {
    const client = new PostHog(
      process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN,
      {
        host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
      }
    )

    // A stable ID from your auth system, not an email. See the note below.
    const distinctId = session.user.id

    flags = await client.getAllFlags(distinctId);
    client.capture({
      distinctId,
      event: 'loaded blog article',
      properties: {
        $current_url: ctx.req.url,
      },
    });

    await client.shutdown()
  }

  const { posts } = await import('../../blog.json')
  const post = posts.find((post) => post.id.toString() === ctx.params.id)
  return {
    props: {
      post,
      flags
    },
  }
}

Note: next-auth doesn't put a user ID on the session by default. Its session is { name, email, image }, so session.user.id is undefined until you add it yourself with a session callback in your authOptions:

JavaScript

// lib/auth.js
export const authOptions = {
  callbacks: {
    session({ session, token, user }) {
      // JWT sessions (the default) carry the user ID in token.sub.
      // Database sessions get it from user.id instead.
      session.user.id = token?.sub ?? user.id
      return session
    },
  },
}

Capturing with an undefined distinct ID creates events that belong to nobody, so check that the ID arrives before relying on it.

Note: Make sure to always call await client.shutdown() after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.

Server-side configuration

Next.js overrides the default fetch behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.

You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.

TSX

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, {
  // ... your configuration
  fetch_options: {
    cache: 'force-cache', // Use Next.js cache
    next_options: {       // Passed to the `next` option for `fetch`
      revalidate: 60,     // Cache for 60 seconds
      tags: ['posthog'],  // Can be used with Next.js `revalidateTag` function
    },
  }
})

Configuring a reverse proxy to PostHog

To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using Next.js rewrites (/docs/advanced/proxy/nextjs.md), Next.js middleware (/docs/advanced/proxy/nextjs-middleware.md), and Vercel rewrites (/docs/advanced/proxy/vercel.md).

Further reading

  • How to set up Next.js analytics, feature flags, and more (/tutorials/nextjs-analytics.md)
  • How to set up Next.js pages router analytics, feature flags, and more (/tutorials/nextjs-pages-analytics.md)
  • How to set up Next.js A/B tests (/tutorials/nextjs-ab-tests.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/nuxt-js-3-6.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Nuxt.js (v3.0 to v3.6)

PostHog makes it easy to get data about usage of your Nuxt.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.

These docs are for Nuxt v3.0 to v3.6. You can see a working example of the Nuxt v3.0 integration in our Nuxt.js demo app

Setting up PostHog on the client side

  1. Install posthog-js using your package manager:
npm
npm install --save posthog-js
Yarn
yarn add posthog-js
pnpm
pnpm add posthog-js
Bun
bun add posthog-js

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

  1. Store your PostHog key and host in environment variables rather than hard-coding them. Add them to a .env file (and to your hosting provider). You can find these in your project settings.

.env

NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=<ph_project_token>
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Then reference them in your nuxt.config.js file:

nuxt.config.js

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      posthogToken: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '<ph_project_token>',
      posthogHost: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
      posthogDefaults: '2026-05-30',
    },
  }
})

Keep your personal API key out of the client bundle

Anything shipped to the browser – the token you pass to posthog.init(), anything under Nuxt's runtimeConfig.public, or the @posthog/nuxt module's posthogConfig – ends up in your client-side JavaScript and is visible to anyone who visits your site. This is fine for your project token (<ph_project_token>), which is designed to be public.

Your personal API key (/docs/api.md#authentication) is different. It can grant full access to your PostHog account, so it must never reach the browser. If you need it – for example, for source map uploads (/docs/error-tracking/upload-source-maps/nuxt.md) or server-side local evaluation (/docs/feature-flags/local-evaluation.md) – read it from a server-only environment variable (or top-level runtimeConfig, never runtimeConfig.public) and only use it in server code.

Either way, prefer reading keys from environment variables rather than hard-coding them in nuxt.config, so you can keep them out of source control and use different values per environment.

  1. Create a new plugin by creating a new file posthog.client.js in your plugins directory.

plugins/posthog.client.js

import { defineNuxtPlugin, useRuntimeConfig } from '#imports'

import posthog from 'posthog-js'

export default defineNuxtPlugin(() => {
  const runtimeConfig = useRuntimeConfig()
  const posthogClient = posthog.init(runtimeConfig.public.posthogToken, {
    api_host: runtimeConfig.public.posthogHost,
    defaults: runtimeConfig.public.posthogDefaults,
    loaded: (posthog) => {
      if (import.meta.env.MODE === 'development') posthog.debug()
    },
  })

  return {
    provide: {
      posthog: () => posthogClient,
    },
  }
})

PostHog can then be accessed throughout your Nuxt.js using the provider accessor, for example:

Vue

<script setup>
   const { $posthog } = useNuxtApp()
   if ($posthog) {
      const posthog = $posthog()
      posthog.capture('<event_name>')
   }
</script>

See the JavaScript SDK docs (/docs/libraries/js/usage.md) for all usable functions, such as:

  • Capture custom event capture, identify users, and more. (/docs/libraries/js/usage.md#capturing-events)
  • Feature flags including variants and payloads. (/docs/libraries/js/usage.md#feature-flags)

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Setting up PostHog on the server side

Install posthog-node using your package manager:

npm
npm install posthog-node --save
Yarn
yarn add posthog-node
pnpm
pnpm add posthog-node
Bun
bun add posthog-node

Add your PostHog API key and host to your nuxt.config.js file, reading them from environment variables. If you've already done this when adding PostHog to the client side, you can skip this step.

nuxt.config.js

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      posthogToken: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '<ph_project_token>',
      posthogHost: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
      posthogDefaults: '2026-05-30',
    }
  }
})

Initialize the PostHog Node client where you'd like to use it on the server side. For example, in a server route:

server/api/example.js

  const runtimeConfig = useRuntimeConfig()

  const posthog = new PostHog(
    runtimeConfig.public.posthogToken,
    {
      host: runtimeConfig.public.posthogHost,
    }
  );

  posthog.capture({
    event: 'api_call',
    distinctId: distinctID,
    properties: {
      $current_url: url,
      query: query
    }
  })
  posthog.shutdown()

  return {
    message: "example response"

Note: Make sure to always call posthog.shutdown() after capturing events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.

See the Node SDK docs (/docs/libraries/node.md) for all usable functions, such as:

  • Capture custom event capture, identify users, and more. (/docs/libraries/node.md#capturing-events)
  • Feature flags including variants and payloads. (/docs/libraries/node.md#feature-flags)

Next steps

For any technical questions for how to integrate specific PostHog features into Nuxt (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js.md) and Node (/docs/libraries/node.md) SDK docs.

Alternatively, the following tutorials can help you get started:

  • How to set up analytics in Nuxt (/tutorials/nuxt-analytics.md)
  • How to set up feature flags in Nuxt (/tutorials/nuxt-feature-flags.md)
  • How to set up A/B tests in Nuxt (/tutorials/nuxtjs-ab-tests.md)
  • How to set up surveys in Nuxt (/tutorials/nuxt-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/nuxt-js.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Nuxt.js

PostHog makes it easy to get data about usage of your Nuxt.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.

This guide covers Nuxt v4.x and v3.7+. For these versions, we recommend using @posthog/nuxt module for client-side capture.

The @posthog/nuxt module provides:

  • Automatic client-side PostHog initialization
  • Auto-imported composables for PostHog and feature flags
  • Automatic exception capture for error tracking
  • Source map configuration and upload for error tracking

For server-side event capture beyond error tracking, use the posthog-node SDK directly.

Using an older version? See our docs for Nuxt 3.0-3.6 (/docs/libraries/nuxt-js-3-6.md) or Nuxt 2.x (/docs/libraries/nuxt-js-2.md).

Installation

Install the PostHog Nuxt module using your package manager:

npm
npm install @posthog/nuxt
Yarn
yarn add @posthog/nuxt
pnpm
pnpm add @posthog/nuxt
Bun
bun add @posthog/nuxt

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Configuration

Store your PostHog keys in environment variables rather than hard-coding them. Add them to a .env file (and to your hosting provider). You can find these values in your project settings.

.env

NUXT_PUBLIC_POSTHOG_KEY=<ph_project_token>
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Then reference them when you add the module to your nuxt.config.ts file:

nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@posthog/nuxt'],
  posthogConfig: {
    publicKey: process.env.NUXT_PUBLIC_POSTHOG_KEY, // Find it in project settings https://app.posthog.com/settings/project
    host: process.env.NUXT_PUBLIC_POSTHOG_HOST, // Optional: defaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU region
    clientConfig: {
      // Optional: PostHog client configuration options
    },
  },
})

Keep your personal API key out of the client bundle

Anything shipped to the browser – the token you pass to posthog.init(), anything under Nuxt's runtimeConfig.public, or the @posthog/nuxt module's posthogConfig – ends up in your client-side JavaScript and is visible to anyone who visits your site. This is fine for your project token (<ph_project_token>), which is designed to be public.

Your personal API key (/docs/api.md#authentication) is different. It can grant full access to your PostHog account, so it must never reach the browser. If you need it – for example, for source map uploads (/docs/error-tracking/upload-source-maps/nuxt.md) or server-side local evaluation (/docs/feature-flags/local-evaluation.md) – read it from a server-only environment variable (or top-level runtimeConfig, never runtimeConfig.public) and only use it in server code.

Either way, prefer reading keys from environment variables rather than hard-coding them in nuxt.config, so you can keep them out of source control and use different values per environment.

Usage on the client side

The module provides the usePostHog() composable which is auto-imported and available in all your Vue components:

app/pages/index.vue

<script setup>
const posthog = usePostHog()

// Capture a custom event
posthog?.capture('button_clicked', { button_name: 'signup' })
</script>

Note: usePostHog() returns undefined on the server side during SSR, so use optional chaining ?. when calling methods.

Usage on the server side

The @posthog/nuxt module initializes a server-side client for error tracking only. For general event capture in Nitro routes, create your own posthog-node SDK client.

The @posthog/nuxt module makes your config available at runtimeConfig.public.posthog.

First, create a server utility to reuse the PostHog client across requests:

server/utils/posthog.ts

import { PostHog } from 'posthog-node'

let client: PostHog | null = null

export function useServerPostHog(): PostHog {
  if (!client) {
    const config = useRuntimeConfig()
    client = new PostHog(config.public.posthog.publicKey, {
      host: config.public.posthog.host,
    })
  }
  return client
}

Then use it in your server routes:

server/api/example.ts

export default defineEventHandler((event) => {
  const posthog = useServerPostHog()

  posthog.capture({
    distinctId: 'user_123',
    event: 'server_event',
  })

  return { success: true }
})

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Feature flags

The module provides auto-imported composables for feature flags. All composables return reactive refs that automatically update when flags are loaded or changed.

Vue

<script setup>
const isEnabled = useFeatureFlagEnabled('new-feature')
// returns true, false, or undefined
</script>

<template>
  <div v-if="isEnabled">Feature is enabled!</div>
</template>

Vue

<script setup>
const variant = useFeatureFlagVariantKey('experiment')
// returns the variant string, true/false, or undefined
</script>

<template>
  <div v-if="variant === 'control'">Control group</div>
  <div v-else-if="variant === 'test'">Test group</div>
</template>

Vue

<script setup>
const payload = useFeatureFlagPayload('config-flag')
// returns any JSON value or undefined
</script>

<template>
  <div v-if="payload">Config: {{ payload.value }}</div>
</template>

Error Tracking

For a detailed error tracking installation guide, including automatic exception capture and source map configuration, see the Nuxt error tracking installation docs (/docs/error-tracking/installation/nuxt-3-7.md).

Troubleshooting

TypeScript errors in posthog config: Remove the .nuxt directory and rebuild your project to regenerate config types.

PostHog not capturing events: Ensure you're using optional chaining (posthog?.capture()) since usePostHog() returns undefined during server-side rendering.

Next steps

For any technical questions for how to integrate specific PostHog features into Nuxt (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js.md) and Node (/docs/libraries/node.md) SDK docs.

Alternatively, the following tutorials can help you get started:

  • How to set up analytics in Nuxt (/tutorials/nuxt-analytics.md)
  • How to set up feature flags in Nuxt (/tutorials/nuxt-feature-flags.md)
  • How to set up A/B tests in Nuxt (/tutorials/nuxtjs-ab-tests.md)
  • How to set up surveys in Nuxt (/tutorials/nuxt-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/php.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

PHP

This is an optional library you can install if you're working with PHP. It uses an internal queue to batch requests, flushes at the end of the request, and optionally does so in an async manner.

Installation

Install the package with Composer:

Terminal

composer require posthog/posthog-php

In your app, set your project token before making any calls.

PHP

PostHog\PostHog::init("<ph_project_token>",
  ['host' => 'https://us.i.posthog.com']
);

Note: As a rule of thumb, we do not recommend having API keys or tokens in plaintext. Setting them as environment variables is best. The PHP SDK reads POSTHOG_API_KEY and POSTHOG_HOST when you omit the project token or host.

You can find your project token and instance address in the project settings page in PostHog.

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Capturing events

You can send custom events using capture:

PHP

PostHog::capture([
  'distinctId' => 'distinct_id_of_the_user',
  'event' => 'user_signed_up'
]);

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

PHP

PostHog::capture([
  'distinctId' => 'distinct_id_of_the_user',
  'event' => 'user_signed_up',
  'properties' => [
    'login_type' => 'email',
    'is_free_trial' => 'true'
  ]
]);
Sending page views

If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:

PHP

PostHog::capture([
  'distinctId' => 'distinct_id_of_the_user',
  'event' => '$pageview',
  'properties' => [
    '$current_url' => 'https://example.com'
  ]
]);

Person profiles and properties

The PHP SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md), call identify with the user's distinct ID and properties:

PHP

PostHog::identify([
    'distinctId' => 'distinct_id',
    'properties' => [
        'email' => 'max@example.com',
        'name' => 'Max Hedgehog',
    ],
]);

You can also include person properties when capturing an event:

PHP

PostHog::capture([
    'distinctId' => 'distinct_id',
    'event' => 'event_name',
    'properties' => [
        '$set' => [
            'name' => 'Max Hedgehog'
        ],
        '$set_once' => [
            'initial_url' => '/blog'
        ]
    ]
]);

For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).

To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:

PHP

PostHog::capture([
    'distinctId' => 'distinct_id',
    'event' => 'event_name',
    'properties' => [
        '$process_person_profile' => false
    ]
]);

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

PHP

PostHog::alias([
  'distinctId' => 'distinct_id',
  'alias' => 'alias_id'
]);

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in PHP:

Step 1: Evaluate flags once

Call PostHog::evaluateFlags() once for the user, then read values from the returned snapshot.

Boolean feature flags

PHP

$flags = PostHog::evaluateFlags('distinct_id_of_your_user');

if ($flags->isEnabled('flag-key')) {
    // Do something differently for this user
    // Optional: fetch the payload
    $matchedFlagPayload = $flags->getFlagPayload('flag-key');
}
Multivariate feature flags

PHP

$flags = PostHog::evaluateFlags('distinct_id_of_your_user');

$enabledVariant = $flags->getFlag('flag-key');

if ($enabledVariant === 'variant-key') { // replace 'variant-key' with the key of your variant
    // Do something differently for this user
    // Optional: fetch the payload
    $matchedFlagPayload = $flags->getFlagPayload('flag-key');
}

$flags->getFlag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and null when the flag wasn't returned by the evaluation.

You can also call $flags->getKeys() to list the evaluated flag keys, or $flags->getEventProperties() to get the $feature/<flag-key> and $active_feature_flags properties that would be attached to a captured event.

Note: PostHog::isFeatureEnabled(), PostHog::getFeatureFlag(), PostHog::getFeatureFlagPayload(), and capture(['send_feature_flags' => true]) still work during the migration period, but they're deprecated. Prefer evaluateFlags() for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Pass the evaluated flags snapshot to capture()

Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.

PHP

$flags = PostHog::evaluateFlags('distinct_id_of_your_user');

if ($flags->isEnabled('flag-key')) {
    // Do something differently for this user
}

PostHog::capture([
    'distinctId' => 'distinct_id_of_your_user',
    'event' => 'event_name',
    'flags' => $flags,
]);

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, pass a filtered snapshot:

PHP

// Attach only flags accessed with isEnabled() or getFlag() before this call
PostHog::capture([
    'distinctId' => 'distinct_id_of_your_user',
    'event' => 'event_name',
    'flags' => $flags->onlyAccessed(),
]);

// Attach only specific flags
PostHog::capture([
    'distinctId' => 'distinct_id_of_your_user',
    'event' => 'event_name',
    'flags' => $flags->only(['checkout-flow', 'new-dashboard']),
]);

onlyAccessed() is order-dependent. If you call it before accessing any flags with isEnabled() or getFlag(), no feature flag properties are attached.

Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

PHP

PostHog::capture([
    'distinctId' => 'distinct_id_of_your_user',
    'event' => 'event_name',
    'properties' => [
        // Replace feature-flag-key with your flag key and 'variant-key' with the key of your variant
        '$feature/feature-flag-key' => 'variant-key',
    ],
]);
Evaluating only specific flags

By default, evaluateFlags() evaluates every flag for the user. If you only need a few flags, pass flagKeys to request only those flags:

PHP

$flags = PostHog::evaluateFlags(
    distinctId: 'distinct_id_of_your_user',
    flagKeys: ['checkout-flow', 'new-dashboard'],
);
Optional evaluation parameters

evaluateFlags() also accepts optional parameters for local evaluation and GeoIP behavior:

PHP

$flags = PostHog::evaluateFlags(
    distinctId: 'distinct_id_of_your_user',
    groups: ['company' => 'company_id_in_your_db'],
    personProperties: ['plan' => 'pro'],
    groupProperties: ['company' => ['employees' => 11]],
    onlyEvaluateLocally: false, // Defaults to false. Set to true to avoid a remote fallback.
    disableGeoip: false, // Defaults to false. Set to true to disable GeoIP enrichment during remote evaluation.
    flagKeys: ['checkout-flow', 'new-dashboard'],
);
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluateFlags(), the SDK sends this event when you call $flags->isEnabled() or $flags->getFlag() for a flag.

The SDK deduplicates these events per (flag key, distinct_id) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.

$flags->getFlagPayload() doesn't send $feature_flag_called events and doesn't count as an access for onlyAccessed().

Advanced: Overriding server properties

Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.

You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.

For example:

PHP

$flags = PostHog::evaluateFlags(
    distinctId: 'distinct_id_of_the_user',
    groups: [
        'your_group_type' => 'your_group_id',
        'another_group_type' => 'your_group_id',
    ],
    personProperties: ['property_name' => 'value'],
    groupProperties: [
        'your_group_type' => ['group_property_name' => 'value'],
        'another_group_type' => ['group_property_name' => 'value'],
    ],
);

if ($flags->isEnabled('flag-key')) {
    // Do something differently for this user
}
Overriding GeoIP properties

By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.

You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.

The following GeoIP properties can be overridden:

  • $geoip_country_code
  • $geoip_country_name
  • $geoip_city_name
  • $geoip_city_confidence
  • $geoip_continent_code
  • $geoip_continent_name
  • $geoip_latitude
  • $geoip_longitude
  • $geoip_postal_code
  • $geoip_subdivision_1_code
  • $geoip_subdivision_1_name
  • $geoip_subdivision_2_code
  • $geoip_subdivision_2_name
  • $geoip_subdivision_3_code
  • $geoip_subdivision_3_name
  • $geoip_time_zone

Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.

Request timeout

You can configure the feature_flag_request_timeout_ms parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.

PHP

PostHog::init("<ph_project_token>",
    [
        'host' => 'https://us.i.posthog.com',
        'feature_flag_request_timeout_ms' => 3000, // Time in milliseconds. Defaults to 3000 (3 seconds).
    ]
);
Local Evaluation

Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.

It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.

To load feature flag definitions for local evaluation, initialize the SDK with your feature flags secure API key as personalAPIKey:

PHP

PostHog::init(
    '<ph_project_token>',
    ['host' => 'https://us.i.posthog.com'],
    personalAPIKey: 'your feature flags secure API key'
);

For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md). For distributed or stateless PHP applications, use flag_definition_cache_provider to share flag definitions across workers or requests. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=PHP.md).

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:

PHP

$flags = PostHog::evaluateFlags('user_distinct_id');
$variant = $flags->getFlag('experiment-feature-flag-key');

if ($variant === 'variant-name') {
    // Do something differently for this user
}

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Group analytics

Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). This feature requires version 2.1.0 or above of the PHP SDK. Read the group analytics guide (/docs/product-analytics/group-analytics.md) for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

To create a group or update its properties, use groupIdentify:

PHP

PostHog::groupIdentify([
    'groupType' => 'company',
    'groupKey' => 'company_id_in_your_db',
    'properties' => [
        'name' => 'Awesome Inc.',
        'employees' => 11,
    ],
    // Optional distinct ID to associate this event with an existing person.
    // Requires posthog-php 4.4.0 or later.
    'distinctId' => 'user_distinct_id'
]);

name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID is used instead.

If the optional distinctId parameter is not provided in the group identify call, it defaults to ${groupType}_${groupKey} (e.g., $company_company_id_in_your_db in the example above). This default behavior results in each group appearing as a separate person in PostHog. To avoid this, use a consistent distinctId, such as group_identifier, or a real user distinct ID.

Once a group is created, you can use the capture method and pass in the groups parameter to capture an event with group analytics.

PHP

PostHog::capture([
    'distinctId' => 'user_distinct_id',
    'event' => 'some_event',
    'groups' => ['company' => 'company_id_in_your_db']
]);

Request context

Use request context to apply a distinct ID, session ID, and common properties to all captures inside a callback. This is useful when connecting frontend activity to backend events, session replay, and error tracking.

PHP

PostHog::withContext([
    'distinctId' => 'user_distinct_id',
    'sessionId' => 'session_id_from_frontend',
    'properties' => [
        '$current_url' => 'https://example.com/account',
    ],
], function () {
    PostHog::capture([
        'event' => 'backend_event',
    ]);
});

You can extract PostHog context from frontend tracing headers with contextFromHeaders(). If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your PHP backend hostname so browser requests include the session and distinct ID headers.

Then read the incoming headers on the server:

PHP

$context = PostHog::contextFromHeaders($_SERVER);

PostHog::withContext($context, function () {
    PostHog::capture([
        'event' => 'backend_event',
    ]);
});

Call PostHog::getContext() to read the currently active context. Pass ['fresh' => true] as the third argument to withContext() if you don't want to inherit any existing context.

Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinctId explicitly for security-sensitive server-side decisions.

Error tracking

The PHP SDK supports both manual exception capture and opt-in automatic error tracking.

To automatically capture uncaught exceptions, PHP errors, and fatal shutdown errors, enable error_tracking when initializing the client:

PHP

PostHog::init(
    '<ph_project_token>',
    [
        'host' => 'https://us.i.posthog.com',
        'error_tracking' => [
            'enabled' => true,
        ],
    ],
);

You can also call PostHog::captureException() directly for manual capture. When source files are readable at runtime, PostHog includes surrounding source lines for in-app stack frames automatically.

For the full setup guide, including context_provider, excluded exceptions, and verification steps, see the PHP error tracking installation docs (/docs/error-tracking/installation/php.md).

Config options

When calling PostHog::init, there are various configuration options you can set apart from the host. Pass them into your client initialisation like so:

PHP

PostHog::init(
    '<ph_project_token>',
    [
        'host' => 'https://us.i.posthog.com',
        'debug' => true,
        'ssl' => false,
        // all options go here
    ],
);

All possible options below:

Attribute Description
host Type: String Default: us.i.posthog.com URL of your PostHog instance.
ssl Type: Boolean Default: true Whether to use SSL for API requests or not. If host includes http:// or https://, the SDK infers this option unless you set it explicitly.
timeout Type: Integer Default: 10000 Request timeout in milliseconds.
verify_batch_events_request Type: Boolean Default: true Whether to verify successful delivery of batch events (true, synchronous) or fire and forget (false, asynchronous) with the lib_curl consumer.
feature_flag_request_timeout_ms Type: Integer Default: 3000 Request timeout for feature flags in milliseconds.
flag_definition_cache_provider Type: PostHog\FlagDefinitionCacheProvider Default: null Provider for distributed local-evaluation flag definition caching. See local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=PHP.md).
maximum_backoff_duration Type: Integer Default: 10000 Request retry backoff. Retries stop after this duration is hit.
consumer Type: String Default: lib_curl One of socket, file, lib_curl, fork_curl, and noop. Determines what transport option to use for analytics capture.
debug Type: Boolean Default: false Output debug logs or not.
max_queue_size Type: Integer Default: 1000 Maximum number of events to queue before rejecting new events. Applies to queued consumers.
batch_size Type: Integer Default: 100 Number of queued events to send in each batch. Applies to queued consumers.
compress_request Type: Boolean/String Default: false Whether to gzip batch request payloads.
error_handler Type: Callable Default: null Callback invoked for SDK transport errors.
filename Type: String Default: sys_get_temp_dir() . '/posthog.log' File path used when consumer is set to file.
error_tracking Type: Array Default: [] Enables automatic error tracking. See the options below or the PHP error tracking setup guide (/docs/error-tracking/installation/php.md).
Error tracking options
Attribute Description
enabled Type: Boolean Default: false Enables automatic error tracking handlers. Manual captureException works regardless.
capture_errors Type: Boolean Default: true When enabled, captures PHP errors and fatal shutdown errors in addition to uncaught exceptions.
excluded_exceptions Type: Array of class strings Default: [] Throwable classes to skip during automatic capture.
max_frames Type: Integer Default: 20 Maximum number of stack frames included in $exception_list.
context_provider Type: Callable or null Default: null Callback that returns distinctId and extra event properties for automatic captures.

Flushing and shutting down

Call PostHog::flush() to send queued events without closing resources. When a script or long-running worker stops, call PostHog::shutdown() instead; it flushes queued events and releases resources held by providers such as flag_definition_cache_provider.

PHP

PostHog::shutdown();

Debug mode

PHP

PostHog::init(
    '<ph_project_token>',
    [
        'host' => 'https://us.i.posthog.com',
        'debug' => true,
    ],
);

Thank you

This library is largely based on the analytics-php package.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/posthog-python.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

PostHog Python SDK

SDK Version: 7.60.0

Integrate PostHog into any python application.

Categories

  • Initialization
  • Identification
  • Capture
  • Error Tracking
  • Feature flags
  • Tracing
  • Contexts
  • Events
  • Client management

PostHog

This is the SDK reference for the PostHog Python SDK. You can learn more about example usage in the Python SDK documentation (/docs/libraries/python). You can also follow Flask (/docs/libraries/flask) and Django (/docs/libraries/django) guides to integrate PostHog into your project. For long-running applications, create one client during application startup and reuse it for the lifetime of the process. This keeps background queues predictable and makes shutdown flushing straightforward. Multiple clients are still supported for intentional multi-project or multi-host setups.

Initialization methods
Client()

Release Tag: public

Initialize a new PostHog client instance.

Parameters
  • project_api_key? (str) - PostHog project API key/token.
  • host (any) - PostHog host. Defaults to the US ingestion endpoint when not set. App hosts such as https://us.posthog.com are mapped to the corresponding ingestion host.
  • debug (bool) - Enable verbose SDK logging and re-raise errors from public API methods.
  • max_queue_size (int) - Maximum number of events buffered before upload.
  • send (bool) - If False, queueing succeeds but events are not sent.
  • on_error (any) - Optional callback invoked by background consumers when an upload fails. Keep it short and non-blocking. Calling lifecycle methods directly is safe and deferred, but do not start another thread or task that calls flush(), join(), or shutdown() and then wait for it from the callback.
  • flush_at (int) - Number of queued events that triggers a batch upload.
  • flush_interval (float) - Maximum seconds a background consumer waits before flushing a partial batch.
  • gzip (bool) - Whether to gzip event upload payloads.
  • max_retries (int) - Number of upload retries. Values below 0 are treated as 0.
  • sync_mode (bool) - If True, send each event synchronously instead of using background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use AsyncPosthog instead.
  • timeout (int) - HTTP request timeout in seconds for event uploads.
  • thread (int) - Number of background consumer threads.
  • poll_interval (int) - Seconds between local feature flag definition refreshes.
  • personal_api_key (any) - Deprecated alias for secret_key. Still honored for backwards compatibility; prefer secret_key, which also accepts a Project Secret API Key.
  • disabled (bool) - If True, disable captures and API requests. Useful in tests.
  • disable_geoip (bool) - Whether to disable server-side GeoIP enrichment. Defaults to True.
  • is_server (bool) - Whether events are emitted from a server-side runtime. Defaults to True; set to False when using the SDK as a client/CLI so the device OS is attributed to the person normally.
  • historical_migration (bool) - Mark events as historical migration imports.
  • feature_flags_request_timeout_seconds (int) - Timeout in seconds for feature flag and remote config requests.
  • feature_flags_request_max_retries (int) - Number of retries for feature flag requests after network, transport, or timeout failures. Defaults to 1. Set to 0 to disable retries.
  • super_properties (any) - Properties merged into every captured event.
  • enable_exception_autocapture (bool) - Automatically capture uncaught exceptions.
  • log_captured_exceptions (bool) - Also log exceptions captured by error tracking.
  • project_root (any) - Root path used to determine in-app stack frames for captured exceptions. Defaults to the current working directory.
  • privacy_mode (bool) - For AI observability, capture usage metadata without prompt inputs or outputs.
  • before_send (any) - Optional callback that can modify or drop events before upload. Return None to drop an event.
  • flag_fallback_cache_url (any) - Optional feature flag fallback cache URL, such as memory://local/?ttl=300&size=10000 or a Redis URL.
  • enable_local_evaluation (bool) - Whether to poll feature flag definitions for local evaluation when a personal API key is configured.
  • flag_definition_cache_provider? (FlagDefinitionCacheProvider) - Optional external cache provider for sharing feature flag definitions across workers.
  • capture_exception_code_variables (bool) - Capture local variable values on exception stack frames.
  • code_variables_mask_patterns (any) - Variable-name patterns to mask when capturing code variables.
  • code_variables_ignore_patterns (any) - Variable-name patterns to omit when capturing code variables.
  • code_variables_mask_url_credentials (any) - Scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables, regardless of the surrounding variable name. Defaults to True.
  • code_variables_detect_secrets (any) - Last-resort entropy-based detection that redacts high-entropy secret-looking values (API keys, tokens, strong passwords) sitting in innocuously-named variables, after the name and URL checks. Skips structured ids (UUIDs, ObjectIds, hashes). Defaults to True.
  • in_app_modules (UnionType[list[str], any]) - Module/package prefixes treated as in-app frames in captured exceptions.
  • enable_exception_autocapture_rate_limiting (bool) - Rate limit autocaptured exceptions client-side with a token bucket per exception type. Disabled by default.
  • exception_autocapture_bucket_size (int) - Maximum burst of autocaptured exceptions allowed per exception type (token bucket size, clamped to 0-100).
  • exception_autocapture_refill_rate (int) - Tokens restored per refill interval for each exception type's bucket.
  • exception_autocapture_refill_interval_seconds (int) - Seconds between token refills for autocaptured exception rate limiting.
  • capture_mode (CaptureMode) - Capture wire protocol to use. Defaults to CaptureMode.V0 (legacy /batch/). Set CaptureMode.V1 (or pass the string "v1") to opt into /i/v1/analytics/events. When omitted, the POSTHOG_CAPTURE_MODE env var is consulted, then V0.
  • capture_compression (CaptureCompression) - Request-body compression for capture-v1 uploads (ignored in V0, which uses gzip). CaptureCompression.GZIP or DEFLATE (or the strings "gzip"/"deflate"). When omitted, the POSTHOG_CAPTURE_COMPRESSION env var is consulted, then the legacy gzip flag, then no compression.
  • secret_key (any) - A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config payloads, and decrypted flag payloads. Example:: posthog.Client(project_api_key, secret_key="phx_...")
  • metrics? (dict)
  • enable_full_ai_capture (bool) - Route PostHog AI wrapper events through the dedicated AI capture endpoint and capture full AI content: skips string truncation and passes media (base64/data URIs) through unredacted. privacy_mode always wins. Defaults to False.
  • capture_trace_context (bool) - When OpenTelemetry is installed and a valid span is active at capture time, add its trace and span IDs as $trace_id and $span_id properties to events captured with capture() and capture_ai(), so they can be correlated with backend traces. Explicit $trace_id/$span_id values passed in properties win. Exception events (capture_exception) always attach these IDs regardless of this setting. Defaults to False.
  • _use_ai_lane (bool)
  • _enable_multimodal_capture (bool)
  • traces? (dict) - Config dict for distributed tracing: service_name, service_version, environment, resource_attributes, flush_interval (5 s), max_queue_size (2048), max_export_batch_size (512), max_live_spans (10000), max_span_age (3600 s), max_attributes_per_span (128), max_events_per_span (128), max_attribute_value_length (8192). before_span_send is a callable, or a list run in order, that receives each finished span as a dict (trace_id, span_id and parent_span_id are read-only) and returns it, edited, or None to drop it; a hook that raises drops the span. Tracing is off until this is provided. Spans export on a background timer even with sync_mode; serverless handlers should call flush() before returning. Defaults to None.
Returns
  • None
Examples
from posthog import Posthog

posthog = Posthog('<ph_project_api_key>', host='<ph_app_host>')

Identification methods
alias()

Release Tag: public

Create an alias between two distinct IDs.

Parameters
  • previous_id? (Number) - The previous distinct ID. Required - the call is dropped with a warning if it is missing or empty.
  • distinct_id? (str) - The new distinct ID to alias to. Falls back to the context distinct ID; the call is dropped with a warning if neither is available.
  • timestamp (datetime) - The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.
  • uuid? (str) - A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.
  • disable_geoip? (bool) - Whether to disable GeoIP for this event.
Returns
  • Optional[str]
Examples
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')

group_identify()

Release Tag: public

Identify a group and set its properties.

Parameters
  • group_type? (str) - The type of group (e.g., 'company', 'team'). Required - the call is dropped with a warning if it is missing or empty.
  • group_key? (str) - The unique identifier for the group. Required - the call is dropped with a warning if it is missing or empty.
  • properties? (dict[str, Any]) - A dictionary of properties to set on the group.
  • timestamp (datetime) - The timestamp of the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.
  • uuid (str) - A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are ignored and replaced with a newly generated UUID.
  • disable_geoip? (bool) - Whether to disable GeoIP for this event.
  • distinct_id (Number) - The distinct ID of the user performing the action.
Returns
  • Optional[str]
Examples
posthog.group_identify('company', 'company_id_in_your_db', {
    'name': 'Awesome Inc.',
    'employees': 11
})

set()

Release Tag: public

Set properties on a person profile.

Parameters
  • kwargs? (Unpack[OptionalSetArgs])
Returns
  • Optional[str]
Examples
# Set with distinct id
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})

set_once()

Release Tag: public

Set properties on a person profile only if they haven't been set before.

Parameters
  • kwargs? (Unpack[OptionalSetArgs])
Returns
  • Optional[str]
Examples
posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})

Capture methods
capture()

Release Tag: public

Captures an event manually. Learn about capture best practices

Parameters
  • event? (str) - The event name to capture.
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]
Examples
Anonymous event
# Anonymous event
posthog.capture('some-anon-event')
Context usage
# Context usage
from posthog import identify_context, new_context
with new_context():
    identify_context('distinct_id_of_the_user')
    posthog.capture('user_signed_up')
    posthog.capture('user_logged_in')
    posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
Set event properties
# Set event properties
posthog.capture(
    "user_signed_up",
    distinct_id="distinct_id_of_the_user",
    properties={
        "login_type": "email",
        "is_free_trial": "true"
    }
)
Page view event
# Page view event
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})

capture_ai()

Release Tag: public

Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as capture(): the event UUID, or None when the event was not admitted (disabled client, or dropped by before_send). The event is queued on an isolated AI lane with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given — no redaction or truncation is applied here.

Parameters
  • event? (str)
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]

Error Tracking methods
capture_exception()

Release Tag: public

Capture an exception for error tracking. When OpenTelemetry is installed and a valid span is active, its trace and span IDs are added as $trace_id and $span_id event properties.

Parameters
  • exception? (BaseException) - The exception to capture.
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]
Examples
try:
    # Some code that might fail
    pass
except Exception as e:
    posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)

Feature flags methods
evaluate_flags()

Release Tag: public

Evaluate all feature flags for a user in a single call and return a :class:FeatureFlagEvaluations snapshot. Branch on .is_enabled() / .get_flag() and pass the same snapshot to :meth:capture via the flags option so events carry the exact flag values the code branched on. Prefer this over repeated get_feature_flag() calls and over capture(send_feature_flags=True) — it consolidates flag evaluation into a single /flags request per incoming request. Local evaluation is transparent: when the poller resolves a flag, the snapshot's $feature_flag_called events are tagged locally_evaluated=True and reason "Evaluated locally".

Parameters
  • distinct_id (Number) - The user's distinct ID. If None, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.
  • groups? (Mapping[str, Union[str, int]]) - Mapping of group type to group key.
  • person_properties? (dict[str, Any]) - Person properties to use for evaluation.
  • group_properties? (dict[str, dict[str, Any]]) - Group properties keyed by group type.
  • only_evaluate_locally (bool) - If True, never fall back to remote evaluation — flags that can't be evaluated locally are simply omitted from the snapshot.
  • disable_geoip? (bool) - Whether to disable GeoIP lookup.
  • flag_keys? (list[str]) - Optional list that scopes local evaluation, the underlying /flags request, and the returned snapshot. When omitted or None, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per evaluate_flags call unless only_evaluate_locally is True. If the server also does not know the key, it is omitted from the snapshot.
  • device_id? (str) - Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.
Returns
  • FeatureFlagEvaluations
Examples
flags = posthog.evaluate_flags(
    "user_123",
    person_properties={"plan": "enterprise"},
)
if flags.is_enabled("new-dashboard"):
    render_new_dashboard()
posthog.capture("page_viewed", distinct_id="user_123", flags=flags)

feature_enabled()

Release Tag: public

Check if a feature flag is enabled for a user.

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • send_feature_flag_events (bool) - Whether to send feature flag events.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • device_id? (str) - The device ID for this request.
Returns
  • Optional[bool]
Examples
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')

feature_flag_definitions()

Release Tag: public

Return feature flag definitions loaded for local evaluation. Returns: The currently loaded feature flag definitions, or None before local evaluation has loaded definitions.

Returns
  • None

get_all_flags()

Release Tag: public

Get all feature flags for a user.

Parameters
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • Optional[dict[str, Union[bool, str]]]
Examples
posthog.get_all_flags('distinct_id_of_your_user')

get_all_flags_and_payloads()

Release Tag: public

Get all feature flags and their payloads for a user.

Parameters
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • FlagsAndPayloads
Examples
posthog.get_all_flags_and_payloads('distinct_id_of_your_user')

get_feature_flag()

Release Tag: public

Get multivariate feature flag value for a user.

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • send_feature_flag_events (bool) - Whether to send feature flag events.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • device_id? (str) - The device ID for this request.
Returns
  • Union[bool, str, any]
Examples
enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')

get_feature_flag_evaluation_runtime()

Release Tag: public

Return where a locally loaded feature flag is meant to be evaluated.

Parameters
  • key? (str) - The feature flag key.
Returns
  • Optional[FeatureFlagEvaluationRuntime]
Examples
from posthog import FeatureFlagEvaluationRuntime

runtime = posthog.get_feature_flag_evaluation_runtime("my-flag")
if runtime is FeatureFlagEvaluationRuntime.SERVER:
    ...

get_feature_flag_keys_by_evaluation_runtime()

Release Tag: public

Return the keys of locally loaded flags that a runtime can evaluate. A flag set to FeatureFlagEvaluationRuntime.ALL suits either runtime, so it is returned for CLIENT and for SERVER, and asking for ALL returns every loaded flag. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.

Parameters
  • evaluation_runtime? (FeatureFlagEvaluationRuntime) - The runtime to match, as a FeatureFlagEvaluationRuntime or its string value.
Returns
  • list[str]
Examples
from posthog import FeatureFlagEvaluationRuntime

client_keys = posthog.get_feature_flag_keys_by_evaluation_runtime(
    FeatureFlagEvaluationRuntime.CLIENT
)

get_feature_flag_payload()

Release Tag: public

Get the payload for a feature flag.

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The distinct ID of the user.
  • match_value (bool) - The specific flag value to get payload for.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • send_feature_flag_events (bool) - Deprecated. Use get_feature_flag() instead if you need events.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • device_id? (str) - The device ID for this request.
Returns
  • Optional[object]
Examples
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')

if is_my_flag_enabled:
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')

get_feature_flags_and_payloads()

Release Tag: public

Get feature flags and payloads for a user.

Parameters
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • FlagsAndPayloads
Examples
result = posthog.get_feature_flags_and_payloads('<distinct_id>')

get_feature_payloads()

Release Tag: public

Get feature flag payloads for a user, preserving valid serialized JSON.

Parameters
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • dict[str, Optional[str]]
Examples
payloads = posthog.get_feature_payloads('<distinct_id>')

get_feature_variants()

Release Tag: public

Get feature flag variants for a user.

Parameters
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • dict[str, Union[bool, str]]

get_flags_decision()

Release Tag: public

Get feature flags decision.

Parameters
  • distinct_id (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • flag_keys_to_evaluate? (list[str]) - A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance.
  • device_id? (str) - The device ID for this request.
Returns
  • FlagsResponse
Examples
decision = posthog.get_flags_decision('user123')

get_remote_config_payload()

Release Tag: public

Get the payload for a remote config feature flag.

Parameters
  • key? (str) - The remote config feature flag key.
Returns
  • None

load_feature_flags()

Release Tag: public

Load feature flags for local evaluation.

Returns
  • None
Examples
posthog.load_feature_flags()

Other methods
flush()

Release Tag: public

Force a flush from the internal queue to the server. Do not use directly, call shutdown() instead.

Parameters
  • timeout_seconds? (float) - Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass None to wait indefinitely. Queued spans are sent at the same time, within the same
Returns
  • any
Examples
posthog.capture('event_name')
posthog.flush()  # Ensures the event is sent immediately

get_feature_flag_result()

Release Tag: public

Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. This also captures the $feature_flag_called event unless send_feature_flag_events is False.

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The distinct ID of the user.
  • groups? (Mapping[str, Union[str, int]]) - A dictionary of group information.
  • person_properties? (dict[str, Any]) - A dictionary of person properties.
  • group_properties? (dict[str, dict[str, Any]]) - A dictionary of group properties.
  • only_evaluate_locally (bool) - Whether to only evaluate locally.
  • send_feature_flag_events (bool) - Whether to send feature flag events.
  • disable_geoip? (bool) - Whether to disable GeoIP for this request.
  • device_id? (str) - The device ID for this request.
Returns
  • Optional[FeatureFlagResult]
Examples
flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
if flag_result and flag_result.get_value() == 'variant-key':
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = flag_result.payload

join()

Release Tag: public

Attempt to process queued events and end the consumer threads. Do not use directly, call shutdown() instead. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.

Returns
  • any
Examples
posthog.join()

shutdown()

Release Tag: public

Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss. Normally this method blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Queued spans get one final flush of up to 30 s (plus a request already in flight); any it cannot send are discarded with a warning, as are spans still open. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. When called directly from an SDK callback such as on_error, shutdown is deferred to avoid blocking the worker that invoked the callback. If the callback must coordinate a blocking shutdown, have it signal an application-owned thread and return before that thread calls shutdown. Do not wait inside the callback for another thread or task that calls a lifecycle method.

Returns
  • any
Examples
posthog.shutdown()

Tracing methods
get_active_span()

Release Tag: public

The span that is active in the current context, or None. Alpha. Only entering a span (with posthog.start_span(...) as span:) makes it active; a span started manually is not. Use it to propagate the trace to the next service: span.traceparent() is the header value.

Returns
  • Optional[Span]
Examples
span = posthog.get_active_span()
if span is not None:
    headers["traceparent"] = span.traceparent()

start_span()

Release Tag: public

Start a span for distributed tracing. Alpha. Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call end() yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling code never branches.

Parameters
  • name? (str) - A low-cardinality operation name, e.g. GET /users/:id. Variable values belong in attributes, not the name.
  • kind? (str) - internal (default), server, client, producer or consumer.
  • attributes? (Mapping[str, Any]) - Initial attributes.
  • parent (Span) - A span handle, or an inbound W3C traceparent header value to continue a remote trace. Defaults to the active span. A forked child starts with no active span; pass the parent span to continue a trace across a fork.
  • tracestate? (str) - The inbound tracestate header accompanying a traceparent string parent; preserved and propagated.
  • start_time (datetime) - A datetime or epoch seconds, to backdate the span.
Returns
  • Span
Examples
posthog = Posthog("<ph_project_api_key>", traces={"service_name": "checkout-api"})

with posthog.start_span("POST /checkout", parent=request.headers.get("traceparent")) as span:
    span.set_attribute("plan", user.plan)
    with posthog.start_span("db.query", kind="client"):
        ...
    outgoing_headers = {"traceparent": span.traceparent()}

Contexts methods
get_tags()

Release Tag: public

Get all tags from the current context. Returns: Dict of all tags in the current context.

Returns
  • dict[str, Any]

identify_context()

Release Tag: public

Identify the current context with a distinct ID.

Parameters
  • distinct_id? (str) - The distinct ID to associate with the current context and its children.
Returns
  • any

new_context()

Release Tag: public

Create a new context for managing shared state. Learn more about contexts (/docs/libraries/python#contexts).

Parameters
  • fresh (bool) - Whether to create a fresh context that doesn't inherit from parent.
  • capture_exceptions? (bool) - Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.
Returns
  • None
Examples
with client.new_context():
    client.identify_context('<distinct_id>')
    client.capture('event_name')

scoped()

Release Tag: public

Decorator that creates a new context for the wrapped function using this client.

Parameters
  • fresh (bool) - Whether to create a fresh context that doesn't inherit from parent.
  • capture_exceptions? (bool) - Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.
Returns
  • None

set_context_device_id()

Release Tag: public

Set the device ID for the current context.

Parameters
  • device_id? (str) - The device ID to associate with the current context and its children.
Returns
  • any

set_context_session()

Release Tag: public

Set the session ID for the current context.

Parameters
  • session_id? (str) - The session ID to associate with the current context and its children.
Returns
  • any

tag()

Release Tag: public

Add a tag to the current context.

Parameters
  • name? (str) - The tag key.
  • value? (Any) - The tag value.
Returns
  • any

PostHog Module Functions

Global functions available in the PostHog module

Identification methods
alias()

Release Tag: public

Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.

Notes:

To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.

Parameters
  • previous_id? (Number) - The unique ID of the user before
  • distinct_id? (str) - The current unique id
  • timestamp (datetime) - Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.
  • uuid? (str) - Optional UUID for the event
  • disable_geoip? (bool) - Whether to disable GeoIP lookup
Returns
  • Optional[str]
Examples
# Alias user
from posthog import alias
alias(previous_id='distinct_id', distinct_id='alias_id')

group_identify()

Release Tag: public

Set properties on a group.

Parameters
  • group_type? (str) - Type of your group. Required - the call is dropped with a warning if it is missing or empty.
  • group_key? (str) - Unique identifier of the group. Required - the call is dropped with a warning if it is missing or empty.
  • properties? (dict[str, Any]) - Properties to set on the group
  • timestamp (datetime) - Optional timestamp for the event. UTC is preferred; non-UTC datetimes and parseable ISO timestamp strings are converted to UTC.
  • uuid? (str) - Optional UUID for the event
  • disable_geoip? (bool) - Whether to disable GeoIP lookup
  • distinct_id (Number) - Optional distinct ID of the user performing the action
Returns
  • Optional[str]
Examples
# Group identify
from posthog import group_identify
group_identify('company', 'company_id_in_your_db', {
    'name': 'Awesome Inc.',
    'employees': 11
})

identify_context()

Release Tag: public

Identify the current context with a distinct ID.

Parameters
  • distinct_id? (str) - The distinct ID to associate with the current context and its children
Returns
  • None
Examples
from posthog import identify_context
identify_context("user_123")

set()

Release Tag: public

Set properties on a user record.

Notes:

This will overwrite previous people property values. Generally operates similar to capture, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling set will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).

Parameters
  • kwargs? (Unpack[OptionalSetArgs])
Returns
  • Optional[str]
Examples
# Set person properties
from posthog import set
set(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'})

set_once()

Release Tag: public

Set properties on a user record, only if they do not yet exist.

Notes:

This will not overwrite previous people property values, unlike set. Otherwise, operates in an identical manner to set.

Parameters
  • kwargs? (Unpack[OptionalSetArgs])
Returns
  • Optional[str]
Examples
# Set property once
from posthog import set_once
set_once(distinct_id='distinct_id', properties={'initial_url': '/blog'})

Events methods
capture()

Release Tag: public

Capture anything a user does within your system.

Notes:

Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the OptionalCaptureArgs type.

Parameters
  • event? (str) - The event name to specify the event **kwargs: Optional arguments including:
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]
Examples
Context and capture usage
# Context and capture usage
from posthog import new_context, identify_context, tag_context, capture
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
with new_context():
    # Associate this context with some user, by distinct_id
    identify_context('some user')

    # Capture an event, associated with the context-level distinct ID ('some user')
    capture('movie started')

    # Capture an event associated with some other user (overriding the context-level distinct ID)
    capture('movie joined', distinct_id='some-other-user')

    # Capture an event with some properties
    capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})

    # Capture an event with some properties
    capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
    # Capture an event with some associated group
    capture('purchase', groups={'company': 'id:5'})

    # Adding a tag to the current context will cause it to appear on all subsequent events
    tag_context('some-tag', 'some-value')

    capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
Set event properties
# Set event properties
from posthog import capture
capture(
    "user_signed_up",
    distinct_id="distinct_id_of_the_user",
    properties={
        "login_type": "email",
        "is_free_trial": "true"
    }
)

capture_ai()

Release Tag: public

Capture an AI event on the dedicated AI capture endpoint. Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Takes the same arguments and returns the same value as capture(): the event UUID, or None when the event was not admitted (disabled client, or dropped by before_send). The event is delivered on an isolated queue with its own consumer pool and a higher per-event size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given — no redaction or truncation is applied here.

Parameters
  • event? (str) - The event name, normally one of the $ai_* event names. **kwargs: Same optional arguments as capture().
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]
Examples
from posthog import capture_ai

uuid = capture_ai(
    "$ai_generation",
    distinct_id="user_123",
    properties={"$ai_model": "gpt-5"},
)

capture_exception()

Release Tag: public

Capture exceptions that happen in your code.

Notes:

Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a with posthog.new_context(): block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call posthog.capture_exception(ValueError("Some Error")), the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. capture_exception takes the same set of optional arguments as capture.

Parameters
  • exception (BaseException) - The exception to capture. If not provided, the current exception is captured via sys.exc_info() **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.
  • kwargs? (Unpack[OptionalCaptureArgs])
Returns
  • Optional[str]
Examples
# Capture exception
from posthog import capture_exception
try:
    risky_operation()
except Exception as e:
    capture_exception(e)

Feature flags methods
evaluate_flags()

Release Tag: public

Evaluate all feature flags for a user in a single call and return a :class:FeatureFlagEvaluations snapshot. Branch on .is_enabled() / .get_flag() and pass the same snapshot to capture() via the flags option so events carry the exact flag values the code branched on. Prefer this over repeated get_feature_flag() calls and over capture(send_feature_flags=True) — it consolidates flag evaluation into a single /flags request per incoming request.

Parameters
  • distinct_id (Number) - The user's distinct ID. If None, falls back to the context distinct_id. If still unresolvable, returns an empty snapshot.
  • groups? (Mapping[str, Union[str, int]]) - Mapping of group type to group key.
  • person_properties? (dict[str, Any]) - Person properties to use for evaluation.
  • group_properties? (dict[str, dict[str, Any]]) - Group properties keyed by group type.
  • only_evaluate_locally (bool) - If True, never fall back to remote evaluation and omit flags that cannot be evaluated locally.
  • disable_geoip? (bool) - Whether to disable GeoIP lookup.
  • flag_keys? (list[str]) - Optional list that scopes local evaluation, the underlying /flags request, and the returned snapshot. When omitted or None, all flags are evaluated. An empty list returns an empty snapshot without evaluating flags. A requested key absent from loaded local definitions is included in one remote fallback per evaluate_flags call unless only_evaluate_locally is True. If the server also does not know the key, it is omitted from the snapshot.
  • device_id? (str) - Optional device ID override. If not provided, falls back to the context device_id (which may be set via tracing headers). Used by experience-continuity flags to match users across distinct_id changes.
Returns
  • FeatureFlagEvaluations
Examples
from posthog import evaluate_flags, capture
flags = evaluate_flags("user_123", person_properties={"plan": "enterprise"})
if flags.is_enabled("new-dashboard"):
    render_new_dashboard()
capture("page_viewed", distinct_id="user_123", flags=flags)

feature_enabled()

Release Tag: public

Use feature flags to enable or disable features for users.

Notes:

You can call posthog.load_feature_flags() before to make sure you're not doing unexpected requests.

Parameters
  • key? (str) - The feature flag key
  • distinct_id? (Number) - The user's distinct ID
  • groups? (Mapping[str, Union[str, int]]) - Groups mapping
  • person_properties? (dict[str, Any]) - Person properties
  • group_properties? (dict[str, dict[str, Any]]) - Group properties
  • only_evaluate_locally (bool) - Whether to evaluate only locally
  • send_feature_flag_events (bool) - Whether to send feature flag events
  • disable_geoip? (bool) - Whether to disable GeoIP lookup
  • device_id? (str) - Optional device ID override for experience-continuity flags
Returns
  • Optional[bool]
Examples
# Boolean feature flag
from posthog import feature_enabled, get_feature_flag_payload
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
    matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')

feature_flag_definitions()

Release Tag: public

Returns loaded feature flags.

Notes:

Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.

Returns
  • None
Examples
from posthog import feature_flag_definitions
definitions = feature_flag_definitions()

get_all_flags()

Release Tag: public

Get all flags for a given user.

Notes:

Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.

Parameters
  • distinct_id? (Number) - The user's distinct ID
  • groups? (Mapping[str, Union[str, int]]) - Groups mapping
  • person_properties? (dict[str, Any]) - Person properties
  • group_properties? (dict[str, dict[str, Any]]) - Group properties
  • only_evaluate_locally (bool) - Whether to evaluate only locally
  • disable_geoip? (bool) - Whether to disable GeoIP lookup
  • device_id? (str) - Optional device ID override for experience-continuity flags
  • flag_keys_to_evaluate? (list[str]) - Optional list of flag keys to evaluate (evaluates all if None)
Returns
  • Optional[dict[str, Union[bool, str]]]
Examples
# All flags for user
from posthog import get_all_flags
get_all_flags('distinct_id_of_your_user')

get_all_flags_and_payloads()

Release Tag: public

Get all feature flag values and payloads for a user.

Parameters
  • distinct_id? (Number) - The user's distinct ID.
  • groups? (Mapping[str, Union[str, int]]) - Mapping of group type to group key.
  • person_properties? (dict[str, Any]) - Person properties to use for evaluation.
  • group_properties? (dict[str, dict[str, Any]]) - Group properties keyed by group type.
  • only_evaluate_locally (bool) - Whether to evaluate only locally.
  • disable_geoip? (bool) - Whether to disable GeoIP lookup.
  • device_id? (str) - Optional device ID override for experience-continuity flags.
  • flag_keys_to_evaluate? (list[str]) - Optional list of flag keys to evaluate. Evaluates all flags when omitted.
Returns
  • FlagsAndPayloads

get_feature_flag()

Release Tag: public

Get feature flag variant for users. Used with experiments.

Notes:

groups are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. group_properties take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "PostHog", "employees": 11}}.

Parameters
  • key? (str) - The feature flag key
  • distinct_id? (Number) - The user's distinct ID
  • groups? (Mapping[str, Union[str, int]]) - Groups mapping from group type to group key
  • person_properties? (dict[str, Any]) - Person properties
  • group_properties? (dict[str, dict[str, Any]]) - Group properties in format { group_type_name: { group_properties } }
  • only_evaluate_locally (bool) - Whether to evaluate only locally
  • send_feature_flag_events (bool) - Whether to send feature flag events
  • disable_geoip? (bool) - Whether to disable GeoIP lookup
  • device_id? (str) - Optional device ID override for experience-continuity flags
Returns
  • Union[bool, str, any]
Examples
# Multivariate feature flag
from posthog import get_feature_flag, get_feature_flag_payload
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key':
    matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')

get_feature_flag_evaluation_runtime()

Release Tag: public

Return where a locally loaded feature flag is meant to be evaluated.

Notes:

Reads the evaluation_runtime each flag definition carries, so no extra request is made. Returns None when local evaluation has not loaded a definition for this key. A definition that carries no runtime reports FeatureFlagEvaluationRuntime.ALL, the default PostHog applies.

Parameters
  • key? (str)
Returns
  • Optional[FeatureFlagEvaluationRuntime]
Examples
from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_evaluation_runtime
runtime = get_feature_flag_evaluation_runtime("my-flag")

get_feature_flag_keys_by_evaluation_runtime()

Release Tag: public

Return the keys of locally loaded flags that a runtime can evaluate.

Notes:

A flag set to FeatureFlagEvaluationRuntime.ALL suits either runtime, so it is returned for CLIENT and for SERVER. Use this to decide which flags to hand to a browser when a backend serves flags to its own frontend.

Parameters
  • evaluation_runtime? (FeatureFlagEvaluationRuntime)
Returns
  • list[str]
Examples
from posthog import FeatureFlagEvaluationRuntime, get_feature_flag_keys_by_evaluation_runtime
client_keys = get_feature_flag_keys_by_evaluation_runtime(FeatureFlagEvaluationRuntime.CLIENT)

get_feature_flag_payload()

Release Tag: public

Get the payload associated with a feature flag value. Deprecated for new code. Prefer evaluate_flags() and flags.get_flag_payload(key) so flag evaluation happens once per request.

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The user's distinct ID.
  • match_value (bool) - Optional flag value to use when selecting a payload.
  • groups? (Mapping[str, Union[str, int]]) - Mapping of group type to group key.
  • person_properties? (dict[str, Any]) - Person properties to use for evaluation.
  • group_properties? (dict[str, dict[str, Any]]) - Group properties keyed by group type.
  • only_evaluate_locally (bool) - Whether to evaluate only locally.
  • send_feature_flag_events (bool) - Whether to send a $feature_flag_called event.
  • disable_geoip? (bool) - Whether to disable GeoIP lookup.
  • device_id? (str) - Optional device ID override for experience-continuity flags.
Returns
  • Optional[object]

load_feature_flags()

Release Tag: public

Load feature flag definitions from PostHog.

Returns
  • None
Examples
from posthog import load_feature_flags
load_feature_flags()

Client management methods
flush()

Release Tag: public

Tell the client to flush all queued events.

Parameters
  • timeout_seconds? (float) - Maximum seconds to wait for the queue to flush. Defaults to 10 seconds. Pass None to wait indefinitely.
Returns
  • any
Examples
from posthog import flush
flush()

join()

Release Tag: public

Attempt to process queued events and stop the client's background workers. Use shutdown() directly in most cases. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry.

Returns
  • any
Examples
from posthog import join
join()

shutdown()

Release Tag: public

Flush all messages and cleanly shutdown the client. This normally blocks until queued events have been attempted and cleanup finishes. Failed or undrainable events may be dropped and reported through logging or on_error; returning does not guarantee server receipt. Lifecycle cleanup is attempted once, and cleanup failures are logged without retry. Calls made directly from SDK callbacks such as on_error are deferred to avoid deadlocking the worker. If blocking completion is required, signal an application-owned thread, return from the callback, and call shutdown() from that thread. Do not wait inside a callback for another thread or task calling a lifecycle method.

Returns
  • any
Examples
from posthog import shutdown
shutdown()

Tracing methods
get_active_span()

Release Tag: public

The span that is active in the current context, or None. Alpha. Only entering a span (with posthog.start_span(...) as span:) makes it active; a span started manually is not. Use it to propagate the trace to the next service: span.traceparent() is the header value.

Returns
  • Optional[Span]
Examples
span = posthog.get_active_span()
if span is not None:
    headers["traceparent"] = span.traceparent()

start_span()

Release Tag: public

Start a span for distributed tracing. Alpha. Returns a span handle. Use it as a context manager to make it the active span for the block and end it on exit (recording a raised exception on the way out); or call end() yourself for a span that cannot wrap a block. Spans started inside the block nest under it automatically. Always returns a usable handle, even when tracing is off, so calling code never branches.

Parameters
  • name? (str) - A low-cardinality operation name, e.g. GET /users/:id. Variable values belong in attributes, not the name.
  • kind? (str) - internal (default), server, client, producer or consumer.
  • attributes? (Mapping[str, Any]) - Initial attributes.
  • parent (Span) - A span handle, or an inbound W3C traceparent header value to continue a remote trace. Defaults to the active span.
  • tracestate? (str) - The inbound tracestate header accompanying a traceparent string parent; preserved and propagated.
  • start_time (datetime) - A datetime or epoch seconds, to backdate the span.
Returns
  • Span
Examples
import posthog
posthog.traces = {"service_name": "checkout-api"}

with posthog.start_span("POST /checkout", parent=request.headers.get("traceparent")) as span:
    span.set_attribute("plan", user.plan)
    with posthog.start_span("db.query", kind="client"):
        ...
    outgoing_headers = {"traceparent": span.traceparent()}

Other methods
get_feature_flag_result()

Release Tag: public

Get a FeatureFlagResult object which contains the flag result and payload. This method evaluates a feature flag and returns a FeatureFlagResult object containing: - enabled: Whether the flag is enabled - variant: The variant value if the flag has variants - payload: The payload associated with the flag (automatically deserialized from JSON) - key: The flag key - reason: Why the flag was enabled/disabled

Parameters
  • key? (str) - The feature flag key.
  • distinct_id? (Number) - The user's distinct ID.
  • groups? (Mapping[str, Union[str, int]]) - Mapping of group type to group key.
  • person_properties? (dict[str, Any]) - Person properties to use for evaluation.
  • group_properties? (dict[str, dict[str, Any]]) - Group properties keyed by group type.
  • only_evaluate_locally (bool) - Whether to evaluate only locally.
  • send_feature_flag_events (bool) - Whether to send a $feature_flag_called event.
  • disable_geoip? (bool) - Whether to disable GeoIP lookup.
  • device_id? (str) - Optional device ID override for experience-continuity flags.
Returns
  • Optional[FeatureFlagResult]

get_remote_config_payload()

Release Tag: public

Get the payload for a remote config feature flag.

Parameters
  • key? (str) - The key of the feature flag
Returns
  • None

set_code_variables_mask_url_credentials_context()

Release Tag: public

Whether to scrub credentials embedded in URLs/DSNs (e.g. user:pass@host) from captured code variables for the current context.

Parameters
  • enabled? (bool)
Returns
  • None

Contexts methods
get_tags()

Release Tag: public

Get all tags from the current context. Returns: Dict of all tags in the current context

Returns
  • dict[str, Any]

new_context()

Release Tag: public

Create a new context scope that will be active for the duration of the with block.

Parameters
  • fresh (bool) - Whether to start with a fresh context (default: False)
  • capture_exceptions? (bool) - Whether to capture exceptions raised within the context. If omitted, defaults to the relevant client's exception autocapture setting.
  • client? (Client) - Optional Posthog client instance to use for this context (default: None)
Returns
  • None
Examples
from posthog import new_context, tag, capture
with new_context():
    tag("request_id", "123")
    capture("event_name", properties={"property": "value"})

scoped()

Release Tag: public

Decorator that creates a new context for the function.

Parameters
  • fresh (bool) - Whether to start with a fresh context (default: False)
  • capture_exceptions? (bool) - Whether to capture and track exceptions with posthog error tracking. If omitted, defaults to the global exception autocapture setting.
Returns
  • None
Examples
from posthog import scoped, tag, capture
@scoped()
def process_payment(payment_id):
    tag("payment_id", payment_id)
    capture("payment_started")

set_capture_exception_code_variables_context()

Release Tag: public

Override code-variable capture for exceptions in the current context.

Parameters
  • enabled? (bool) - Whether exceptions captured in this context should include local variable values from stack frames.
Returns
  • None

set_code_variables_detect_secrets_context()

Release Tag: public

Whether to apply entropy-based secret detection as a last-resort redaction of high-entropy values (API keys, tokens, strong passwords) in captured code variables for the current context.

Parameters
  • enabled? (bool)
Returns
  • None

set_code_variables_ignore_patterns_context()

Release Tag: public

Override code-variable ignore patterns for exceptions in the current context.

Parameters
  • ignore_patterns? (list[str]) - Variable-name patterns that should be omitted entirely when code variables are captured.
Returns
  • None

set_code_variables_mask_patterns_context()

Release Tag: public

Override code-variable mask patterns for exceptions in the current context.

Parameters
  • mask_patterns? (list[str]) - Variable-name patterns whose values should be replaced with *** when code variables are captured.
Returns
  • None

set_context_device_id()

Release Tag: public

Set the device ID for the current context, associating all feature flag requests in this or child contexts with the given device ID.

Parameters
  • device_id? (str) - The device ID to associate with the current context and its children
Returns
  • None
Examples
from posthog import set_context_device_id
set_context_device_id("device_123")

set_context_session()

Release Tag: public

Set the session ID for the current context.

Parameters
  • session_id? (str) - The session ID to associate with the current context and its children
Returns
  • None
Examples
from posthog import set_context_session
set_context_session("session_123")

tag()

Release Tag: public

Add a tag to the current context.

Parameters
  • name? (str) - The tag key
  • value? (Any) - The tag value
Returns
  • None
Examples
from posthog import tag
tag("user_id", "123")

Initialization methods
setup()

Release Tag: public

Create or return the global PostHog client configured by module settings. Most applications should either instantiate Posthog directly or set posthog.api_key/other module settings before calling top-level helpers. setup() is called automatically by global APIs such as capture(). Returns: The global Client instance. If both api_key and project_api_key are missing or blank, the client is disabled and module-level calls become no-ops.

Returns
  • Client

references/python.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Python

The Python SDK makes it easy to capture events, evaluate feature flags, track errors, and more in your Python apps.

These docs cover version 7.x of the Python SDK, which requires Python 3.10 or higher. On Python 3.9? See supported versions (#supported-versions).

Installation

Terminal

pip install posthog

Upgrading to v6

Version 6.x of the PostHog Python SDK introduces a new contexts (/docs/libraries/python.md#contexts) API and breaking changes. If you're upgrading from 5.x to 6.x, read the migration guide (/tutorials/python-v6-migration.md) first to learn more.

In your app, import the posthog library and set your project token and host before making any calls.

Python

from posthog import Posthog

posthog = Posthog('<ph_project_token>', host='https://us.i.posthog.com')

Note: As a rule of thumb, we do not recommend having API keys or tokens in plaintext. Setting it as an environment variable is best.

You can find your project token and instance address in the project settings page in PostHog.

Use the asyncio client

The Python SDK includes an asyncio-native client in version 7.45.0 and later. Continue to use Posthog in synchronous apps. For an asyncio app, install the optional async dependencies:

Terminal

pip install "posthog[async]>=7.45.0"

Import AsyncPosthog, the customer-facing name for AsyncClient. Both names provide the same async context manager and lifecycle methods. Keep one client for the lifetime of your app. For example, use a FastAPI lifespan handler:

Python

import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from posthog import AsyncPosthog

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with AsyncPosthog(
        os.environ["POSTHOG_PROJECT_TOKEN"],
        host=os.environ["POSTHOG_HOST"],
        secret_key=os.environ.get("POSTHOG_FEATURE_FLAGS_SECURE_API_KEY"),
    ) as posthog:
        app.state.posthog = posthog
        yield

app = FastAPI(lifespan=lifespan)

Exiting the context calls shutdown(). This flushes buffered events, waits for in-flight operations, stops the workers, and closes the HTTP transport. If you don't use the context manager, call await posthog.shutdown() during app shutdown. await posthog.join() has the same effect.

Capture events without blocking the event loop

capture() queues an event and returns without waiting for a network request. Don't await it:

Python

posthog.capture(
    "event_name",
    distinct_id="user-distinct-id",
    properties={"source": "fastapi"},
)

Use capture_immediate() when your code must wait for that event's delivery attempt:

Python

capture_id = await posthog.capture_immediate(
    "event_name",
    distinct_id="user-distinct-id",
)
Evaluate feature flags

Await evaluate_flags() once, then use its snapshot with synchronous in-memory accessors. Pass the same snapshot to capture() to attach the exact values used for branching without another feature flag request:

Python

flags = await posthog.evaluate_flags("user-distinct-id")

if flags.is_enabled("new-checkout"):
    # Show the new checkout
    pass

posthog.capture(
    "checkout started",
    distinct_id="user-distinct-id",
    flags=flags,
)

The snapshot provides synchronous is_enabled(), get_flag(), and get_flag_payload() accessors. The awaited evaluate_flags() call also accepts groups, person_properties, group_properties, disable_geoip, flag_keys, and device_id arguments.

Fetch remote config

Initialize the client with a server-side feature flags secure API key (/docs/feature-flags/remote-config.md#step-1-find-your-feature-flags-secure-api-key) as secret_key, then await the remote config request:

Python

config = await posthog.get_remote_config_payload("landing-page-config")

See Remote config (/docs/feature-flags/remote-config.md) for setup and security details.

Identifying users

Identifying users is required. Backend events need a distinct_id to associate events with the correct user.

In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct distinct_id. Typically, you would set a fresh context and identify at the top of each route.

Python

from posthog import new_context, identify_context, capture

@app.get("/foo")
def foo(current_user: User = Depends(get_current_user)):
    with new_context(): # Set context at the top of a route
        identify_context(current_user.id)
        capture("foo_viewed")
    return {"status": "ok"}

When possible, write a small piece of middleware that resolves your authenticated user, wrap a context around the request, and identifies it. Every capture() downstream is then attributed automatically. The SDK's Django middleware does this automatically and you can replicate it when using the plain Python SDK.

Capturing events

You can send custom events using capture:

Python

# Events captured with no context or explicit distinct_id are marked as personless and have an auto-generated distinct_id:
posthog.capture('some-anon-event')

from posthog import identify_context, new_context

# Use contexts to manage user identification across multiple capture calls
with new_context():
    identify_context('distinct_id_of_the_user')
    posthog.capture('user_signed_up')
    posthog.capture('user_logged_in')
    # You can also capture events with a specific distinct_id
    posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Tip: You can define event schemas with typed properties and generate type-safe code using schema management (/docs/product-analytics/schema-management.md).

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Python

posthog.capture(
    "user_signed_up",
    distinct_id="distinct_id_of_the_user",
    properties={
        "login_type": "email",
        "is_free_trial": "true"
    }
)
Sending page views

If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:

Python

posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})

Person profiles and properties

The Python SDK captures identified events if the current context is identified or if you pass a distinct ID explicitly. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:

Python

# Passing a distinct id explicitly
posthog.capture(
    'event_name',
    distinct_id='user-distinct-id',
    properties={
        '$set': {'name': 'Max Hedgehog'},
        '$set_once': {'initial_url': '/blog'}
    }
)

# Using contexts
from posthog import new_context, identify_context
with new_context():
    identify_context('user-distinct-id')
    posthog.capture('event_name')

For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).

To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to False. Events captured with no context or explicit distinct_id are marked as personless, and will have an auto-generated distinct_id:

Python

posthog.capture(
    event='event_name',
    properties={
        '$process_person_profile': False
    }
)

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Python

posthog.alias(previous_id='distinct_id', distinct_id='alias_id')

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Contexts

The Python SDK uses nested contexts for managing state that's shared across events. Contexts are the recommended way to manage things like "which user is taking this action" (through identify_context), rather than manually passing user state through your apps stack.

When events (including exceptions) are captured in a context, the event uses the user distinct ID (/docs/getting-started/identify-users.md), session ID (/docs/data/sessions.md), and tags that are (optionally) set in the context. This is useful for adding properties to multiple events during a single user's interaction with your product.

You can enter a context using the with statement:

Python

from posthog import new_context, tag, set_context_session, identify_context

with new_context():
    tag("transaction_id", "abc123")
    tag("some_arbitrary_value", {"tags": "can be dicts"})

    # Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session
    # See https://posthog.com/docs/data/sessions
    set_context_session(session_id)

    # Setting the context-level distinct ID. See below for more details.
    identify_context(user_id)

    # This event is captured with the distinct ID, session ID, and tags set above
    posthog.capture("order_processed")

Contexts are persisted across function calls. If you enter one and then call a function and capture an event in the called function, it uses the context tags and session ID set in the parent context:

Python

from posthog import new_context, tag

def some_function():
    # When called from `outer_function`, this event is captured with the property some-key="value-4"
    posthog.capture("order_processed")


def outer_function():
    with new_context():
        tag("some-key", "value-4")
        some_function()

Contexts are nested, so tags added to a parent context are inherited by child contexts. If you set the same tag in both a parent and child context, the child context's value overrides the parent's at event capture (but the parent context won't be affected). This nesting also applies to session IDs and distinct IDs.

Python

from posthog import new_context, tag

with new_context():
    tag("some-key", "value-1")
    tag("some-other-key", "another-value")
    with new_context():
        tag("some-key", "value-2")
        # This event is captured with some-key="value-2" and some-other-key="another-value"
        posthog.capture("order_processed")

    # This event is captured with some-key="value-1" and some-other-key="another-value"
    posthog.capture("order_processed")

You can disable this nesting behavior by passing fresh=True to new_context:

Python

from posthog import new_context, tag

with new_context(fresh=True):
    tag("some-key", "value-2")
    # This event only has the property some-key="value-2" from the fresh context
    posthog.capture("order_processed")

Note: Distinct IDs, session IDs, and properties passed directly to calls to capture and related functions override context state in the final event captured.

Contexts and user identification

Contexts can be associated with a distinct ID by calling posthog.identify_context:

Python

from posthog import identify_context

identify_context("distinct-id")

Within a context associated with a distinct ID, all events captured are associated with that user. You can override the distinct ID for a specific event by passing a distinct_id argument to capture:

Python

from posthog import new_context, identify_context

with new_context():
    identify_context("distinct-id")
    posthog.capture("order_processed") # will be associated with distinct-id
    posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-id

It's recommended to pass the currently active distinct ID from the frontend to the backend, using the X-POSTHOG-DISTINCT-ID header. If you're using our Django middleware, this is extracted and associated with the request handler context automatically.

You can read more about identifying users in the user identification documentation (/docs/product-analytics/identify.md).

Contexts and sessions

Contexts can be associated with a session ID by calling posthog.set_context_session. When linking backend events to frontend sessions, use the session ID from the frontend SDK (PostHog session IDs are UUIDv7 strings).

Python

from posthog import new_context, set_context_session
with new_context():
    set_context_session(request.get_header("X-POSTHOG-SESSION-ID"))

Using PostHog on your frontend too?

If you're using the PostHog JavaScript Web SDK on your frontend, it generates a session ID for you. Configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests automatically.

You need to extract the header in your request handler (if you're using our Django middleware integration, this happens automatically).

If you associate a context with a session, you'll be able to do things like:

  • See backend events on the session timeline when viewing session replays
  • View session replays for users that triggered a backend exception in error tracking

You can read more about sessions in the session tracking (/docs/data/sessions.md) documentation.

Exception capture

By default exceptions raised within a context are captured and available in the error tracking (/docs/error-tracking.md) dashboard. You can override this behavior by passing capture_exceptions=False to new_context:

Python

from posthog import new_context, tag

with new_context(capture_exceptions=False):
    tag("transaction_id", "abc123")
    tag("some_arbitrary_value", {"tags": "can be dicts"})

    # This event will be captured with the tags set above
    posthog.capture("order_processed")
    # This exception will not be captured
    raise Exception("Order processing failed")
Decorating functions

The SDK exposes a function decorator. It takes the same fresh and capture_exceptions arguments as new_context and provides a handy way to mark a whole function as being in a new context. For example:

Python

from posthog import scoped, identify_context

@scoped(fresh=True)
def process_order(user, order_id):
    identify_context(user.distinct_id)
    posthog.capture("order_processed") # Associated with the user
    raise Exception("Order processing failed") # This exception is also captured and associated with the user

Group analytics

Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our pricing page (/pricing.md).

To capture an event and associate it with a group:

Python

posthog.capture('some_event', groups={'company': 'company_id_in_your_db'})

To update properties on a group:

Python

posthog.group_identify('company', 'company_id_in_your_db', {
    'name': 'Awesome Inc.',
    'employees': 11
})

The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.

Feature flags

The examples in this section use the synchronous Posthog client. For AsyncPosthog, use the awaited feature flag example (#evaluate-feature-flags). The returned snapshot uses the same accessors.

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in Python:

Step 1: Evaluate flags once

Call posthog.evaluate_flags() once for the user, then read values from the returned snapshot.

Boolean feature flags

Python

flags = posthog.evaluate_flags("distinct_id_of_your_user")

if flags.is_enabled("flag-key"):
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = flags.get_flag_payload("flag-key")
Multivariate feature flags

Python

flags = posthog.evaluate_flags("distinct_id_of_your_user")

enabled_variant = flags.get_flag("flag-key")

if enabled_variant == "variant-key":  # replace "variant-key" with the key of your variant
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = flags.get_flag_payload("flag-key")

flags.get_flag() returns the variant string for multivariate flags, True for enabled boolean flags, False for disabled flags, and None when the flag wasn't returned by the evaluation.

Note: posthog.feature_enabled(), posthog.get_feature_flag(), posthog.get_feature_flag_payload(), and posthog.capture(send_feature_flags=True) still work during the migration period, but they're deprecated. Prefer posthog.evaluate_flags() for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Pass the evaluated flags snapshot to capture()

Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.

Python

flags = posthog.evaluate_flags("distinct_id_of_your_user")

if flags.is_enabled("flag-key"):
    # Do something differently for this user
    pass

posthog.capture(
    "event_name",
    distinct_id="distinct_id_of_your_user",
    flags=flags,
)

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, pass a filtered snapshot:

Python

# Attach only flags accessed with is_enabled() or get_flag() before this call
posthog.capture(
    "event_name",
    distinct_id="distinct_id_of_your_user",
    flags=flags.only_accessed(),
)

# Attach only specific flags
posthog.capture(
    "event_name",
    distinct_id="distinct_id_of_your_user",
    flags=flags.only(["checkout-flow", "new-dashboard"]),
)

only_accessed() is order-dependent. If you call it before accessing any flags with is_enabled() or get_flag(), no feature flag properties are attached.

Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

Python

posthog.capture(
    "event_name",
    distinct_id="distinct_id_of_the_user",
    properties={
        # Replace feature-flag-key with your flag key and "variant-key" with the key of your variant
        "$feature/feature-flag-key": "variant-key",
    },
)
Evaluating only specific flags

By default, posthog.evaluate_flags() evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:

Python

flags = posthog.evaluate_flags(
    "distinct_id_of_your_user",
    flag_keys=["checkout-flow", "new-dashboard"],
)
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With posthog.evaluate_flags(), the SDK sends this event when you call flags.is_enabled() or flags.get_flag() for a flag.

The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.

flags.get_flag_payload() doesn't send $feature_flag_called events and doesn't count as an access for only_accessed().

Advanced: Overriding server properties

Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.

You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.

For example:

Python

flags = posthog.evaluate_flags(
    "distinct_id_of_the_user",
    person_properties={"property_name": "value"},
    groups={
        "your_group_type": "your_group_id",
        "another_group_type": "your_group_id",
    },
    group_properties={
        "your_group_type": {"group_property_name": "value"},
        "another_group_type": {"group_property_name": "value"},
    },
)

if flags.is_enabled("flag-key"):
    # Do something differently for this user
Overriding GeoIP properties

By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.

You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.

The following GeoIP properties can be overridden:

  • $geoip_country_code
  • $geoip_country_name
  • $geoip_city_name
  • $geoip_city_confidence
  • $geoip_continent_code
  • $geoip_continent_name
  • $geoip_latitude
  • $geoip_longitude
  • $geoip_postal_code
  • $geoip_subdivision_1_code
  • $geoip_subdivision_1_name
  • $geoip_subdivision_2_code
  • $geoip_subdivision_2_name
  • $geoip_subdivision_3_code
  • $geoip_subdivision_3_name
  • $geoip_time_zone

Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.

Request timeout

You can configure the feature_flags_request_timeout_seconds parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.

Python

posthog = Posthog(
    "<ph_project_token>",
    host="https://us.i.posthog.com",
    feature_flags_request_timeout_seconds=3,  # Time in seconds. Defaults to 3.
)
Local evaluation

Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.

It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.

For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).

Distributed environments

In multi-worker or edge environments, you can implement custom caching for flag definitions using Redis, Cloudflare KV, or other storage backends. This enables sharing definitions across workers and coordinating fetches. See our guide for local evaluation in distributed environments (/docs/feature-flags/local-evaluation/distributed-environments?tab=Python.md) for details.

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. This example uses the synchronous Posthog client:

Python

flags = posthog.evaluate_flags("user_distinct_id")
variant = flags.get_flag("experiment-feature-flag-key")

if variant == "variant-name":
    # Do something

With AsyncPosthog, await the evaluation: flags = await posthog.evaluate_flags("user_distinct_id"). The remaining snapshot access is the same.

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

AI Observability

Our Python SDK includes a built-in AI Observability feature. It enables you to capture LLM usage, performance, and more. Check out our analytics docs (/docs/ai-observability.md) for more details on setting it up.

Error tracking

You can autocapture exceptions (/docs/error-tracking/installation.md) by setting the enable_exception_autocapture argument to True when initializing the PostHog client.

Python

from posthog import Posthog

posthog = Posthog("<ph_project_token>", enable_exception_autocapture=True, ...)

You can also manually capture exceptions using the capture_exception method:

Python

posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties)

Contexts automatically capture exceptions thrown inside them, unless disable it by passing capture_exceptions=False to new_context().

Code variables capture

The Python SDK can automatically capture the state of local variables when an exception occurs. This gives you a debugger-like view of your application state at the time of the error:

Python

posthog = Posthog(
    "<ph_project_token>",
    enable_exception_autocapture=True,
    capture_exception_code_variables=True,
)

You can configure which variables are captured, masked, or ignored. See the code variables documentation (/docs/error-tracking/code-variables/python.md) for detailed configuration options.

Distributed tracing

Requires posthog version 7.58.0 or later.

The span API is experimental

Tracing is new in the Python SDK and its API can still change in a minor release. Spans you send are kept – it's the SDK surface that isn't frozen yet.

Tracing records spans – timed units of work – so you can see where time went in a request and how work fans out across your services. Spans created inside a context (#contexts) automatically carry the person and session they belong to, so a slow trace links back to the person who experienced it.

Tracing is off until you set the traces option. No OpenTelemetry dependency is required. For what you can do with spans once they arrive, see Distributed tracing (/docs/distributed-tracing/start-here.md).

Python

from posthog import Posthog

posthog = Posthog(
    "<ph_project_token>",
    host="https://us.i.posthog.com",
    traces={"service_name": "checkout-api"},
)

If you use the module-level API instead, set posthog.traces = {"service_name": "checkout-api"} alongside your other options, before you start the first span.

Set service_name – PostHog groups operations by service and span name.

Tracing is available on the synchronous Posthog client and the module-level API. AsyncPosthog doesn't support it yet.

Creating spans

start_span returns a span. Use it in a with block to make it the active span for the block and end it when the block exits. Spans started inside the block nest underneath it automatically.

Python

with posthog.start_span("POST /checkout", kind="server") as span:
    span.set_attribute("plan", user.plan)

    with posthog.start_span("create-order"):
        order = create_order(cart)
    with posthog.start_span("charge-card"):
        stripe.charge(order)

If an exception escapes the block, the span records it, its status is set to error, and the exception propagates unchanged. KeyboardInterrupt, GeneratorExit, and asyncio.CancelledError still end the span but aren't recorded as failures.

The recorded exception includes the stack trace, which contains file paths from your server. If you'd rather those didn't leave your process, remove exception.stacktrace in before_span_send (#scrubbing-and-dropping-spans).

For work that can't wrap a block, call start_span without with. A span started this way isn't active, so spans started afterwards aren't its children unless you pass parent explicitly – and you must call end() yourself.

Python

span = posthog.start_span("background-sync", attributes={"queue": "emails"})
try:
    # Explicitly parent a child to a span that isn't active.
    child = posthog.start_span("send-batch", parent=span)
    child.end()
finally:
    span.end()

get_active_span() returns the span active in the current context, or None when there isn't one.

The active span is tracked with contextvars, so it carries across await in asyncio code. Threads don't reliably inherit it – pass parent=span to continue the trace in a thread you start or a ThreadPoolExecutor task. A forked child process starts with no active span, so pass parent there too.

start_span always returns a usable span, even when tracing is off, so your code never needs to check whether tracing is enabled.

Span names and attributes

Span names should be low-cardinality operation names – GET /users/:id, not GET /users/123. Variable values belong in attributes. Strings, booleans, integers, and floats keep their type. Lists and dictionaries are sent as arrays and maps, but PostHog stores them as serialized strings. Setting an attribute to None removes it, and any other value is converted to a string.

Python

with posthog.start_span("GET /users/:id", kind="server") as span:
    span.set_attributes({"user.id": user_id, "db.rows": len(rows)})
    span.add_event("cache-miss")

    if not rows:
        span.set_status("error", "user not found")
Method Description
set_attribute(key, value) Set a single attribute
set_attributes(attributes) Merge several attributes at once
add_event(name, attributes=None, timestamp=None) Record a timestamped event within the span
set_status(code, message=None) Set the outcome: "ok" or "error". "ok" is final – an exception raised later in the with block doesn't override it
record_exception(exception) Attach an exception event carrying the type, message, and – for a raised exception – stack trace, and set status to error. Use it for exceptions you catch and handle
update_name(name) Replace the span name, e.g. once a route template resolves
traceparent() This span's W3C traceparent header value, or None
tracestate() This span's W3C tracestate value, or None when it has none
end(end_time=None) End the span and queue it for export. A with block does this for you

Every method except traceparent(), tracestate(), and end() returns the span, so calls chain. Calls after end() are ignored.

start_span takes these keyword arguments:

Argument Description
kind What the work is: "internal" (default), "server" for an inbound request, "client" for an outbound call, "producer" or "consumer" for queue work
attributes Attributes to set at span start
parent A span, or an inbound W3C traceparent string to continue a trace another service started. Defaults to the active span
tracestate The W3C tracestate accompanying a traceparent string. Ignored when parent is a span, which inherits its parent's
start_time Backdate the span's start, as a datetime or seconds since the epoch. The server clamps a start more than 24 hours old to receive time; with debug on, the SDK prints a debug message when you pass one
Tracing across services

Spans use W3C Trace Context, so a trace can span several services. Pass an inbound traceparent header as parent to continue a trace another service started, and send span.traceparent() onward when you call out.

app.py

import requests
from flask import request


@app.post("/checkout")
def checkout():
    with posthog.start_span(
        "POST /checkout",
        kind="server",
        parent=request.headers.get("traceparent"),
    ) as span:
        traceparent = span.traceparent()

        requests.post(
            "https://payments.internal/charge",
            headers={"traceparent": traceparent} if traceparent else {},
        )

        return {"status": "ok"}

A malformed traceparent starts a new trace rather than raising. A missing one (None) falls back to the active span, if there is one.

A continued trace propagates the sampled flag it was handed, so a downstream sampler sees the decision the head service made. PostHog itself doesn't sample – a span is recorded and exported whichever way that flag is set.

Linking traces to people and sessions

Spans created inside a context (#contexts) that has a distinct ID or session ID automatically carry posthogDistinctId and sessionId attributes, which is what makes a trace reachable from a person or a Session Replay recording. In Django, the contexts middleware (/docs/libraries/django.md#django-contexts-middleware) sets these for every request. Elsewhere, set them yourself:

Python

from posthog import new_context, identify_context, set_context_session

with new_context():
    identify_context(user.id)
    set_context_session(session_id)

    with posthog.start_span("POST /checkout"):
        process_order()

Spans created outside a context with those values omit the attributes.

Scrubbing and dropping spans

before_span_send runs on every finished span before it's queued for export. It receives the span as a dict with name, kind, status, attributes, events, start_time_ns, end_time_ns, trace_id, span_id, and parent_span_id. Edit it and return it, or return None to drop the span entirely.

Python

def scrub_spans(span):
    if span["attributes"].get("http.route") == "/health":
        return None

    span["attributes"].pop("http.request.header.authorization", None)
    return span


posthog = Posthog(
    "<ph_project_token>",
    host="https://us.i.posthog.com",
    traces={
        "service_name": "checkout-api",
        "before_span_send": scrub_spans,
    },
)

Attributes are plain Python values, not the OTLP wire encoding. The hook runs after PostHog attaches posthogDistinctId and sessionId, so those are visible to the hook and can be scrubbed too. An exception's stack trace is on its event, under event["attributes"]["exception.stacktrace"].

  • trace_id, span_id, and parent_span_id are read-only. Rewriting them would orphan child spans that have already been exported, so changes are reverted.
  • A hook that raises drops the span rather than exporting it without scrubbing.
  • Pass a list to run several hooks in order. The first one to return None stops the chain.
  • The hook must be a regular function. An async hook drops every span.
  • If an entry isn't callable, tracing turns off for the client rather than exporting spans the hook was meant to scrub.
Span limits

A span is capped at 128 attributes and 128 events, each event at 128 attributes, and each string attribute value at 8192 characters. The endpoint rejects a span that's too large, and a rejected span is lost whole rather than truncated, so the caps bound a span before it gets there.

Past the cap, the earliest attributes and events are kept and the number dropped is reported alongside the span, so a truncated span reads as truncated rather than as quietly incomplete. The attributes PostHog attaches itself – posthogDistinctId and sessionId – don't count toward the cap and are never dropped, so a span at the limit still links back to its person and session.

The event cap is absolute: an exception event the SDK records for you spends an ordinary slot like any other. A span that fills its events and then raises keeps its error status but not the exception detail, and reports the loss as a dropped event. Raise max_events_per_span on spans that record many events and can also fail.

The length bound reaches inside a value, including strings nested in lists and dictionaries, and applies to exception.stacktrace like any other attribute – a long stack trace keeps its last 8192 characters. All four caps are re-applied after before_span_send, so a hook that enriches a span can't push it back over.

Configuration
Option Default Description
service_name – Name of the service producing spans. Set this
service_version – Version of the service
environment – Deployment environment, e.g. production
resource_attributes – Extra OTLP resource attributes. Takes precedence over the fields above
flush_interval 5 Seconds between exports of queued spans
max_export_batch_size 512 Maximum spans per request
max_queue_size 2048 Maximum spans held in memory. Spans beyond this are dropped
max_live_spans 10000 Maximum spans open at once. At the limit start_span returns a span that isn't recorded
max_span_age 3600 Once max_live_spans is reached, spans open longer than this many seconds are treated as leaked and never exported
before_span_send – Edit or drop each finished span before export. Return None to drop it
max_attributes_per_span 128 Maximum attributes you set on one span
max_events_per_span 128 Maximum events on one span
max_attribute_value_length 8192 Maximum characters in a string attribute value

An invalid value falls back to its default with a warning. The exception is before_span_send: an entry that isn't callable turns tracing off.

Shutdown and short-lived processes

Spans are exported on a background interval, even with sync_mode on. Both flush() and shutdown() export spans that have already ended. A span still open at flush() is exported once it ends; a span still open at shutdown() is discarded with a warning, so end your spans before shutting down – a with block does this for you. shutdown() gives queued spans up to 30 seconds to send.

In a serverless handler, call flush() before returning. Events and spans are flushed concurrently, so it costs one round trip, not two.

Python

def handler(event, context):
    with posthog.start_span("handler"):
        do_work()
    posthog.flush()

A script that exits without calling shutdown() still gets a brief best-effort flush at exit, but don't rely on it for spans you need.

GeoIP properties

Before posthog-python v3.0, we added GeoIP properties to all incoming events by default. We also used these properties for feature flag evaluation, based on the IP address of the request. This isn't ideal since they are created based on your server IP address, rather than the user's, leading to incorrect location resolution.

As of posthog-python v3.0, the default now is to disregard the server IP, not add the GeoIP properties, and not use the values for feature flag evaluations.

You can go back to previous behavior by doing setting the disable_geoip argument in your initialization to False:

Python

posthog = Posthog('api_key', disable_geoip=False)

The list of properties that this overrides:

  1. $geoip_city_name
  2. $geoip_country_name
  3. $geoip_country_code
  4. $geoip_continent_name
  5. $geoip_continent_code
  6. $geoip_postal_code
  7. $geoip_time_zone

You can also explicitly chose to enable or disable GeoIP for a single capture request like so:

Python

posthog.capture('test_event', disable_geoip=True|False)

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.

You can enable debug mode by setting the debug option to True in the PostHog object. This will enable verbose logs about the inner workings of the SDK.

Python

posthog.debug = True

Disabling requests during tests

You can disable requests during tests by setting the disabled option to True in the PostHog object. This means no events will be captured or no requests will be sent to PostHog.

Python

if settings.TEST:
    posthog.disabled = True

Connection configuration

The SDK uses HTTP connection pooling internally for better performance. These settings typically need not be changed, but in some environments, such as when running behind NAT gateways, pooled connections may be terminated non-gracefully, causing request failures.

You can configure connection behavior in several ways. The following settings should be called during initialization, before any API requests are made.

Enable TCP keepalive

TCP keepalive probes help prevent idle connections from being dropped by network infrastructure. This is the recommended approach for most cases where idle connections are terminated.

Python

import posthog

posthog.enable_keep_alive()

This enables TCP keepalive with sensible defaults (60 second idle time, 60 second probe interval, 3 probes before timeout).

Disable connection pooling

If you need each request to use a fresh connection, you can disable connection reuse entirely. This will incur additional overhead per request but may be desirable in some circumstances.

Python

import posthog

posthog.disable_connection_reuse()
Custom HTTP socket options

For advanced use cases, you can configure arbitrary socket options on the underlying HTTP connection.

Python

import socket
import posthog

posthog.set_socket_options([
    (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
    # Add additional socket options as needed
])

Pass None to set_socket_options() to reset to default behavior.

Filtering or modifying events before sending

Use before_send to modify or drop events before they are queued for delivery. Return the modified event dictionary to send it, or None to drop it.

Python

from typing import Any

import posthog


def scrub_pii(event: dict[str, Any]) -> dict[str, Any] | None:
    properties = event.get("properties", {})

    if "email" in properties:
        email = properties["email"]
        properties["email"] = f"***@{email.split('@', 1)[1]}" if "@" in email else "***"

    if event.get("event") == "test_event":
        return None

    return event


client = posthog.Client(
    "<ph_project_api_key>",
    before_send=scrub_pii,
)

If your callback raises an exception, the SDK logs the error and continues with the original unmodified event.

Historical migrations

You can use the Python or Node SDK to run historical migrations (/docs/migrate.md) of data into PostHog. To do so, set the historical_migration option to true when initializing the client.

Python
from posthog import Posthog
from datetime import datetime

posthog = Posthog(
    '<ph_project_token>',
    host='https://us.i.posthog.com',
    debug=True,
    historical_migration=True
)

events = [
  {
    "event": "batched_event_name",
    "distinct_id": "user_id",
    "timestamp": datetime.fromisoformat("2024-04-02T12:00:00+00:00"),
    "properties": {"account_type": "pro"}
  },
  {
    "event": "batched_event_name",
    "distinct_id": "user_id",
    "timestamp": datetime.fromisoformat("2024-04-03T09:30:00+00:00"),
    "properties": {"account_type": "pro"}
  }
]

for event in events:
  posthog.capture(
    event["event"],
    distinct_id=event["distinct_id"],
    properties=event["properties"],
    timestamp=event["timestamp"],
  )

posthog.shutdown()
Node.js
import { PostHog } from 'posthog-node'

const client = new PostHog(
    '<ph_project_token>',
    {
      host: 'https://us.i.posthog.com',
      historicalMigration: true
    }
)

client.debug()

client.capture({
    event: "batched_event_name",
    distinctId: "user_id",
    properties: {},
    timestamp: new Date("2024-04-03T12:00:00Z")
})

client.capture({
    event: "batched_event_name",
    distinctId: "user_id",
    properties: {},
    timestamp: new Date("2024-04-03T13:00:00Z")
})

await client.shutdown()

Serverless environments (Render/Lambda/...)

Synchronous Posthog

By default, the synchronous Posthog client buffers events before sending them to the capture endpoint. This can lead to lost events if the platform terminates the Python process before the buffer is fully flushed. To avoid this, you can either:

  • Call posthog.shutdown() before the process ends. This blocking call attempts to deliver queued events and cleans up the client.
  • Enable sync_mode when initializing the client so each posthog.capture() call attempts delivery before it returns.

If you use distributed tracing (#distributed-tracing), sync_mode doesn't apply to spans. Call posthog.flush() before the handler returns – see Shutdown and short-lived processes (#shutdown-and-short-lived-processes).

Asyncio AsyncPosthog

Keep one AsyncPosthog client for the lifetime of your application. Use buffered capture() by default, or await capture_immediate() when one invocation must wait for an event's delivery attempt. Call await posthog.shutdown() once during application cleanup. Don't shut down the client after each request.

Django

See our Django docs (/docs/libraries/django.md) for how to set up PostHog in Django. Our library includes a contexts middleware (/docs/libraries/django.md#django-contexts-middleware) that can automatically capture distinct IDs, session IDs, and other properties you can set up with tags.

Alternative name

As our open source project PostHog shares the same module name, we created a special posthoganalytics package, mostly for internal use to avoid module collision. It is the exact same.

Thank you

This library is largely based on the analytics-python package.

Supported versions

These docs cover version 7.x of the PostHog Python SDK, which requires Python 3.10 or higher. Python 3.9 is no longer supported on 7.x.x and higher — pin to the 6.x line with pip install 'posthog<7', where 6.9.3 is the final release.

Everything on this page works the same way on 6.9.3. Event capture, the context API (new_context, identify_context, set_context_session), and PosthogContextMiddleware are identical on 6.9.3 and 7.0.0 — 7.0.0 only dropped Python 3.9 and bumped the optional LLM provider SDKs. That includes the middleware identifying the request context from the X-POSTHOG-DISTINCT-ID header and falling back to the authenticated user, which behaves the same across both lines.

Later 7.x releases add what the 6.x line does not receive, such as the Celery integration, tracing header sanitization, and set_context_device_id. They also changed the middleware's own captured properties: 7.x sends the request IP as $ip, where 6.9.3 sends it as $ip_address, and 7.x additionally captures $request_path, $raw_user_agent, and the authenticated user's email.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/react-native.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

React Native

Installation

Our React Native enables you to integrate PostHog with your React Native project. For React Native projects built with Expo, there are no mobile native dependencies outside of supported Expo packages.

To install, add the posthog-react-native package to your project as well as the required peer dependencies.

Expo apps

Terminal

npx expo install posthog-react-native expo-file-system expo-application expo-device expo-localization
React Native apps

Terminal

yarn add posthog-react-native @react-native-async-storage/async-storage react-native-device-info react-native-localize
# or
npm i -s posthog-react-native @react-native-async-storage/async-storage react-native-device-info react-native-localize
React Native Web and macOS

If you're using React Native Web or React Native macOS, do not use the expo-file-system package since the Web and macOS targets aren't supported, use the @react-native-async-storage/async-storage package instead.

Configuration
With the PosthogProvider

The recommended way to set up PostHog for React Native is to use the PostHogProvider. This utilizes the Context API to pass the PostHog client around, and enables autocapture (/docs/product-analytics/autocapture.md).

To set up PostHogProvider, add it to your App.js or App.ts file:

App.js

// App.(js|ts)
import { usePostHog, PostHogProvider } from 'posthog-react-native'
...

export function MyApp() {
    return (
        <PostHogProvider apiKey="<ph_project_token>" options={{
            // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
            host: 'https://us.i.posthog.com',
        }}>
            <MyComponent />
        </PostHogProvider>
    )
}

Then you can access PostHog using the usePostHog() hook:

React Native

const MyComponent = () => {
    const posthog = usePostHog()

    useEffect(() => {
        posthog.capture("event_name")
    }, [posthog])
}
Without the PosthogProvider

If you prefer not to use the provider, you can initialize PostHog in its own file and import the instance from there:

posthog.ts

import PostHog from 'posthog-react-native'

export const posthog = new PostHog('<ph_project_token>', {
  // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
  host: 'https://us.i.posthog.com'
})

Then you can access PostHog by importing your instance:

React Native

import { posthog } from './posthog'

export function MyApp1() {
    useEffect(() => {
        posthog.capture('event_name')
    }, [])

    return <View>Your app code</View>
}

You can even use this instance with the PostHogProvider:

React Native

import { posthog } from './posthog'

export function MyApp() {
  return <PostHogProvider client={posthog}>{/* Your app code */}</PostHogProvider>
}
Choose an iOS dependency path for the native plugin

The optional @posthog/react-native-plugin package adds native features such as session replay and native crash capture. Install it as described in the guide for the feature that you use. Then choose one iOS dependency path:

Path Requirements What it resolves
CocoaPods A React Native project that uses CocoaPods CocoaPods resolves the plugin and posthog-ios. This remains the default path.
CocoaPods with posthog-ios through Swift Package Manager React Native 0.75 or later and a CocoaPods project with dynamic frameworks CocoaPods resolves the plugin. Swift Package Manager resolves posthog-ios.
Full Swift Package Manager Verified with an iOS-only React Native 0.87.1 app and React Native Community CLI 20.2.0. Requires @posthog/react-native-plugin 2.4.0 or later, Xcode 16 or later, and an iOS 15.1 or later app deployment target. React Native's experimental Swift Package Manager integration resolves the plugin and posthog-ios. This path does not use CocoaPods.

This verification does not cover Expo or other React Native versions. Use CocoaPods or the hybrid path unless you validate the full Swift Package Manager path for your configuration.

CocoaPods

Use the standard React Native CocoaPods flow:

Terminal

cd ios
pod install

The plugin podspec adds posthog-ios as a CocoaPods dependency. You do not need to add posthog-ios separately.

CocoaPods with posthog-ios through Swift Package Manager

Add the following property to ios/Podfile.properties.json:

JSON

{
  "posthog.useSpm": "true"
}

Add dynamic frameworks to your ios/Podfile:

Ruby

use_frameworks! :linkage => :dynamic

Then install the pods:

Terminal

cd ios
pod install

This setting changes only how the plugin resolves posthog-ios. The plugin and other React Native dependencies still use CocoaPods.

Full Swift Package Manager

This path uses React Native's experimental CocoaPods-free iOS integration. Every native dependency in your app must support React Native's full Swift Package Manager integration. Use CocoaPods or the hybrid path if a dependency does not support it.

Install your JavaScript dependencies first. Make a clean commit or a backup of your iOS project before the conversion. Then run this command from the ios directory:

Terminal

npx react-native spm add --deintegrate --yes

The --deintegrate option removes the complete CocoaPods integration from the iOS project. React Native then finds the plugin's ios/Package.swift manifest. Swift Package Manager resolves the plugin and posthog-ios. Do not run pod install for this path.

PostHog CI verifies this path with the configuration in the requirements table. The verified app sets its deployment target to iOS 15.1. The plugin package manifest has a separate iOS 15 minimum. The CocoaPods and hybrid paths keep the plugin podspec's iOS 13 minimum.

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Configuration options

You can further customize how PostHog works through its configuration on initialization.

Attribute Description
host Type: String Default: https://us.i.posthog.com PostHog API host (usually https://us.i.posthog.com by default or https://eu.i.posthog.com). Host is optional if you use https://us.i.posthog.com.
flushAt Type: Number Default: 20 The number of events to queue before sending to PostHog (flushing).
flushInterval Type: Number Default: 10000 The interval in milliseconds between periodic flushes.
maxBatchSize Type: Number Default: 100 The maximum number of queued messages to be flushed as part of a single batch (must be higher than flushAt).
maxQueueSize Type: Number Default: 1000 The maximum number of cached messages either in memory or on the local storage (must be higher than flushAt).
disabled Type: Boolean Default: false If set to true, the SDK is essentially disabled (useful for local environments where you don't want to track anything).
defaultOptIn Type: Boolean Default: true If set to false, the SDK will not track until the optIn() function is called.
sendFeatureFlagEvent Type: Boolean Default: true Whether to track that getFeatureFlag was called (used by experiments).
preloadFeatureFlags Type: Boolean Default: true Whether to load feature flags when initialized or not.
bootstrap Type: Object Default: {} Seeds identity (distinctId, isIdentifiedId) and feature flag state (featureFlags, featureFlagPayloads) during initialization. See SDK bootstrapping (/docs/libraries/bootstrapping.md).
disableRemoteFeatureFlags Type: Boolean Default: false When true, the SDK never fetches or evaluates feature flags from PostHog, and identify(), group(), and reset() stop triggering /flags requests. Supply flag values yourself via bootstrap (at startup) and updateFlags() (at runtime). Available in version 4.49.0+.
fetchRetryCount Type: Number Default: 3 How many times HTTP requests will be retried.
fetchRetryDelay Type: Number Default: 3000 The delay between HTTP request retries.
requestTimeout Type: Number Default: 10000 Timeout in milliseconds for any calls.
featureFlagsRequestTimeoutMs Type: Number Default: 10000 Timeout in milliseconds for feature flag calls.
sessionExpirationTimeSeconds Type: Number Default: 1800 For session analysis, how long before a session expires (defaults to 30 minutes).
persistence Type: String Default: file Allows you to provide the storage type. file will try to load the best available storage, the provided customStorage, customAsyncStorage, or in-memory storage.
customAppProperties Type: Object or Function Default: null Allows you to provide your own implementation of the common information about your App or a function to modify the default App properties generated.
customStorage Type: Object Default: null Allows you to provide a custom asynchronous storage such as async-storage, expo-file-system, or a synchronous storage such as mmkv. If not provided, PostHog will attempt to use the best available storage via optional peer dependencies. If persistence is set to memory, this option is ignored.
captureAppLifecycleEvents Type: Boolean Default: true Captures app lifecycle events such as Application Installed, Application Updated, Application Opened, Application Became Active, and Application Backgrounded. Enabled by default since version 4.39.0.
disableGeoip Type: Boolean Default: false When true, disables automatic GeoIP resolution for events and feature flags.
enableSessionReplay Type: Boolean Default: false Enable Recording of Session replay for Android and iOS.
sessionReplayConfig Type: Object Default: null Session replay configuration. See the replay install docs (/docs/session-replay/installation.md) for more details.
enablePersistSessionIdAcrossRestart Type: Boolean Default: false When true, persists the $session_id across app restarts. If false, $session_id always resets on app restart.
evaluationContexts Type: Array of Strings Default: undefined Evaluation context tags that constrain which feature flags are evaluated. When set, only flags with matching evaluation context tags (or no evaluation context tags) will be returned. This helps reduce unnecessary flag evaluations and improves performance. See evaluation contexts documentation (/docs/feature-flags/evaluation-contexts.md) for more details. Available in version 4.21.0+. The legacy parameter evaluationEnvironments (version 4.10.0+) is also supported for backward compatibility.
addTracingHeaders Type: Array of Strings Default: undefined Hostnames for which PostHog should add tracing headers to outgoing fetch requests. Matching requests include X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID, which lets backend events, errors, and LLM traces link back to frontend sessions and replays. Use hostnames only, without the protocol or path.
before_send Type: Function Default: undefined A callback function that is called before each event is sent to PostHog. You can use it to modify, filter, or suppress events. Return null to drop the event, or return the modified event to send it. See customizing exception capture (#customizing-exception-capture-with-before_send) for details.
capturePushNotificationSubscriptions Type: Boolean Default: true Whether to automatically register this device's push token so Workflows (/docs/workflows.md) can target it. Requires @posthog/react-native-plugin. See push notifications (#push-notifications). Available in version 4.62.0+.
capturePushNotificationOpened Type: Boolean Default: true Whether to automatically capture $push_notification_opened when the user taps a push notification. Requires @posthog/react-native-plugin. See push notifications (#push-notifications). Available in version 4.62.0+.
pushIdentityProvider Type: Function Default: undefined Supplies a signed identity-verification token for push subscription requests. Only needed when your push channel requires identity verification. See identity verification (#identity-verification). Available in version 4.62.0+.
Tracing headers

Use addTracingHeaders to connect React Native network requests to backend events, errors, and LLM traces captured by a server-side PostHog SDK:

typescript

const posthog = new PostHog('<ph_project_token>', {
  host: 'https://us.i.posthog.com',
  addTracingHeaders: ['api.example.com'],
})

Hostnames are matched exactly. The SDK patches global fetch and sends X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID on matching requests when those values are available.

Capturing events

You can send custom events using capture:

React Native

posthog.capture('user_signed_up')

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

React Native

posthog.capture('user_signed_up', {
    login_type: "email",
    is_free_trial: true
})
Capturing screen views
With @react-navigation/native and autocapture:

When using @react-navigation/native v6 or lower, screen tracking is automatically captured if the autocapture (/docs/libraries/react-native.md#autocapture) property is used in the PostHogProvider:

It is important that the PostHogProvider is configured as a child of the NavigationContainer:

React Native

// App.(js|ts)

import { PostHogProvider } from 'posthog-react-native'
import { NavigationContainer } from '@react-navigation/native'

export function App() {
    return (
        <NavigationContainer>
            <PostHogProvider apiKey="<ph_project_token>" autocapture>
                {/* Rest of app */}
            </PostHogProvider>
        </NavigationContainer>
    )
}

When using @react-navigation/native v7 or higher, screen tracking has to be manually captured:

React Native

// App.(js|ts)

import { PostHogProvider } from 'posthog-react-native'
import { NavigationContainer } from '@react-navigation/native'

// Using `PostHogProvider` is optional, but needed if you want to capture touch events automatically with the `captureTouches` option.
export function App() {
    return (
        <NavigationContainer>
            <PostHogProvider apiKey="<ph_project_token>" autocapture={{
              captureScreens: false, // Screen events are handled differently for v7 and higher
              captureTouches: true,
            }}>
                {/* Rest of app */}
            </PostHogProvider>
        </NavigationContainer>
    )
}

Check out and set it up the official way for Screen tracking for analytics.

Then call the screen method within the trackScreenView method.

React Native

const posthog = usePostHog() // use the usePostHog hook if using the PostHogProvider or your own custom posthog instance
// you can read the params from `getCurrentRoute()`
posthog.screen(currentRouteName, params)
With react-native-navigation and autocapture:

First, simplify the wrapping of your screens with a shared PostHogProvider:

React Native

import PostHog, { PostHogProvider } from 'posthog-react-native'
import { Navigation } from 'react-native-navigation';

export const posthog = new PostHog('<ph_project_token>');

export const SharedPostHogProvider = (props: any) => {
  return (
    <PostHogProvider client={posthog} autocapture={{
      captureScreens: false, // Screen events are handled differently for react-native-navigation
      captureTouches: true,
    }}>
      {props.children}
    </PostHogProvider>
  );
};

Then, every screen needs to be wrapped with this provider if you want to capture touches or use the usePostHog() hook

React Native

export const MyScreen = () => {
  return (
    <SharedPostHogProvider>
      <View>
        ...
      </View>
    </SharedPostHogProvider>
  );
};

Navigation.registerComponent('Screen', () => MyScreen);

Navigation.events().registerAppLaunchedListener(async () => {
  posthog.initReactNativeNavigation({
    navigation: {
      // (Optional) Set the name based on the route. Defaults to the route name.
      routeToName: (name, properties) => name,
      // (Optional) Tracks all passProps as properties. Defaults to undefined
      routeToProperties: (name, properties) => properties,
    },
    captureScreens: true,
  });
});
With expo-router:

Check out and set it up the official way for Screen tracking for analytics.

Then call the screen method within the useEffect callback.

React Native

const posthog = usePostHog() // use the usePostHog hook if using the PostHogProvider or your own custom posthog instance
posthog.screen(pathname, params)
Manually capturing screen capture events

If you prefer not to use autocapture, you can manually capture screen views by calling posthog.screen(). This function requires a name. You may also pass in an optional properties object.

JavaScript

posthog.screen('dashboard', {
    background: 'blue',
    hero: 'superhog',
})

Autocapture

PostHog autocapture can automatically track the following events for you:

  • Application Opened – when the app is opened from a closed state
  • Application Became Active – when the app comes to the foreground (e.g. from the app switcher)
  • Application Backgrounded – when the app is sent to the background by the user
  • Application Installed – when the app is installed.
  • Application Updated – when the app is updated.
  • $screen – when the user navigates (if using @react-navigation/native (v6 or lower) or react-native-navigation), check out the capturing screen views (/docs/libraries/react-native.md#capturing-screen-views) section
  • $autocapture – touch events when the user interacts with the screen
  • $exception – when the app throws exceptions.

⚠️ React Navigation v7 users

React Navigation v7 restricts navigation hooks (such as useNavigationState) to components rendered inside a Screen that belongs to a Navigator.

Because of this change, automatic screen tracking may throw errors if PostHog is initialized outside a screen context. This commonly affects apps upgrading from React Navigation v6 to v7.

For React Navigation v7, we recommend disabling automatic screen capture for screens and manually calling posthog.screen() inside each screen component. See the Capturing screen views (/docs/libraries/react-native.md#capturing-screen-views) section below.

Application lifecycle events are enabled by default. Screen capture is enabled by default in PostHogProvider unless you set captureScreens: false. Touch capture is disabled by default and requires captureTouches: true.

When touch capture is enabled, touch events for children of PostHogProvider are tracked, capturing a snapshot of the view hierarchy at that point. This enables you to create insights (/docs/product-analytics/insights.md) in PostHog without adding custom events.

PostHog will try to generate a sensible name for touched elements based on the React component displayName or name. If you prefer, you can set your own name using the ph-label prop:

React Native

<View ph-label="my-special-label"></View>
Autocapture configuration

React Native

<PostHogProvider apiKey="<ph_project_token>" autocapture={{
    captureTouches: true, // Disabled by default
    captureScreens: true, // Enabled by default
    ignoreLabels: [], // Any labels here will be ignored from the stack in touch events
    customLabelProp: "ph-label",
    maxElementsCaptured: 20,
    noCaptureProp: "ph-no-capture",
    propsToCapture: ["testID"], // Limit which props are captured. By default, identifiers and text content are captured.

    navigation: {
        // By default, only the screen name is tracked but it is possible to track the
        // params or modify the name by intercepting the autocapture like so
        routeToName: (name, params) => {
            if (params.id) return `${name}/${params.id}`
            return name
        },
        routeToProperties: (name, params) => {
            if (name === "SensitiveScreen") return undefined
            return params
        },
    },
}}>
    ...
</PostHogProvider>
Preventing sensitive data capture

If there are elements you don't want to be captured, you can add the ph-no-capture property. If this property is found anywhere in the view hierarchy, the entire touch event is ignored:

React Native

<View ph-no-capture>Sensitive view here</View>
Capturing screen views

With captureScreens: true (the default in PostHogProvider), PostHog captures a $screen event automatically when the user navigates, provided you're using @react-navigation/native (v6 or lower) or react-native-navigation.

To manually send a screen capture event, use the screen method:

React Native

posthog.screen('Dashboard', { fromIcon: 'bottom' })

React Navigation v7 users: automatic screen tracking may throw errors if PostHog is initialized outside a screen context. For v7, disable automatic screen capture (captureScreens: false) and call posthog.screen() manually inside each screen component.

Filtering autocaptured screens

You can stop specific screens from being autocaptured by filtering them in your before-send hook. Return null for any $screen event whose $screen_name matches a screen you don't want to track, and it's dropped before being sent – keeping unwanted screen views out of your event log.

Because it's just a function, you can filter however you like – an ignorelist (drop the screens you name), an allowlist (invert the check to capture only the screens you name), or any custom rule such as a name prefix, a regex, or a check against the event's properties.

before_send is a client option, so pass it via the provider's options prop (or to new PostHog(...) if you create the client yourself):

React Native

const IGNORED_SCREENS = new Set(['Splash', 'Debug'])

<PostHogProvider
    apiKey="<ph_project_token>"
    autocapture={{ captureScreens: true }}
    options={{
        host: 'https://us.i.posthog.com',
        before_send: (event) => {
            if (event?.event === '$screen') {
                const screenName = event.properties?.['$screen_name']
                return IGNORED_SCREENS.has(screenName) ? null : event
            }
            return event
        },
    }}
>
    {/* app */}
</PostHogProvider>

Swap the check for an allowlist (return TRACKED_SCREENS.has(screenName) ? event : null) if you'd rather capture only a specific set of screens.

Common before_send patterns

before_send accepts a single function or an array of functions that run in order, so you can compose several small hooks. Filtering screens is one use – here are a few others.

Drop a specific event. Stop an internal or debug event from ever being sent:

React Native

const posthog = new PostHog('<ph_project_token>', {
    before_send: (event) => {
        if (event?.event === 'debug_only_event') {
            return null // never send this event
        }
        return event
    },
})

Log events instead of sending them. Handy while debugging what would be captured:

React Native

const posthog = new PostHog('<ph_project_token>', {
    before_send: (event) => {
        console.log('[PostHog] would send', event?.event, event?.properties)
        return null // drop everything
    },
})

Redact sensitive properties. Strip a value before it leaves the device:

React Native

const posthog = new PostHog('<ph_project_token>', {
    before_send: (event) => {
        if (event?.properties?.email) {
            event.properties.email = '***'
        }
        return event
    },
})

For more examples, see the JavaScript Web SDK docs (/docs/libraries/js/usage.md#amending-or-sampling-events).

Identifying users

We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.

Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.

An identify call has the following arguments:

  • distinctId: Required. A unique identifier for your user. Typically either their email or database ID.
  • properties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)

React Native

posthog.identify('distinctID',
  { // ($set):
      email: 'user@posthog.com',
      name: 'My Name'
  }
)

$set_once works just like $set, except that it will only set the property if the user doesn't already have that property set. See the difference between $set and $set_once (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)

React Native

posthog.identify('distinctID',
  {
    $set: {
        email: 'user@posthog.com',
        name: 'My Name'
    },
    $set_once: {
        date_of_first_log_in: '2024-03-01'
    }
  }
)

You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.

When you call identify, all previously tracked anonymous events (/docs/data/anonymous-vs-identified-events.md) will be linked to the user.

Get the current user's distinct ID

You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.

To do this, call posthog.get_distinct_id(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

React Native

// Sets alias for current user
posthog.alias('distinct_id')

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Setting person properties

Person properties enable you to capture, manage, and analyze specific data about a user. You can use them to create filters (/docs/product-analytics/trends.md#filtering-events-based-on-properties) or cohorts (/docs/data/cohorts.md), which can then be used in insights (/docs/product-analytics/insights.md), feature flags (/docs/feature-flags.md), and more.

To set a user's properties, include the $set or $set_once property when capturing any event:

$set

JavaScript

posthog.capture('some_event', { $set: { userProperty: 'value' } })
$set_once

$set_once works just like $set, except it only sets the property if the user doesn't already have that property set.

JavaScript

posthog.capture('some_event', { $set_once: { userProperty: 'value' } })

You can also use setPersonProperties() and unsetPersonProperties() to manage person properties directly. See person properties (/docs/product-analytics/person-properties.md) for examples.

Super properties

Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, an autocaptured touch, or anything else.

They are set using posthog.register, which takes a properties object as a parameter, and they persist across sessions.

For example:

JavaScript

posthog.register({
    'icecream pref': 'vanilla',
    team_id: 22,
})

The call above ensures that every event sent by the user will include "icecream pref": "vanilla" and "team_id": 22. This way, if you filtered events by property using icecream_pref = vanilla, it would display all events captured on that user after the posthog.register call, since they all include the specified Super Property.

This does not set the user's properties. This only sets the properties for their events. To store person properties, see the setting person properties section (#setting-user-properties).

Removing stored super properties

Super Properties are persisted across sessions so you have to explicitly remove them if they are no longer relevant. In order to stop sending a Super Property with events, you can use posthog.unregister, like so:

JavaScript

posthog.unregister('icecream pref'),

This will remove the super property and subsequent events will not include it.

If you are doing this as part of a user logging out you can instead simply posthog.reset() (#reset-after-logout) which takes care of clearing all stored Super Properties and more.

Opt out of data capture

You can completely opt users out from data capture by default or on a per-person basis. See Opt in/out (#opt-inout) for the current React Native API.

Flush

You can configure how many events queue before flushing with flushAt. Setting this to 1 will send events immediately and will use more battery. The default is 20.

You can also configure the flush interval with flushInterval, in milliseconds (default 10000), after which queued events are sent regardless of how many have been gathered:

JavaScript

const posthog = new PostHog('<ph_project_token>', {
  flushAt: 20,
  flushInterval: 10000,
})

You can also manually flush the queue to start sending events immediately instead of waiting for the next batch:

JavaScript

await posthog.flush()

If a flush is already in progress, it returns a promise for the existing flush.

Flushing is best-effort and asynchronous – it starts sending queued events in the background but doesn't wait for the request to finish, so it isn't a delivery guarantee.

Reset after logout

To reset the user's ID and anonymous ID, call reset. Usually you would do this right after the user logs out.

JavaScript

posthog.reset()

Offline behavior

The PostHog React Native SDK will continue to capture events when the device is offline. When persistence is set to file (by default), the events are stored in a queue in the device's file storage. Even when the app is closed, the events are persisted and will be flushed when the app is opened again.

  • The queue has a maximum size defined by maxQueueSize in the configuration.
  • When the queue is full, the oldest event is deleted first.
  • The queue is flushed only when the device is online.

Opt in/out

By default, PostHog has tracking enabled unless it is forcefully disabled by default using the option { defaultOptIn: false }.

You can give your users the option to opt in or out by calling the relevant methods. Once these have been called they are persisted and will be respected until optIn/Out is called again or the reset function is called.

To opt in/out of tracking, use the following calls.

JavaScript

posthog.optedOut // See if a user has opted out
posthog.optIn() // opt in
posthog.optOut() // opt out

If you still wish capture these events but want to create a distinction between users and team in PostHog, you should look into Cohorts (/docs/user-guides/cohorts.md#differentiating-team-vs-users-traffic).

Feature Flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two ways to implement feature flags in React Native:

  1. Using hooks.
  2. Loading the flag directly.
Method 1: Using hooks
Example 1: Boolean feature flags

React Native

import { useFeatureFlag } from 'posthog-react-native'

const MyComponent = () => {
    const booleanFlag = useFeatureFlag('key-for-your-boolean-flag')

    if (booleanFlag === undefined) {
        // the response is undefined if the flags are being loaded
        return null
    }

    // Optional use the 'useFeatureFlagWithPayload' hook for fetching the feature flag payload

    return booleanFlag ? <Text>Testing feature 😄</Text> : <Text>Not Testing feature 😢</Text>
}
Example 2: Multivariate feature flags

React Native

import { useFeatureFlag } from 'posthog-react-native'

const MyComponent = () => {
    const multiVariantFeature = useFeatureFlag('key-for-your-multivariate-flag')

    if (multiVariantFeature === undefined) {
        // the response is undefined if the flags are being loaded
        return null
    } else if (multiVariantFeature === 'variant-name') { // replace 'variant-name' with the name of your variant
      // Do something
    }

    // Optional use the 'useFeatureFlagWithPayload' hook for fetching the feature flag payload

    return <div/>
}
Method 2: Loading the flag directly

React Native

// Defaults to undefined if not loaded yet or if there was a problem loading
posthog.isFeatureEnabled('key-for-your-boolean-flag')

// Defaults to undefined if not loaded yet or if there was a problem loading
posthog.getFeatureFlag('key-for-your-boolean-flag')

// Multivariant feature flags are returned as a string
posthog.getFeatureFlag('key-for-your-multivariate-flag')

// Optional: fetch the payload (returns 'JsonType' or undefined if not loaded yet or if there was a problem loading)
posthog.getFeatureFlagResult('key-for-your-multivariate-flag')?.payload
Inspecting all feature flags

You can inspect all currently loaded feature flags with getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:

React Native

for (const flag of posthog.getAllFeatureFlags()) {
    console.log(flag.key, flag.enabled, flag.variant, flag.payload)
}
Ensuring flags are loaded before usage

Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.

This means that for most screens, the feature flags are available immediately — except for the first time a user visits.

To handle this, you can use the onFeatureFlags callback to wait for the feature flag request to finish:

React Native

posthog.onFeatureFlags((flags) => {
  // feature flags are guaranteed to be available at this point
  if (posthog.isFeatureEnabled('flag-key')) {
    // do something
  }
})
Reloading flags

PostHog loads feature flags when instantiated and refreshes whenever methods are called that affect the flag.

If want to manually trigger a refresh, you can call reloadFeatureFlagsAsync():

React Native

posthog.reloadFeatureFlagsAsync().then((refreshedFlags) => console.log(refreshedFlags))

Or when you want to trigger the reload, but don't care about the result:

React Native

posthog.reloadFeatureFlags()
Feature flag caching

The React Native SDK caches feature flag values in AsyncStorage. Cached values persist indefinitely with no TTL until updated by a successful API call. This enables offline support and reduces latency, but means inactive users may see stale flag values from their last session.

For example, if a user last opened your app when a flag was false, that value remains cached even after you roll it out to 100%. When they reopen the app, the SDK returns the cached false first, then fetches the fresh true value from the API.

To ensure fresh flag values:

React Native

// Force refresh on app start
await posthog.reloadFeatureFlagsAsync()

Or clear cached values for inactive users:

React Native

if (lastActiveDate < migrationDate) {
  posthog.reset() // Clears all cached data
}
Request timeout

You can configure the featureFlagsRequestTimeoutMs parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked in the case when PostHog's servers are too slow to respond. By default, this is set at 10 seconds.

React Native

export const posthog = new PostHog('<ph_project_token>', {
  // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
  host: 'https://us.i.posthog.com',
  featureFlagsRequestTimeoutMs: 10000 // Time in milliseconds. Default is 10000 (10 seconds).
})
Error handling

When using the PostHog SDK, it's important to handle potential errors that may occur during feature flag operations. Here's an example of how to wrap PostHog SDK methods in an error handler:

React Native

function handleFeatureFlag(client, flagKey, distinctId) {
    try {
        const isEnabled = client.isFeatureEnabled(flagKey, distinctId);
        console.log(`Feature flag '${flagKey}' for user '${distinctId}' is ${isEnabled ? 'enabled' : 'disabled'}`);
        return isEnabled;
    } catch (error) {
        console.error(`Error fetching feature flag '${flagKey}': ${error.message}`);
        // Optionally, you can return a default value or throw the error
        // return false; // Default to disabled
        throw error;
    }
}

// Usage example
try {
    const flagEnabled = handleFeatureFlag(client, 'new-feature', 'user-123');
    if (flagEnabled) {
        // Implement new feature logic
    } else {
        // Implement old feature logic
    }
} catch (error) {
    // Handle the error at a higher level
    console.error('Feature flag check failed, using default behavior');
    // Implement fallback logic
}
Overriding server properties

Sometimes, you might want to evaluate feature flags using properties that haven't been ingested yet, or were set incorrectly earlier. You can do so by setting properties the flag depends on with these calls:

React Native

posthog.setPersonPropertiesForFlags({'property1': 'value', property2: 'value2'})

Note that these are set for the entire session. Successive calls are additive: all properties you set are combined together and sent for flag evaluation.

Whenever you set these properties, we also trigger a reload of feature flags to ensure we have the latest values. You can disable this by passing in the optional parameter for reloading:

React Native

posthog.setPersonPropertiesForFlags({'property1': 'value', property2: 'value2'}, false)

At any point, you can reset these properties by calling resetPersonPropertiesForFlags:

React Native

posthog.resetPersonPropertiesForFlags()

The same holds for group (/docs/product-analytics/group-analytics.md) properties:

React Native

// set properties for a group
posthog.setGroupPropertiesForFlags({'company': {'property1': 'value', property2: 'value2'}})

// reset properties for all groups:
posthog.resetGroupPropertiesForFlags()

Note: You don't need to add the group names here, since these properties are automatically attached to the current group (set via posthog.group()). When you change the group, these properties are reset.

Automatic overrides

Whenever you call posthog.identify with person properties, we automatically add these properties to flag evaluation calls to help determine the correct flag values. The same is true for when you call posthog.group().

Default overridden properties

By default, we always override some properties based on the user IP address.

The list of properties that this overrides:

  1. $geoip_city_name
  2. $geoip_country_name
  3. $geoip_country_code
  4. $geoip_continent_name
  5. $geoip_continent_code
  6. $geoip_postal_code
  7. $geoip_time_zone

This enables any geolocation-based flags to work without manually setting these properties.

Bootstrapping flags

Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.

To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.

Pass bootstrap in the initialization options to seed identity and flag values:

React Native

<PostHogProvider
    apiKey="<ph_project_token>"
    options={{
        host: 'https://us.i.posthog.com',
        bootstrap: {
            distinctId: 'distinct_id_of_your_user',
            isIdentifiedId: true,
            featureFlags: {
                'flag-1': true,
                'variant-flag': 'control',
            },
        },
    }}
>
    <MyComponent />
</PostHogProvider>

See bootstrapping Feature Flags (/docs/feature-flags/bootstrapping.md) for server-side evaluation and flag lifecycle, and SDK bootstrapping (/docs/libraries/bootstrapping.md) for cross-SDK identity behavior.

Supplying flags from your own backend

If you evaluate feature flags outside the SDK – for example on your own server with posthog-node local evaluation (/docs/feature-flags/local-evaluation.md), then pass the results into your app – you can have the SDK use those values and never fetch flags itself.

Set disableRemoteFeatureFlags: true so the SDK never requests /flags (including the refetches that identify(), group(), and reset() normally trigger), then push your evaluated flags at runtime with updateFlags(flags, payloads?, { merge }):

React Native

const posthog = new PostHog('<ph_project_token>', {
  host: 'https://us.i.posthog.com',
  // Don't fetch or evaluate flags on-device – we supply them ourselves.
  disableRemoteFeatureFlags: true,
  // Optional: values that must be available at startup, before updateFlags() runs.
  // Without this, reads return their not-loaded defaults until you push flags.
  bootstrap: {
    featureFlags: { 'my-flag': true },
    featureFlagPayloads: { 'my-flag': { color: 'blue' } },
  },
})

// Later – e.g. after login, once your backend has evaluated flags for this user:
posthog.updateFlags(
  { 'my-flag': true, 'my-variant-flag': 'test' },
  { 'my-flag': { color: 'blue' } }
)

posthog.getFeatureFlag('my-variant-flag') // 'test'
posthog.getFeatureFlagResult('my-flag')?.payload // { color: 'blue' }

updateFlags replaces the stored flags by default; pass { merge: true } to merge into the existing set instead. Values persist across app restarts, and getFeatureFlag() / getFeatureFlagResult() read them back like any other flag.

Note that reset() (called on logout) clears the supplied flags, so re-push them with updateFlags() after the next identity change. Use bootstrap for any flag values that must be available at startup before updateFlags() runs.

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See adding experiment code (/docs/experiments/adding-experiment-code.md) for React Native examples.

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Group analytics

Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for implementation details.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

Error tracking

To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).

Native crash autocapture

The JavaScript-level autocapture only covers exceptions thrown in your JS/TS code. To also capture native iOS and Android crashes – for example, a crash inside a native module or the platform runtime – install the optional @posthog/react-native-plugin package and enable errorTracking.autocapture.nativeCrashes. Native capture is gated by your project's Enable exception autocapture setting, and crash reports need native debug symbols uploaded at build time to produce readable stack traces.

Follow the React Native installation guide (/docs/error-tracking/installation/react-native.md) for the full setup, and native crash symbolication (/docs/error-tracking/upload-source-maps/react-native.md#native-crash-symbolication) to upload symbols.

Error boundaries

You can use the PostHogErrorBoundary component to capture React rendering errors thrown by components:

React Native

import { PostHogProvider, PostHogErrorBoundary } from 'posthog-react-native'
import { View, Text } from 'react-native'

const App = () => {
  return (
    <PostHogProvider apiKey="<ph_project_token>">
      <PostHogErrorBoundary
        fallback={YourFallbackComponent}
        additionalProperties={{ screen: "home" }}
      >
        <YourApp />
      </PostHogErrorBoundary>
    </PostHogProvider>
  )
}

const YourFallbackComponent = ({ error, componentStack }) => {
  return (
    <View>
      <Text>Something went wrong!</Text>
      <Text>{error instanceof Error ? error.message : String(error)}</Text>
    </View>
  )
}

The fallback prop accepts a component to render when an error occurs. The additionalProperties prop lets you add custom properties to the captured error event.

Duplicate errors with console capture

If you have both PostHogErrorBoundary and console capture enabled in your errorTracking config, render errors will be captured twice. This is because React logs all errors to the console by default. To avoid this, set console: [] on errorTracking.autocapture (for example, errorTracking: { autocapture: { console: [] } }) when using PostHogErrorBoundary.

Customizing exception capture with before_send

You can use the before_send callback to modify, filter, or suppress exception events before they are sent to PostHog. This is useful for:

  • Adding custom properties to exceptions
  • Overriding exception fingerprints for custom grouping
  • Suppressing specific types of exceptions
  • Redacting sensitive information

React Native

const posthog = new PostHog('<ph_project_token>', {
  host: 'https://us.i.posthog.com',
  before_send: (event) => {
    if (event.event === '$exception') {
      const exceptionList = event.properties?.['$exception_list'] || []
      const exception = exceptionList.length > 0 ? exceptionList[0] : null

      if (exception) {
        // Add custom properties
        event.properties['custom_property'] = 'custom_value'

        // Override fingerprint for custom grouping
        event.properties['$exception_fingerprint'] = 'MyCustomGroup'
      }

      // Suppress specific exception types
      if (exception?.['$exception_type'] === 'IgnoredError') {
        return null // Drop the event
      }
    }
    return event
  },
})

You can also use before_send to sample or filter other event types. See the JavaScript Web SDK documentation (/docs/libraries/js/usage.md#amending-or-sampling-events) for more examples.

Logs

To set up logs (/docs/logs.md) in your React Native app, follow the React Native logs installation guide (/docs/logs/installation/react-native.md). The SDK exposes posthog.captureLog, posthog.logger.{trace,debug,info,warn,error,fatal}, and posthog.flushLogs for sending structured records to PostHog Logs.

Minimum version: posthog-react-native@4.44.0 or later.

Session replay

To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the React Native SDK and the Session replay plugin, then follow the instructions to enable Session Replay (/docs/session-replay/installation/react-native.md) for React Native.

Surveys

To set up surveys, follow the additional installation instructions for React Native (/docs/surveys/installation/react-native.md). Surveys launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.

Note: URL and CSS selector targeting are not supported in React Native. Surveys that rely on these conditions will not appear.

Push notifications

The React Native SDK can register a device for Workflows (/docs/workflows.md) push notifications and capture when a user opens one. For setup, including automatic and manual registration, capturing opens, opting out, and identity verification, see Push notifications (/docs/workflows/push-notifications.md).

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.

You can enable debug mode by setting the debug option to true in the PostHogProvider options. This will enable verbose logs about the inner workings of the SDK.

React Native

<PostHogProvider
    debug={true}
    apiKey="<ph_project_token>"
    options={{
        host: "https://us.i.posthog.com",
    }}
>

You can also call the debug() method in your code.

React Native

posthog.debug()

Disabling for local development

You may want to disable PostHog when working locally or in a test environment. You can do this by setting the disable option to true when initializing PostHog. Helpfully this allows you to continue using usePostHog and safely calling it without anything actually happening.

React Native

// App.(js|ts)
import { usePostHog, PostHogProvider } from 'posthog-react-native'
...

export function MyApp() {
    return (
        <PostHogProvider apiKey="<ph_project_token>" options={{
            // Disable PostHog in development (or whatever other logic you choose)
            disabled: __DEV__,
        }}>
            <MyComponent />
        </PostHogProvider>
    )
}

const MyComponent = () => {
    const posthog = usePostHog()

    useEffect(() => {
        // Safe to call even when disabled!
        posthog.capture("mycomponent_loaded", { foo: "bar" })
    }, [])
}

Upgrading from V1, V2 to V3 or V3 to V4

V1 of this library utilised the underlying posthog-ios and posthog-android SDKs to do most of the work. Since the new version is written entirely in JS, using only Expo supported libraries, there are some changes to the way PostHog is configured as well as actually calling PostHog.

For iOS, the new React Native SDK will attempt to migrate the previously persisted data (such as distinctId and anonymousId) which should result in no unexpected changes to tracked data.

For Android, it is unfortunately not possible for persisted Android data to be loaded which means stored information such as the randomly generated anonymousId or the distinctId set by posthog.identify will not be present. For identified users, the simple workaround is to ensure that identify is called at least once when the app loads. For anonymous users there is unfortunately no straightforward workaround they will show up as new anonymous users in PostHog.

Events such as Application Installed and Application Updated that require previously persisted data were unable to be migrated, the side effect being that you may see much higher numbers for Application Installed events. This is due to the fact that there is no native way of detecting a real "install" and as such, we store a marker the first time the SDK loads and treat that as an install.

JSX

// DEPRECATED V1 Setup

import PostHog from 'posthog-react-native'

await PostHog.setup('<ph_project_token>', {
    // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
    host: 'https://us.i.posthog.com',
    captureApplicationLifecycleEvents: false, // Replaced by 'PostHogProvider'
    captureDeepLinks: false, // No longer supported
    recordScreenViews: false, // Replaced by 'PostHogProvider' supporting @react-navigation/native
    flushInterval: 30, // Stays the same
    flushAt: 20, // Stays the same
    android: {...}, // No longer needed
    iOS: {...}, // No longer needed
})

PostHog.capture("foo")


// V2 Setup difference
import PostHog from 'posthog-react-native'

const posthog = await Posthog.initAsync('<ph_project_token>', {
    // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
    host: 'https://us.i.posthog.com',
    // Add any other options here.
})

// Use created instance rather than the PostHog class
posthog.capture("foo")

// V3 Setup difference
import PostHog from 'posthog-react-native'

const posthog = new PostHog('<ph_project_token>', {
    // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
    host: 'https://us.i.posthog.com',
    // Add any other options here.
})

// Use created instance rather than the PostHog class
posthog.capture("foo")

// V4 Setup difference
import PostHog from 'posthog-react-native'

const posthog = new PostHog('<ph_project_token>', {
    // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
    host: 'https://us.i.posthog.com',
    // captureAppLifecycleEvents is enabled by default since version 4.39.0 (previously named `captureNativeAppLifecycleEvents` or `autocapture={{ captureLifecycleEvents: true }}`)
    // captureMode: 'json', // No longer supported
    // maskPhotoLibraryImages: true, // No longer supported
})

posthog.setPersonPropertiesForFlags(...) // instead of `personProperties`
posthog.setGroupPropertiesForFlags(...) // instead of `groupProperties`
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/react-router-v6.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

React Router V6

This guide walks you through setting up PostHog for React Router V6. If you're using React Router v7, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).

  1. 1

    Install client-side SDKs

    Required

    First, you'll need to install posthog-js and @posthog/react using your package manager. These packages allow you to capture client-side events.

    npm
    npm install --save posthog-js @posthog/react
    Yarn
    yarn add posthog-js @posthog/react
    pnpm
    pnpm add posthog-js @posthog/react
    Bun
    bun add posthog-js @posthog/react

    If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

    script-src 'self' https://*.posthog.com;
    connect-src 'self' https://*.posthog.com;
    worker-src 'self' blob: data:;

    script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

  2. 2

    Add your environment variables

    Required

    Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names with VITE_ ensures they are accessible in the frontend.

    .env.local

    VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token>
    VITE_POSTHOG_HOST=https://us.i.posthog.com
  3. 3

    Add the PostHogProvider to your app

    Required

    In declarative mode, you'll need to wrap your BrowserRouter with the PostHogProvider context. This passes an initialized PostHog client to your app.

    src/main.tsx

    import { StrictMode } from "react";
    import ReactDOM from "react-dom/client";
    import { BrowserRouter, Routes, Route } from "react-router";
    
    import posthog from 'posthog-js';
    import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'
    
    // Initialize PostHog
    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
    });
    
    const root = document.getElementById("root");
    
    ReactDOM.createRoot(root).render(
      <StrictMode>
        {/* Pass PostHog client through PostHogProvider */}
        <PostHogProvider client={posthog}>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Root />}>
            {/* ... Your routes ... */}
            </Route>
          </Routes>
        </BrowserRouter>
        </PostHogProvider>
      </StrictMode>,
    );

    This initializes PostHog and passes it to your app through the PostHogProvider context.

    TypeError: Cannot read properties of undefined

    If you see the error TypeError: Cannot read properties of undefined (reading '...') this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.

    To fix this error, add a check that posthog has been initialized such as:

    React

    useEffect(() => {
      posthog?.capture('test') // using optional chaining (recommended)
    
      if (posthog) {
        posthog.capture('test') // using an if statement
      }
    }, [posthog])

    Typescript helps protect against these errors.

  4. Verify client-side events are captured

    Checkpoint

    Confirm that you can capture client-side events and see them in your PostHog project

    At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).

    You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the usePostHog hook.

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    You should see these events in a minute or two in the activity tab.

  5. 4

    Access PostHog methods

    Required

    On the client-side, you can access the PostHog client using the usePostHog hook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).

  6. 5

    Identify your user

    Recommended

    Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.

    Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the identify method on the PostHog client:

    TSX

    export default function Login() {
      const { user, login } = useAuth();
      const posthog = usePostHog();
    
      const handleLogin = async (e: React.FormEvent) => {
        // existing code to handle login...
        const user = await login({ email, password });
    
        posthog?.identify(user.email,
          {
            email: user.email,
            name: user.name,
          }
        );
        posthog?.capture('user_logged_in');
      };
    
      return (
        <div>
          {/* ... existing code ... */}
          <button onClick={handleLogin}>Login</button>
        </div>
      );
    }

    PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.

  7. 6

    Create an error boundary

    Recommended

    PostHog can capture exceptions thrown in your app through an error boundary. PostHog provides a PostHogErrorBoundary component that you can use to capture exceptions. You can wrap your app with this component to capture exceptions.

    TSX

    ReactDOM.createRoot(root).render(
      <StrictMode>
        <PostHogProvider client={posthog}>
        <PostHogErrorBoundary>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Root />}>
              {/* ... Your routes ... */}
            </Route>
          </Routes>
        </BrowserRouter>
        </PostHogErrorBoundary>
        </PostHogProvider>
      </StrictMode>,
    );

    This automatically captures exceptions thrown in your React Router app using the posthog.captureException() method.

  8. 7

    Tracking element visibility

    Recommended

    The PostHogCaptureOnViewed component enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.

    The component wraps your content and sends a $element_viewed event to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.

    Basic usage:

    React

    import { PostHogCaptureOnViewed } from '@posthog/react'
    
    function App() {
        return (
            <PostHogCaptureOnViewed name="hero-banner">
                <div>Your important content here</div>
            </PostHogCaptureOnViewed>
        )
    }

    With custom properties:

    You can include additional properties with the event to provide more context:

    React

    <PostHogCaptureOnViewed
        name="product-card"
        properties={{
            product_id: '123',
            category: 'electronics',
            price: 299.99
        }}
    >
        <ProductCard />
    </PostHogCaptureOnViewed>

    Tracking multiple children:

    Use trackAllChildren to track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:

    React

    <PostHogCaptureOnViewed
        name="product-gallery"
        properties={{ gallery_type: 'featured' }}
        trackAllChildren
    >
        <ProductCard id="1" />
        <ProductCard id="2" />
        <ProductCard id="3" />
    </PostHogCaptureOnViewed>

    When trackAllChildren is enabled, each child element sends its own event with a child_index property indicating its position.

    Custom intersection observer options:

    You can customize when elements are considered "viewed" by passing options to the IntersectionObserver:

    React

    <PostHogCaptureOnViewed
        name="footer"
        observerOptions={{
            threshold: 0.5,  // Element is 50% visible
            rootMargin: '0px'
        }}
    >
        <Footer />
    </PostHogCaptureOnViewed>

    The component passes all other props to the wrapper div, so you can add styling, classes, or other HTML attributes as needed.

  9. 8

    Set up server-side analytics

    Recommended

    Now that you've set up PostHog for React Router V7 in declarative mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).

    To help PostHog track your user sessions across the client and server, you'll need to add the tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...] option to your PostHog initialization:

    TSX

    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
      tracing_headers: [ window.location.hostname, 'localhost' ],
    });

    This adds the X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID headers to requests sent to the configured hostnames, which you can later use on the server-side.

  10. 9

    Next steps

    Recommended

    Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.

    To get the most out of PostHog, you should familiarize yourself with the following:

    • PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
    • PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
    • Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
    • Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
    • PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
    • Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/react-router-v7-data-mode.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

React Router V7 data mode

This guide walks you through setting up PostHog for React Router V7 in data mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).

  1. 1

    Install client-side SDKs

    Required

    First, you'll need to install posthog-js and @posthog/react using your package manager. These packages allow you to capture client-side events.

    npm
    npm install --save posthog-js @posthog/react
    Yarn
    yarn add posthog-js @posthog/react
    pnpm
    pnpm add posthog-js @posthog/react
    Bun
    bun add posthog-js @posthog/react

    If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

    script-src 'self' https://*.posthog.com;
    connect-src 'self' https://*.posthog.com;
    worker-src 'self' blob: data:;

    script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

  2. 2

    Add your environment variables

    Required

    Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names with VITE_ ensures they are accessible in the frontend.

    .env.local

    VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token>
    VITE_POSTHOG_HOST=https://us.i.posthog.com
  3. 3

    Add the PostHogProvider to your app

    Required

    In data mode, you'll need to wrap your RouterProvider with the PostHogProvider context. This passes an initialized PostHog client to your app.

    app/index.tsx

    import { StrictMode } from "react";
    import { createRoot } from "react-dom/client";
    import { createBrowserRouter, RouterProvider } from "react-router";
    import Root, { RootErrorBoundary } from "./app/root";
    
    import posthog from 'posthog-js';
    import { PostHogProvider } from '@posthog/react'
    
    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
    });
    
    const router = createBrowserRouter([...]);
    
    createRoot(document.getElementById("root")!).render(
      <StrictMode>
        {/* Pass PostHog client through PostHogProvider */}
        <PostHogProvider client={posthog}>
          <RouterProvider router={router} />
        </PostHogProvider>,
      );
    });

    This initializes PostHog and passes it to your app through the PostHogProvider context.

    TypeError: Cannot read properties of undefined

    If you see the error TypeError: Cannot read properties of undefined (reading '...') this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.

    To fix this error, add a check that posthog has been initialized such as:

    React

    useEffect(() => {
      posthog?.capture('test') // using optional chaining (recommended)
    
      if (posthog) {
        posthog.capture('test') // using an if statement
      }
    }, [posthog])

    Typescript helps protect against these errors.

  4. Verify client-side events are captured

    Checkpoint

    Confirm that you can capture client-side events and see them in your PostHog project

    At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).

    You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the usePostHog hook.

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    You should see these events in a minute or two in the activity tab.

  5. 4

    Access PostHog methods

    Required

    On the client-side, you can access the PostHog client using the usePostHog hook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).

  6. 5

    Identify your user

    Recommended

    Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.

    Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the identify method on the PostHog client:

    TSX

    export default function Login() {
      const { user, login } = useAuth();
      const posthog = usePostHog();
    
      const handleLogin = async (e: React.FormEvent) => {
        // existing code to handle login...
        const user = await login({ email, password });
    
        posthog?.identify(user.email,
          {
            email: user.email,
            name: user.name,
          }
        );
        posthog?.capture('user_logged_in');
      };
    
      return (
        <div>
          {/* ... existing code ... */}
          <button onClick={handleLogin}>Login</button>
        </div>
      );
    }

    PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.

  7. 6

    Create an error boundary

    Recommended

    PostHog can capture exceptions thrown in your app through an error boundary. React Router in data mode has a built-in error boundary that you can use to capture exceptions. You can create an error boundary by exporting RootErrorBoundary from your app/root.tsx file.

    app/root.tsx

    import { usePostHog } from '@posthog/react'
    
    export function RootErrorBoundary() {
      const error = useRouteError();
    
      const posthog = usePostHog();
      if (error) {
        posthog.captureException(error);
      }
    
      // other error handling code...
    }

    This automatically captures exceptions thrown in your React Router app using the posthog.captureException() method.

  8. 7

    Tracking element visibility

    Recommended

    The PostHogCaptureOnViewed component enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.

    The component wraps your content and sends a $element_viewed event to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.

    Basic usage:

    React

    import { PostHogCaptureOnViewed } from '@posthog/react'
    
    function App() {
        return (
            <PostHogCaptureOnViewed name="hero-banner">
                <div>Your important content here</div>
            </PostHogCaptureOnViewed>
        )
    }

    With custom properties:

    You can include additional properties with the event to provide more context:

    React

    <PostHogCaptureOnViewed
        name="product-card"
        properties={{
            product_id: '123',
            category: 'electronics',
            price: 299.99
        }}
    >
        <ProductCard />
    </PostHogCaptureOnViewed>

    Tracking multiple children:

    Use trackAllChildren to track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:

    React

    <PostHogCaptureOnViewed
        name="product-gallery"
        properties={{ gallery_type: 'featured' }}
        trackAllChildren
    >
        <ProductCard id="1" />
        <ProductCard id="2" />
        <ProductCard id="3" />
    </PostHogCaptureOnViewed>

    When trackAllChildren is enabled, each child element sends its own event with a child_index property indicating its position.

    Custom intersection observer options:

    You can customize when elements are considered "viewed" by passing options to the IntersectionObserver:

    React

    <PostHogCaptureOnViewed
        name="footer"
        observerOptions={{
            threshold: 0.5,  // Element is 50% visible
            rootMargin: '0px'
        }}
    >
        <Footer />
    </PostHogCaptureOnViewed>

    The component passes all other props to the wrapper div, so you can add styling, classes, or other HTML attributes as needed.

  9. 8

    Set up server-side analytics

    Recommended

    Now that you've set up PostHog for React Router V7 in data mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).

    To help PostHog track your user sessions across the client and server, you'll need to add the tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...] option to your PostHog initialization:

    TSX

    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
      tracing_headers: [ window.location.hostname, 'localhost' ],
    });

    This adds the X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID headers to requests sent to the configured hostnames, which you can later use on the server-side.

  10. 9

    Next steps

    Recommended

    Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.

    To get the most out of PostHog, you should familiarize yourself with the following:

    • PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
    • PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
    • Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
    • Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
    • PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
    • Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/react-router-v7-declarative-mode.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

React Router V7 declarative mode

This guide walks you through setting up PostHog for React Router V7 in declarative mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).

  1. 1

    Install client-side SDKs

    Required

    First, you'll need to install posthog-js and @posthog/react using your package manager. These packages allow you to capture client-side events.

    npm
    npm install --save posthog-js @posthog/react
    Yarn
    yarn add posthog-js @posthog/react
    pnpm
    pnpm add posthog-js @posthog/react
    Bun
    bun add posthog-js @posthog/react

    If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

    script-src 'self' https://*.posthog.com;
    connect-src 'self' https://*.posthog.com;
    worker-src 'self' blob: data:;

    script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

  2. 2

    Add your environment variables

    Required

    Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names with VITE_ ensures they are accessible in the frontend.

    .env.local

    VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token>
    VITE_POSTHOG_HOST=https://us.i.posthog.com
  3. 3

    Add the PostHogProvider to your app

    Required

    In declarative mode, you'll need to wrap your BrowserRouter with the PostHogProvider context. This passes an initialized PostHog client to your app.

    src/main.tsx

    import { StrictMode } from "react";
    import ReactDOM from "react-dom/client";
    import { BrowserRouter, Routes, Route } from "react-router";
    
    import posthog from 'posthog-js';
    import { PostHogErrorBoundary, PostHogProvider } from '@posthog/react'
    
    // Initialize PostHog
    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
    });
    
    const root = document.getElementById("root");
    
    ReactDOM.createRoot(root).render(
      <StrictMode>
        {/* Pass PostHog client through PostHogProvider */}
        <PostHogProvider client={posthog}>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Root />}>
            {/* ... Your routes ... */}
            </Route>
          </Routes>
        </BrowserRouter>
        </PostHogProvider>
      </StrictMode>,
    );

    This initializes PostHog and passes it to your app through the PostHogProvider context.

    TypeError: Cannot read properties of undefined

    If you see the error TypeError: Cannot read properties of undefined (reading '...') this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.

    To fix this error, add a check that posthog has been initialized such as:

    React

    useEffect(() => {
      posthog?.capture('test') // using optional chaining (recommended)
    
      if (posthog) {
        posthog.capture('test') // using an if statement
      }
    }, [posthog])

    Typescript helps protect against these errors.

  4. Verify client-side events are captured

    Checkpoint

    Confirm that you can capture client-side events and see them in your PostHog project

    At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).

    You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the usePostHog hook.

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    You should see these events in a minute or two in the activity tab.

  5. 4

    Access PostHog methods

    Required

    On the client-side, you can access the PostHog client using the usePostHog hook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).

  6. 5

    Identify your user

    Recommended

    Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.

    Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the identify method on the PostHog client:

    TSX

    export default function Login() {
      const { user, login } = useAuth();
      const posthog = usePostHog();
    
      const handleLogin = async (e: React.FormEvent) => {
        // existing code to handle login...
        const user = await login({ email, password });
    
        posthog?.identify(user.email,
          {
            email: user.email,
            name: user.name,
          }
        );
        posthog?.capture('user_logged_in');
      };
    
      return (
        <div>
          {/* ... existing code ... */}
          <button onClick={handleLogin}>Login</button>
        </div>
      );
    }

    PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.

  7. 6

    Create an error boundary

    Recommended

    PostHog can capture exceptions thrown in your app through an error boundary. PostHog provides a PostHogErrorBoundary component that you can use to capture exceptions. You can wrap your app with this component to capture exceptions.

    TSX

    ReactDOM.createRoot(root).render(
      <StrictMode>
        <PostHogProvider client={posthog}>
        <PostHogErrorBoundary>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Root />}>
              {/* ... Your routes ... */}
            </Route>
          </Routes>
        </BrowserRouter>
        </PostHogErrorBoundary>
        </PostHogProvider>
      </StrictMode>,
    );

    This automatically captures exceptions thrown in your React Router app using the posthog.captureException() method.

  8. 7

    Tracking element visibility

    Recommended

    The PostHogCaptureOnViewed component enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.

    The component wraps your content and sends a $element_viewed event to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.

    Basic usage:

    React

    import { PostHogCaptureOnViewed } from '@posthog/react'
    
    function App() {
        return (
            <PostHogCaptureOnViewed name="hero-banner">
                <div>Your important content here</div>
            </PostHogCaptureOnViewed>
        )
    }

    With custom properties:

    You can include additional properties with the event to provide more context:

    React

    <PostHogCaptureOnViewed
        name="product-card"
        properties={{
            product_id: '123',
            category: 'electronics',
            price: 299.99
        }}
    >
        <ProductCard />
    </PostHogCaptureOnViewed>

    Tracking multiple children:

    Use trackAllChildren to track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:

    React

    <PostHogCaptureOnViewed
        name="product-gallery"
        properties={{ gallery_type: 'featured' }}
        trackAllChildren
    >
        <ProductCard id="1" />
        <ProductCard id="2" />
        <ProductCard id="3" />
    </PostHogCaptureOnViewed>

    When trackAllChildren is enabled, each child element sends its own event with a child_index property indicating its position.

    Custom intersection observer options:

    You can customize when elements are considered "viewed" by passing options to the IntersectionObserver:

    React

    <PostHogCaptureOnViewed
        name="footer"
        observerOptions={{
            threshold: 0.5,  // Element is 50% visible
            rootMargin: '0px'
        }}
    >
        <Footer />
    </PostHogCaptureOnViewed>

    The component passes all other props to the wrapper div, so you can add styling, classes, or other HTML attributes as needed.

  9. 8

    Set up server-side analytics

    Recommended

    Now that you've set up PostHog for React Router V7 in declarative mode, you can continue to set up server-side analytics. You can find our other SDKs in the SDKs page (/docs/libraries.md).

    To help PostHog track your user sessions across the client and server, you'll need to add the tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...] option to your PostHog initialization:

    TSX

    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
      tracing_headers: [ window.location.hostname, 'localhost' ],
    });

    This adds the X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID headers to requests sent to the configured hostnames, which you can later use on the server-side.

  10. 9

    Next steps

    Recommended

    Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.

    To get the most out of PostHog, you should familiarize yourself with the following:

    • PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
    • PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
    • Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
    • Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
    • PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
    • Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/react-router-v7-framework-mode.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

React Router V7 framework mode (Remix V3)

This guide walks you through setting up PostHog for React Router V7 in framework mode. If you're using React Router in another mode, find the guide for that mode in the React Router page (/docs/libraries/react-router.md). If you're using React with another framework, go to the React integration guide (/docs/libraries/react.md).

  1. 1

    Install client-side SDKs

    Required

    First, you'll need to install posthog-js and @posthog/react using your package manager. These packages allow you to capture client-side events.

    npm
    npm install --save posthog-js @posthog/react
    Yarn
    yarn add posthog-js @posthog/react
    pnpm
    pnpm add posthog-js @posthog/react
    Bun
    bun add posthog-js @posthog/react

    If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

    script-src 'self' https://*.posthog.com;
    connect-src 'self' https://*.posthog.com;
    worker-src 'self' blob: data:;

    script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

    In framework mode, you'll also need to set posthog-js and @posthog/react as external packages in your vite.config.ts file to avoid SSR errors.

    vite.config.ts

    // ... imports
    
    export default defineConfig({
      plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
      ssr: {
        noExternal: ['posthog-js', '@posthog/react']
      }
    });
  2. 2

    Add your environment variables

    Required

    Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token and host in your project settings. If you're using Vite, prefixing variable names with VITE_ ensures they are accessible in the frontend.

    .env.local

    VITE_POSTHOG_PROJECT_TOKEN=<ph_project_token>
    VITE_POSTHOG_HOST=https://us.i.posthog.com
  3. 3

    Add the PostHogProvider to your app

    Required

    In framework mode, your app enters from the app/entry.client.tsx file. In this file, you'll need to initialize the PostHog SDK and pass it to your app through the PostHogProvider context.

    app/entry.client.tsx

    import { startTransition, StrictMode } from "react";
    import { hydrateRoot } from "react-dom/client";
    import { HydratedRouter } from "react-router/dom";
    
    import posthog from 'posthog-js';
    import { PostHogProvider } from '@posthog/react'
    
    posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN, {
      api_host: import.meta.env.VITE_POSTHOG_HOST,
      defaults: '2026-05-30',
      tracing_headers: [ window.location.hostname, 'localhost' ],
    });
    
    
    startTransition(() => {
      hydrateRoot(
        document,
        {/* Pass PostHog client through PostHogProvider */}
        <PostHogProvider client={posthog}>
          <StrictMode>
            <HydratedRouter />
          </StrictMode>
        </PostHogProvider>,
      );
    });

    To help PostHog track your user sessions across the client and server, you'll need to add the tracing_headers: ['your-backend-hostname1.com', 'your-backend-hostname2.com', ...] option to your PostHog initialization. This adds the X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID headers to requests sent to the configured hostnames, which we'll later use on the server-side.

    TypeError: Cannot read properties of undefined

    If you see the error TypeError: Cannot read properties of undefined (reading '...') this is likely because you tried to call a posthog function when posthog was not initialized (such as during the initial render). On purpose, we still render the children even if PostHog is not initialized so that your app still loads even if PostHog can't load.

    To fix this error, add a check that posthog has been initialized such as:

    React

    useEffect(() => {
      posthog?.capture('test') // using optional chaining (recommended)
    
      if (posthog) {
        posthog.capture('test') // using an if statement
      }
    }, [posthog])

    Typescript helps protect against these errors.

  4. Verify client-side events are captured

    Checkpoint

    Confirm that you can capture client-side events and see them in your PostHog project

    At this point, you should be able to capture client-side events and see them in your PostHog project. This includes basic events like page views and button clicks that are autocaptured (/docs/product-analytics/autocapture.md).

    You can also try to capture a custom event to verify it's working. You can access PostHog in any component using the usePostHog hook.

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    You should see these events in a minute or two in the activity tab.

  5. 4

    Access PostHog methods

    Required

    On the client-side, you can access the PostHog client using the usePostHog hook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example:

    TSX

    import { usePostHog } from '@posthog/react'
    
    function App() {
      const posthog = usePostHog()
      return <button onClick={() => posthog?.capture('button_clicked')}>Click me</button>
    }

    For a complete list of available methods, see the posthog-js documentation (/docs/libraries/js.md).

  6. 5

    Identify your user

    Recommended

    Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events.

    Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the identify method on the PostHog client:

    TSX

    export default function Login() {
      const { user, login } = useAuth();
      const posthog = usePostHog();
    
      const handleLogin = async (e: React.FormEvent) => {
        // existing code to handle login...
        const user = await login({ email, password });
    
        posthog?.identify(user.email,
          {
            email: user.email,
            name: user.name,
          }
        );
        posthog?.capture('user_logged_in');
      };
    
      return (
        <div>
          {/* ... existing code ... */}
          <button onClick={handleLogin}>Login</button>
        </div>
      );
    }

    PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person.

  7. 6

    Create an error boundary

    Recommended

    PostHog can capture exceptions thrown in your app through an error boundary. React Router in framework mode has a built-in error boundary that you can use to capture exceptions. You can create an error boundary by exporting ErrorBoundary from your app/root.tsx file.

    app/root.tsx

    import { usePostHog } from '@posthog/react'
    
    export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
      const posthog = usePostHog();
      posthog?.captureException(error);
    
      // other error handling code...
      return (
        <div>
          <h1>Something went wrong</h1>
          <p>{error.message}</p>
        </div>
      );
    }

    This automatically captures exceptions thrown in your React Router app using the posthog.captureException() method.

  8. 7

    Tracking element visibility

    Recommended

    The PostHogCaptureOnViewed component enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing.

    The component wraps your content and sends a $element_viewed event to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance.

    Basic usage:

    React

    import { PostHogCaptureOnViewed } from '@posthog/react'
    
    function App() {
        return (
            <PostHogCaptureOnViewed name="hero-banner">
                <div>Your important content here</div>
            </PostHogCaptureOnViewed>
        )
    }

    With custom properties:

    You can include additional properties with the event to provide more context:

    React

    <PostHogCaptureOnViewed
        name="product-card"
        properties={{
            product_id: '123',
            category: 'electronics',
            price: 299.99
        }}
    >
        <ProductCard />
    </PostHogCaptureOnViewed>

    Tracking multiple children:

    Use trackAllChildren to track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed:

    React

    <PostHogCaptureOnViewed
        name="product-gallery"
        properties={{ gallery_type: 'featured' }}
        trackAllChildren
    >
        <ProductCard id="1" />
        <ProductCard id="2" />
        <ProductCard id="3" />
    </PostHogCaptureOnViewed>

    When trackAllChildren is enabled, each child element sends its own event with a child_index property indicating its position.

    Custom intersection observer options:

    You can customize when elements are considered "viewed" by passing options to the IntersectionObserver:

    React

    <PostHogCaptureOnViewed
        name="footer"
        observerOptions={{
            threshold: 0.5,  // Element is 50% visible
            rootMargin: '0px'
        }}
    >
        <Footer />
    </PostHogCaptureOnViewed>

    The component passes all other props to the wrapper div, so you can add styling, classes, or other HTML attributes as needed.

  9. 8

    Install server-side SDKs

    Recommended

    Install the PostHog Node SDK (/docs/libraries/node.md) using your package manager. This is the SDK you'll use to capture server-side events.

    npm
    npm install posthog-node --save
    Yarn
    yarn add posthog-node
    pnpm
    pnpm add posthog-node
    Bun
    bun add posthog-node
  10. 9

    Create a server-side middleware

    Recommended

    Next, create a server-side middleware to help you capture server-side events. This middleware helps you achieve the following:

    • Initialize a PostHog client
    • Fetch the session and distinct ID from the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers and pass them to your request as a context (/docs/libraries/node.md#contexts). This automatically identifies the user and session for you in all subsequent event captures.
    • Calls shutdown() on the PostHog client to ensure all events are sent before the request is completed.

    app/lib/posthog-middleware.ts

    import { PostHog } from "posthog-node";
    import type { RouterContextProvider } from "react-router";
    import type { Route } from "../+types/root";
    
    export interface PostHogContext extends RouterContextProvider {
      posthog?: PostHog;
    }
    
    export const posthogMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => {
      const posthog = new PostHog(process.env.VITE_POSTHOG_PROJECT_TOKEN!, {
        host: process.env.VITE_POSTHOG_HOST!,
        flushAt: 1,
        flushInterval: 0,
      });
    
      const sessionId = request.headers.get('X-POSTHOG-SESSION-ID');
      const distinctId = request.headers.get('X-POSTHOG-DISTINCT-ID');
    
      (context as PostHogContext).posthog = posthog;
    
      const response = await posthog.withContext(
        { sessionId: sessionId ?? undefined, distinctId: distinctId ?? undefined },
        next
      );
    
      await posthog.shutdown().catch(() => {});
    
      return response;
    };

    Then, you'll need to register the middleware in your app in the app/root.tsx file by exporting it in the Route.MiddlewareFunction[] array.

    app/root.tsx

    import { posthogMiddleware } from './lib/posthog-middleware';
    
    export const middleware: Route.MiddlewareFunction[] = [
      posthogMiddleware,
      // other middlewares...
    ];
  11. Verify server-side events are captured

    Checkpoint

    Confirm that you can capture server-side events and see them in your PostHog project

    At this point, you should be able to capture server-side events and see them in your PostHog project.

    In a route, you can access the PostHog client from the context and capture an event. The middleware assigns the session ID and the distinct ID. This ensures that the system associates events with the correct user and session.

    app/routes/api.checkout.ts

    import type { PostHogContext } from "../lib/posthog-middleware";
    
    export async function action({ request, context }: Route.ActionArgs) {
      const body = await request.json();
      // ... existing code ...
    
      // Access the PostHog client from the context and capture an event
      const posthog = (context as PostHogContext).posthog;
      posthog?.capture({ event: 'checkout_completed' });
    
      return Response.json({
        success: true,
        // ... existing code ...
      });
    }
  12. 10

    Next steps

    Recommended

    Now that you've set up PostHog for React Router, you can start capturing events and exceptions in your app.

    To get the most out of PostHog, you should familiarize yourself with the following:

    • PostHog Web SDK docs (/docs/libraries/js.md): Learn more about the PostHog Web SDK and how to use it on the client-side.
    • PostHog Node SDK docs (/docs/libraries/node.md): Learn more about the PostHog Node SDK and how to use it on the server-side.
    • Identify users (/docs/product-analytics/identify.md): Learn more about how to identify users in your app.
    • Group analytics (/docs/product-analytics/group-analytics.md): Learn more about how to use group analytics in your app.
    • PostHog AI (/docs/posthog-ai.md): After capturing events, use PostHog AI to help you understand your data and build insights.
    • Feature flags and experiments (/docs/libraries/react.md#feature-flags): Feature flag and experiment setup is the same as React. You can find more details in the React integration guide.
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/ruby-on-rails.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Ruby on Rails

PostHog makes it easy to get data about traffic and usage of your Ruby on Rails app. Integrating PostHog enables analytics, custom event capture, feature flags, and automatic exception tracking.

This guide walks you through integrating PostHog into your Rails app using the posthog-rails gem.

Beta: integration via LLM

Install PostHog for Rails in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.

npx @posthog/wizard

Learn more (/wizard.md)

Or, to integrate manually, continue with the rest of this guide.

Features

  • Automatic exception tracking – Captures unhandled and rescued exceptions
  • ActiveJob instrumentation – Tracks background job exceptions
  • User context – Automatically associates exceptions with the current user
  • Smart filtering – Excludes common Rails exceptions (404s, etc.) by default
  • Request context – Adds request metadata and optional PostHog tracing header identity/session context to captured events
  • Rails 7.0+ error reporter – Integrates with Rails' built-in error reporting
  • Log forwarding – Optionally forwards Rails.logger output to PostHog Logs (/docs/logs.md) over OpenTelemetry, automatically correlated with request context (Ruby 3.3+)

Installation

Add both gems to your Gemfile:

Gemfile

gem 'posthog-ruby', require: 'posthog'
gem 'posthog-rails'

Then run:

Terminal

bundle install

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Generate the initializer

Run the install generator to create the PostHog initializer:

Terminal

rails generate posthog:install

This creates config/initializers/posthog.rb with sensible defaults and documentation.

Configuration

PostHog.init creates a single client instance used across your app. Avoid creating multiple PostHog::Client instances with the same API key, as this can cause dropped events and inconsistent behavior.

The generated initializer includes the most common options:

config/initializers/posthog.rb

# Rails-specific configuration
PostHog::Rails.configure do |config|
  config.auto_capture_exceptions = true           # Enable automatic exception capture (default: false)
  config.report_rescued_exceptions = true         # Report exceptions Rails rescues (default: false)
  config.auto_instrument_active_job = true        # Instrument background jobs (default: false)
  config.use_tracing_headers = true               # Use PostHog tracing headers for identity/session context (default: true)
  config.capture_user_context = true              # Include authenticated user info in exceptions (default: true)
  config.current_user_method = :current_user      # Method to get current user (default: :current_user)
  config.user_id_method = nil                     # Method to get ID from user object (default: auto-detect)

  # Add additional exceptions to ignore
  config.excluded_exceptions = ['MyCustomError']
end

# Core PostHog client initialization
PostHog.init do |config|
  # Required: Your PostHog project API key
  config.api_key = '<ph_project_token>'

  # Optional: Your PostHog instance URL
  config.host = 'https://us.i.posthog.com'

  # Optional: Personal API key for feature flags
  config.personal_api_key = 'phx_xxxxxxxxx'

  # Maximum number of events to queue before dropping (default: 10000)
  config.max_queue_size = 10_000

  # Send events synchronously on the calling thread (default: false)
  config.sync_mode = false

  # Feature flags polling interval in seconds (default: 30)
  config.feature_flags_polling_interval = 30

  # Feature flag request timeout in seconds (default: 3)
  config.feature_flag_request_timeout_seconds = 3

  # Error callback to detect misconfiguration
  config.on_error = proc { |status, msg|
    Rails.logger.error("PostHog error: #{msg}")
  }

  # Before-send callback to modify or drop events
  config.before_send = proc { |event|
    event[:properties] ||= {}
    event[:properties]['environment'] = Rails.env
    event
  }

  # Disable network calls in test mode
  config.test_mode = true if Rails.env.test?
end

You can find your project token and instance address in your project settings.

Tip: Use Rails.application.credentials to avoid hardcoding API keys. First, add your keys and then reference them in your initializer:

Terminal

rails credentials:edit

config/credentials.yml.enc

posthog:
  api_key: <ph_project_token>
  host: https://us.i.posthog.com
  personal_api_key: phx_xxxxxxxxx

config/initializers/posthog.rb

config.api_key = Rails.application.credentials.posthog[:api_key]
config.host = Rails.application.credentials.posthog[:host]
config.personal_api_key = Rails.application.credentials.posthog[:personal_api_key]

Capturing events

Track custom events anywhere in your Rails app:

Ruby

PostHog.capture({
  distinct_id: current_user.id,
  event: 'post_created',
  properties: { title: @post.title }
})

Identify a user and set their person properties:

Ruby

PostHog.identify({
  distinct_id: current_user.id,
  properties: {
    email: current_user.email,
    plan: current_user.plan
  }
})

The Rails integration delegates methods like capture, identify, alias, group_identify, evaluate_flags, capture_exception, flush, and shutdown to the initialized PostHog::Client.

Request context

PostHog Rails automatically applies request-scoped context to events captured during web requests. Request metadata such as $current_url, $request_method, $request_path, $user_agent, and $ip is added to event properties.

When use_tracing_headers is enabled, PostHog tracing headers (X-PostHog-Distinct-Id and X-PostHog-Session-Id) are also used as default distinct_id and $session_id values. Explicit distinct_id and properties passed to PostHog.capture always take precedence.

If you're using PostHog JS (/docs/libraries/js.md) on the frontend, configure tracing_headers (/docs/libraries/js/config.md#tracing-headers) for your Rails backend hostname so browser requests include the session and distinct ID headers.

Tracing headers are client-controlled analytics context, not authentication or authorization. Pass an authenticated distinct_id explicitly for security-sensitive server-side decisions.

Disable tracing header identity/session capture if you do not want client-supplied tracing headers used for server-side events. Request metadata is still captured:

Ruby

PostHog::Rails.config.use_tracing_headers = false

Logs

To set up PostHog Logs (/docs/logs.md) in your Rails app, follow the Ruby on Rails logs installation guide (/docs/logs/installation/ruby-on-rails.md). The integration forwards Rails.logger output to PostHog Logs over OpenTelemetry, automatically correlated with each request's distinct ID and session ID. Requires Ruby 3.3+.

Error tracking

For full details on setting up error tracking with Rails, see our Rails error tracking installation guide (/docs/error-tracking/installation/ruby-on-rails.md).

Automatic exception tracking

When auto_capture_exceptions is enabled, exceptions are automatically captured:

Ruby

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    # Any exception here is automatically captured
  end
end

report_rescued_exceptions controls whether exceptions Rails rescues (for example, exceptions rendered by Rails error pages) are captured. Enable it along with auto_capture_exceptions for complete error visibility, or leave it disabled to capture only unhandled exceptions.

Manual exception capture

You can also manually capture exceptions:

Ruby

PostHog.capture_exception(
  exception,
  current_user.id,
  { custom_property: 'value' }
)

If you evaluated feature flags for the request, pass the same snapshot to include matching flag properties on the exception event:

Ruby

flags = PostHog.evaluate_flags(current_user.id)

PostHog.capture_exception(
  exception,
  current_user.id,
  { custom_property: 'value' },
  flags: flags
)
Background job exceptions

When auto_instrument_active_job is enabled, ActiveJob exceptions are automatically captured with job context:

Ruby

class EmailJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
    # Exceptions are automatically captured
  end
end
Associating jobs with users

By default, PostHog extracts a distinct_id from job arguments by looking for a user_id key in hash arguments:

Ruby

# PostHog will automatically use options[:user_id] as the distinct_id
ProcessOrderJob.perform_later(order.id, user_id: current_user.id)

For more control, use the posthog_distinct_id class method. The proc or block receives the same arguments as perform:

Ruby

class SendWelcomeEmailJob < ApplicationJob
  posthog_distinct_id ->(user, _options) { user.id }

  def perform(user, options = {})
    UserMailer.welcome(user).deliver_now
  end
end

You can also use a block:

Ruby

class ProcessOrderJob < ApplicationJob
  posthog_distinct_id do |_order, notify_user_id|
    notify_user_id
  end

  def perform(order, notify_user_id)
    # Process the order...
  end
end
Rails 7.0+ error reporter

PostHog integrates with Rails' built-in error reporting:

Ruby

# These errors are automatically sent to PostHog
Rails.error.handle do
  # Code that might raise an error
end

Rails.error.record(exception, context: { user_id: current_user.id })

PostHog automatically extracts the user's distinct ID from user_id or distinct_id in the context hash. Other context keys are included as properties on the exception event.

User context

PostHog Rails automatically captures authenticated user information from your controllers for exceptions. Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity.

If your user method has a different name, configure it:

Ruby

PostHog::Rails.config.current_user_method = :logged_in_user
User ID extraction

By default, PostHog Rails auto-detects the user's distinct ID by trying these methods in order:

  1. posthog_distinct_id – Define this on your User model for full control
  2. distinct_id – Common analytics convention
  3. id – Standard ActiveRecord primary key
  4. pk – Primary key alias
  5. uuid – For UUID-based primary keys

It also checks hash-like users for id, pk, and uuid keys.

You can configure a specific method:

Ruby

PostHog::Rails.config.user_id_method = :email

Or define a method on your User model:

Ruby

class User < ApplicationRecord
  def posthog_distinct_id
    "user_#{id}"  # or external_id, or any unique identifier
  end
end
Excluded exceptions

The following exceptions are not reported by default (common 4xx errors):

  • AbstractController::ActionNotFound
  • ActionController::BadRequest
  • ActionController::InvalidAuthenticityToken
  • ActionController::InvalidCrossOriginRequest
  • ActionController::MethodNotAllowed
  • ActionController::NotImplemented
  • ActionController::ParameterMissing
  • ActionController::RoutingError
  • ActionController::UnknownFormat
  • ActionController::UnknownHttpMethod
  • ActionDispatch::Http::Parameters::ParseError
  • ActiveRecord::RecordNotFound
  • ActiveRecord::RecordNotUnique

Add more with:

Ruby

PostHog::Rails.config.excluded_exceptions = ['MyException']

Feature flags

Evaluate flags once for the current user, then read values from the returned snapshot:

Ruby

class PostsController < ApplicationController
  def show
    flags = PostHog.evaluate_flags(current_user.id)

    if flags.enabled?('new-post-design')
      render 'posts/show_new'
    else
      render 'posts/show'
    end
  end
end

For multivariate flags and experiments, use get_flag:

Ruby

flags = PostHog.evaluate_flags(current_user.id)
variant = flags.get_flag('checkout-experiment')

if variant == 'test'
  # Do something differently
end

When capturing an event after branching on a flag, pass the same flags snapshot so the event includes the exact flag values used by your code:

Ruby

flags = PostHog.evaluate_flags(current_user.id)

PostHog.capture({
  distinct_id: current_user.id,
  event: 'checkout_started',
  flags: flags.only_accessed
})

For local evaluation, ensure you've set personal_api_key:

Ruby

config.personal_api_key = Rails.application.credentials.posthog[:personal_api_key]

See our Ruby SDK docs (/docs/libraries/ruby.md#local-evaluation) for details on local evaluation with Puma and Unicorn servers.

Note: PostHog.is_feature_enabled, PostHog.get_feature_flag, PostHog.get_feature_flag_result, PostHog.get_feature_flag_payload, and PostHog.capture({ ..., send_feature_flags: true }) still work during the migration period, but they're deprecated. Prefer PostHog.evaluate_flags for new code.

Testing

In your test environment, disable network calls with test mode:

config/environments/test.rb

PostHog.init do |config|
  config.api_key = '<ph_project_token>'
  config.test_mode = true
end

Or in your specs:

spec/rails_helper.rb

RSpec.configure do |config|
  config.before(:each) do
    allow(PostHog).to receive(:capture)
  end
end

Configuration reference

Core PostHog options
Option Type Default Description
api_key String required Your PostHog project token.
host String https://us.i.posthog.com Fully qualified PostHog API host.
personal_api_key String nil Personal API key for local feature flag evaluation and remote config payloads.
max_queue_size Integer 10000 Maximum number of events to keep in the async queue before dropping new events.
test_mode Boolean false Keep events queued and do not send them. Useful for tests.
sync_mode Boolean false Send events synchronously on the calling thread.
on_error Proc no-op Callback called as on_error.call(status, error).
feature_flags_polling_interval Integer 30 Seconds between local feature flag definition polls.
feature_flag_request_timeout_seconds Integer 3 Timeout, in seconds, for feature flag requests.
before_send Proc nil Callback that receives the event hash before it is queued or sent. Return a modified event hash, or nil to drop the event.

The PostHog.init block supports the options above. Less common core options like batch_size, disable_singleton_warning, skip_ssl_verification, and flag_definition_cache_provider can be passed as an options hash to PostHog.init(...); see the Ruby SDK docs (/docs/libraries/ruby.md#configuration) for details.

Rails-specific options

Configure these via PostHog::Rails.configure or PostHog::Rails.config:

Option Type Default Description
auto_capture_exceptions Boolean false Automatically capture exceptions.
report_rescued_exceptions Boolean false Report exceptions Rails rescues.
auto_instrument_active_job Boolean false Capture ActiveJob exceptions with job context.
excluded_exceptions Array [] Additional exception class names to ignore.
use_tracing_headers Boolean true Use X-PostHog-Distinct-Id and X-PostHog-Session-Id as request-scoped defaults.
capture_user_context Boolean true Include authenticated user info in exceptions.
current_user_method Symbol :current_user Controller method used to fetch the current user.
user_id_method Symbol nil Method used to extract the distinct ID from the user object. Auto-detects when nil.

Troubleshooting

Exceptions not being captured
  1. Verify PostHog is initialized:

    Ruby

    Rails.console
    > PostHog.initialized?
    => true
  2. Check your excluded exceptions list.

  3. Verify middleware is installed:

    Ruby

    Rails.application.middleware
User context not working
  1. Verify current_user_method matches your controller method.
  2. Check that the user object responds to posthog_distinct_id, distinct_id, id, pk, or uuid.
  3. If using a custom identifier, set PostHog::Rails.config.user_id_method = :your_method.
Feature flags not working

Ensure you've set personal_api_key in your configuration.

Next steps

For any technical questions for how to integrate specific PostHog features into Rails (such as analytics, feature flags, A/B testing, etc.), have a look at our Ruby SDK docs (/docs/libraries/ruby.md).

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/ruby.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Ruby

The posthog-ruby library provides tracking functionality on the server-side for applications built in Ruby.

It uses an internal queue to make calls fast and non-blocking. It also batches requests and flushes asynchronously, making it perfect to use in any part of your web app or other server-side application that needs performance.

Use a single client instance (singleton) — Create the PostHog client once and reuse it throughout your application. Multiple client instances with the same API key can cause dropped events and inconsistent behavior. The SDK logs a warning if it detects multiple instances.

Installation

Add this to your Gemfile:

Terminal

gem "posthog-ruby"

In your app, set your API key before making any calls. If setting a custom host, make sure to include the protocol (e.g. https://).

Ruby

require 'posthog'

posthog = PostHog::Client.new({
  api_key: "<ph_project_token>",
  host: "https://us.i.posthog.com",
  on_error: Proc.new { |status, msg| print msg }
})

You can find your project token and instance address in the project settings page in PostHog.

Configuration

Initialize the client with your project token before making any calls:

Ruby

require 'posthog'

posthog = PostHog::Client.new({
  api_key: '<ph_project_token>',
  host: 'https://us.i.posthog.com',
  on_error: Proc.new { |status, msg| print msg }
})

Available client options:

Option Type Default Description
api_key String required Your PostHog project token.
host String https://us.i.posthog.com Fully qualified PostHog API host. Include the protocol, for example https://us.i.posthog.com or https://eu.i.posthog.com.
personal_api_key String nil Personal API key. Required for local feature flag evaluation and remote config payloads.
max_queue_size Integer 10000 Maximum number of events to keep in the async queue before dropping new events.
batch_size Integer 100 Maximum number of events to send in one async batch.
test_mode Boolean false Keep events queued and do not send them. Useful for tests.
sync_mode Boolean false Send events synchronously on the calling thread. Useful in forking environments like Sidekiq and Resque.
on_error Proc no-op Callback called as on_error.call(status, error) for API or serialization errors.
feature_flags_polling_interval Integer 30 Seconds between local feature flag definition polls.
feature_flag_request_timeout_seconds Integer 3 Timeout, in seconds, for feature flag requests.
before_send Proc nil Callback that receives the event hash before it is queued or sent. Return a modified event hash, or nil to drop the event.
disable_singleton_warning Boolean false Suppress warnings about multiple clients with the same API key. Use only when you intentionally need multiple clients.
skip_ssl_verification Boolean false Disable SSL certificate verification. Intended only for local development or custom deployments.
flag_definition_cache_provider Object nil Provider for distributed feature flag definition caching. See distributed flag definition caching (#distributed-flag-definition-caching).
Filtering or modifying events before sending

Use before_send to add, modify, or drop events immediately before the SDK queues or sends them:

Ruby

posthog = PostHog::Client.new({
  api_key: '<ph_project_token>',
  before_send: Proc.new do |event|
    event[:properties] ||= {}
    event[:properties]['environment'] = ENV['RACK_ENV']

    # Return nil to drop the event
    event[:properties]['internal_user'] == true ? nil : event
  end
})
Flushing and shutting down

For short-lived scripts, call flush before the process exits. Call shutdown when your application is stopping to flush pending events and stop background resources.

Ruby

posthog.capture({ distinct_id: 'user_123', event: 'script_finished' })
posthog.flush
posthog.shutdown

Identifying users

Identifying users is required. Backend events need a distinct_id that matches the ID your frontend uses when calling posthog.identify(). Without this, backend events are orphaned — they can't be linked to frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), or error tracking (/docs/error-tracking.md).

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

Identify a user and set their person properties with identify:

Ruby

posthog.identify({
  distinct_id: 'distinct_id_of_your_user',
  properties: {
    email: 'john@doe.com',
    pro_user: false
  }
})

Capturing events

You can send custom events using capture:

Ruby

posthog.capture({
    distinct_id: 'distinct_id_of_the_user',
    event: 'user_signed_up'
})

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Ruby

posthog.capture({
    distinct_id: 'distinct_id_of_the_user',
    event: 'user_signed_up',
    properties: {
        login_type: 'email',
        is_free_trial: true
    }
})
Sending pageviews

If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send pageviews from your backend like so:

Ruby

posthog.capture({
    distinct_id: 'distinct_id_of_the_user',
    event: '$pageview',
    properties: {
        '$current_url': 'https://example.com'
    }
})

capture accepts these fields:

Field Type Description
distinct_id String The user ID. If omitted, framework integrations can provide request context; otherwise the SDK generates a UUID and marks the event as personless.
event String Event name. Required.
properties Hash Event properties.
groups Hash Group analytics mapping from group type to group key.
timestamp Time When the event occurred. Defaults to the current time.
message_id String Optional message ID.
uuid String Optional event UUID used for deduplication. Must be a valid UUID.
flags PostHog::FeatureFlagEvaluations Snapshot returned by evaluate_flags. Adds $feature/<key> and $active_feature_flags properties without another /flags request.
send_feature_flags Boolean, Hash, or PostHog::SendFeatureFlagsOptions Deprecated. Prefer passing flags: from evaluate_flags.

Person profiles and properties

The Ruby SDK captures identified events by default. These create person profiles (/docs/data/persons.md). To set person properties (/docs/product-analytics/person-properties.md) in these profiles, include them when capturing an event:

Ruby

posthog.capture({
    distinct_id: 'distinct_id',
    event: 'event_name',
    properties: {
        '$set': { name: 'Max Hedgehog' },
        '$set_once': { initial_url: '/blog' }
    }
})

For more details on the difference between $set and $set_once, see our person properties docs (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once).

To capture anonymous events (/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's $process_person_profile property to false:

Ruby

posthog.capture({
    distinct_id: 'distinct_id',
    event: 'event_name',
    properties: {
        '$process_person_profile': false
    }
})

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Ruby

posthog.alias({
  distinct_id: 'distinct_id',
  alias: 'alias_id'
})

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

There are two steps to implement feature flags in Ruby:

Step 1: Evaluate flags once

Call posthog.evaluate_flags() once for the user, then read values from the returned snapshot.

Boolean feature flags

Ruby

flags = posthog.evaluate_flags('distinct_id_of_your_user')

if flags.enabled?('flag-key')
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = flags.get_flag_payload('flag-key')
end
Multivariate feature flags

Ruby

flags = posthog.evaluate_flags('distinct_id_of_your_user')

enabled_variant = flags.get_flag('flag-key')

if enabled_variant == 'variant-key' # replace 'variant-key' with the key of your variant
    # Do something differently for this user
    # Optional: fetch the payload
    matched_flag_payload = flags.get_flag_payload('flag-key')
end

flags.get_flag() returns the variant string for multivariate flags, true for enabled boolean flags, false for disabled flags, and nil when the flag wasn't returned by the evaluation.

Note: posthog.is_feature_enabled(), posthog.get_feature_flag(), posthog.get_feature_flag_result(), posthog.get_feature_flag_payload(), and capture({ ..., send_feature_flags: true }) still work during the migration period, but they're deprecated. Prefer evaluate_flags() for new code.

Step 2: Include feature flag information when capturing events

If you want use your feature flag to breakdown or filter events in your insights (/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.

Note: This step is only required for events captured using our server-side SDKs or API (/docs/api.md).

There are two methods you can use to include feature flag information in your events:

Method 1: Pass the evaluated flags snapshot to capture()

Pass the same flags object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another /flags request.

Ruby

flags = posthog.evaluate_flags('distinct_id_of_your_user')

if flags.enabled?('flag-key')
    # Do something differently for this user
end

posthog.capture({
    distinct_id: 'distinct_id_of_your_user',
    event: 'event_name',
    flags: flags,
})

By default, this attaches every flag in the snapshot using $feature/<flag-key> properties and $active_feature_flags.

To reduce event property bloat, pass a filtered snapshot:

Ruby

# Attach only flags accessed with enabled?() or get_flag() before this call
posthog.capture({
    distinct_id: 'distinct_id_of_your_user',
    event: 'event_name',
    flags: flags.only_accessed,
})

# Attach only specific flags
posthog.capture({
    distinct_id: 'distinct_id_of_your_user',
    event: 'event_name',
    flags: flags.only(['checkout-flow', 'new-dashboard']),
})

only_accessed is order-dependent. If you call it before accessing any flags with enabled?() or get_flag(), no feature flag properties are attached.

Method 2: Include the $feature/feature_flag_name property manually

In the event properties, include $feature/feature_flag_name: variant_key:

Ruby

posthog.capture({
    distinct_id: 'distinct_id_of_your_user',
    event: 'event_name',
    properties: {
        # Replace feature-flag-key with your flag key and 'variant-key' with the key of your variant
        '$feature/feature-flag-key': 'variant-key',
    },
})
Evaluating only specific flags

By default, evaluate_flags() evaluates every flag for the user. If you only need a few flags, pass flag_keys to request only those flags:

Ruby

flags = posthog.evaluate_flags(
    'distinct_id_of_your_user',
    flag_keys: ['checkout-flow', 'new-dashboard'],
)
Evaluating locally only

If you want to skip the remote /flags request and only use locally cached definitions, pass only_evaluate_locally: true:

Ruby

flags = posthog.evaluate_flags(
    'distinct_id_of_your_user',
    only_evaluate_locally: true,
)
Disabling GeoIP for flag evaluation

Pass disable_geoip: true to disable GeoIP lookup for remote flag evaluation:

Ruby

flags = posthog.evaluate_flags(
    'distinct_id_of_your_user',
    disable_geoip: true,
)
Sending $feature_flag_called events

Capturing $feature_flag_called events enables PostHog to know when a flag was accessed by a user and provide analytics and insights (/docs/product-analytics/insights.md) on the flag. With evaluate_flags(), the SDK sends this event when you call flags.enabled?() or flags.get_flag() for a flag.

The SDK deduplicates these events per (distinct_id, flag, value) in a local cache. If you reinitialize the PostHog client, the cache resets and $feature_flag_called events may be sent again. PostHog handles duplicates, so duplicate $feature_flag_called events don't affect your analytics.

flags.get_flag_payload() doesn't send $feature_flag_called events and doesn't count as an access for only_accessed.

Advanced: Overriding server properties

Sometimes, you may want to evaluate feature flags using person properties (/docs/product-analytics/person-properties.md), groups (/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.

You can provide properties to evaluate the flag with by using the person properties, groups, and group properties arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.

For example:

Ruby

flags = posthog.evaluate_flags(
    'distinct_id_of_the_user',
    person_properties: {
        property_name: 'value'
    },
    groups: {
        your_group_type: 'your_group_id',
        another_group_type: 'your_group_id',
    },
    group_properties: {
        your_group_type: {
            group_property_name: 'value'
        },
        another_group_type: {
            group_property_name: 'value'
        },
    },
)

if flags.enabled?('flag-key')
    # Do something differently for this user
end
Overriding GeoIP properties

By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.

You can override GeoIP properties by including them in the person_properties parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.

The following GeoIP properties can be overridden:

  • $geoip_country_code
  • $geoip_country_name
  • $geoip_city_name
  • $geoip_city_confidence
  • $geoip_continent_code
  • $geoip_continent_name
  • $geoip_latitude
  • $geoip_longitude
  • $geoip_postal_code
  • $geoip_subdivision_1_code
  • $geoip_subdivision_1_name
  • $geoip_subdivision_2_code
  • $geoip_subdivision_2_name
  • $geoip_subdivision_3_code
  • $geoip_subdivision_3_name
  • $geoip_time_zone

Simply include any of these properties in the person_properties parameter alongside your other person properties when calling feature flags.

Request timeout

You can configure the feature_flag_request_timeout_seconds parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.

Ruby

posthog = PostHog::Client.new({
    # rest of your configuration...
    feature_flag_request_timeout_seconds: 3 # Time in seconds. Defaults to 3.
})
Legacy single-flag methods

The following methods are still available during the migration period, but are deprecated. Prefer evaluate_flags for new code.

Method Replacement
posthog.is_feature_enabled(flag_key, distinct_id, ...) posthog.evaluate_flags(distinct_id, ...).enabled?(flag_key)
posthog.get_feature_flag(flag_key, distinct_id, ...) posthog.evaluate_flags(distinct_id, ...).get_flag(flag_key)
posthog.get_feature_flag_payload(flag_key, distinct_id, ...) posthog.evaluate_flags(distinct_id, ...).get_flag_payload(flag_key)
posthog.get_feature_flag_result(flag_key, distinct_id, ...) posthog.evaluate_flags(distinct_id, ...) and read get_flag / get_flag_payload
posthog.capture({ ..., send_feature_flags: true }) posthog.capture({ ..., flags: flags })
Local Evaluation

Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.

It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.

For details on how to implement local evaluation, see our local evaluation guide (/docs/feature-flags/local-evaluation.md).

Evaluating feature flags locally in unicorn server

If you have preload_app true in your unicorn config, you can use the after_fork hook (which is part of the unicorn's configuration) to enable the feature flag cache to receive the updates from PostHog.

Ruby

after_fork do |_server, _worker|
  $posthog = PostHog::Client.new({
    api_key: '<ph_project_token>',
    personal_api_key: '<ph_personal_api_key>',
    host: 'https://us.i.posthog.com',
    on_error: Proc.new { |status, msg| print msg }
  })
end
Evaluating feature flags locally in a Puma server

If you use Puma with multiple workers, you can use the on_worker_boot hook (which is part of Puma's configuration) to enable the feature flag cache to receive updates from PostHog.

Ruby

on_worker_boot do
  $posthog = PostHog::Client.new({
    api_key: '<ph_project_token>',
    personal_api_key: '<ph_personal_api_key>',
    host: 'https://us.i.posthog.com',
    on_error: Proc.new { |status, msg| print msg }
  })
end
Distributed flag definition caching

flag_definition_cache_provider shares locally evaluated feature flag definitions across multiple workers or processes. The provider object must implement:

  • flag_definitions – returns cached definitions as a hash with :flags, :group_type_mapping, and :cohorts, or nil if empty.
  • should_fetch_flag_definitions? – returns true if this process should fetch fresh definitions from PostHog.
  • on_flag_definitions_received(data) – stores freshly fetched definitions.
  • shutdown – releases locks or other resources.

Ruby

posthog = PostHog::Client.new({
  api_key: '<ph_project_token>',
  personal_api_key: '<ph_personal_api_key>',
  flag_definition_cache_provider: my_cache_provider
})
Remote config payloads

Use get_remote_config_payload to fetch the decrypted remote config payload for a flag. This requires personal_api_key.

Ruby

payload = posthog.get_remote_config_payload('flag-key')

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:

Ruby

flags = posthog.evaluate_flags('user_distinct_id')
variant = flags.get_flag('experiment-feature-flag-key')

if variant == 'variant-name'
    # Do something
end

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

Group analytics

Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the Group Analytics (/docs/user-guides/group-analytics.md) guide for more information.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

Capture an event and associate it with a group:

Ruby

posthog.capture({
    distinct_id: 'distinct_id_of_the_user',
    event: 'movie_played',
    properties: {
        movie_id: '123',
        category: 'romcom'
    },
    groups: {
        'company': 'company_id_in_your_db'
    }
})

Update properties on a group:

Ruby

posthog.group_identify({
  group_type: 'company',
  group_key: 'company_id_in_your_db',
  properties: {
    name: 'Awesome Inc.'
  }
})

The name is a special property which is used in the PostHog UI for the name of the group. If you don't specify a name property, the group ID will be used instead.

If the optional distinct_id is not provided in the group identify call, it defaults to $#{group_type}_#{group_key} (e.g., $company_company_id_in_your_db in the example above). This default behavior will result in each group appearing as a separate person in PostHog. To avoid this, it's often more practical to use a consistent distinct_id, such as group_identifier.

Exception capture

You can capture exceptions using the posthog-ruby library. This enables you to see stack traces and debug errors in your application. Learn more in our error tracking docs (/docs/error-tracking/installation/ruby.md).

Using Rails?

The posthog-rails (/docs/libraries/ruby-on-rails.md) gem provides automatic exception capture, ActiveJob instrumentation, and user context out of the box. See our Rails error tracking guide (/docs/error-tracking/installation/ruby-on-rails.md) for details.

For non-Rails Ruby applications, you can manually capture exceptions with capture_exception:

Ruby

begin
  # Code that might raise an exception
  raise StandardError, 'Something went wrong'
rescue => e
  posthog.capture_exception(
    e,
    'user_distinct_id',
    {
      custom_property: 'custom_value'
    }
  )
end

The capture_exception method accepts the following parameters:

Parameter Type Description
exception Exception, String, or exception-like object The exception to capture. Required.
distinct_id String The distinct ID of the user. Optional; request context can provide a default, otherwise the SDK generates a UUID.
additional_properties Hash Additional properties to attach to the exception event. Optional.
flags PostHog::FeatureFlagEvaluations Optional keyword argument. Adds the same feature flag properties as capture({ flags: flags }).

You can also override the fingerprint (/docs/error-tracking/fingerprints.md) to customize how exceptions are grouped into issues:

Ruby

posthog.capture_exception(
  e,
  'user_distinct_id',
  {
    '$exception_fingerprint': 'CustomExceptionGroup'
  }
)

Debug mode

The Ruby SDK logs warnings by default. You can change the log level to DEBUG to debug the client:

Ruby

posthog.logger.level = Logger::DEBUG

You can also replace the SDK logger globally:

Ruby

PostHog::Logging.logger = Rails.logger

Test helpers

When test_mode: true, events remain queued. You can inspect and clear the queue in tests:

Ruby

posthog = PostHog::Client.new({ api_key: '<ph_project_token>', test_mode: true })
posthog.capture({ distinct_id: 'user_123', event: 'test_event' })

posthog.queued_messages
posthog.dequeue_last_message
posthog.clear

Thank you

This library is largely based on the analytics-ruby package.

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/svelte.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Svelte

PostHog makes it easy to get data about traffic and usage of your Svelte app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.

This guide walks you through integrating PostHog into your SvelteKit app using the JavaScript Web (/docs/libraries/js.md) and Node.js (/docs/libraries/node.md) SDKs.

Beta: integration via LLM

Install PostHog for Svelte in seconds with our wizard by running this prompt with LLM coding agents (/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.

npx @posthog/wizard

Learn more (/wizard.md)

Or, to integrate manually, continue with the rest of this guide.

Client-side setup

Install posthog-js using your package manager:

npm
npm install --save posthog-js
Yarn
yarn add posthog-js
pnpm
pnpm add posthog-js
Bun
bun add posthog-js

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Then, if you haven't created a root layout already, create a new file called +layout.js in your src/routes folder In this file, check the environment is the browser, and initialize PostHog if so. You can get both your API key and instance address in your project settings.

routes/+layout.js

import posthog from 'posthog-js'
import { browser } from '$app/environment';

export const load = async () => {
  if (browser) {
    posthog.init('<ph_project_token>', {
      api_host: 'https://us.i.posthog.com',
      defaults: '2026-05-30',
    })
  }

  return
};

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

❗️ If you intend on using session replays with a server-side rendered Svelte app ensure that your asset URLs are configured to be relative (/docs/session-replay/troubleshooting.md#ensure-assets-are-imported-from-the-base-URL-in-Svelte).

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Server-side setup

Install posthog-node using your package manager:

npm
npm install posthog-node --save
Yarn
yarn add posthog-node
pnpm
pnpm add posthog-node
Bun
bun add posthog-node

Then, initialize the PostHog Node client where you'd like to use it on the server side. For example, in a load function:

routes/+page.server.js

import { PostHog } from 'posthog-node';

export async function load() {
  const posthog = new PostHog('<ph_project_token>', { host: 'https://us.i.posthog.com' });

  posthog.capture({
    distinctId: 'distinct_id_of_the_user',
    event: 'event_name',
  })

  await posthog.shutdown()
}

Note: Make sure to always call posthog.shutdown() after capturing events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.

Feature flags

To use client-side feature flags, import PostHog into your Svelte component and check if the feature is enabled (while ensuring the code only runs in the browser).

routes/+page.svelte

<script>
  import posthog from 'posthog-js'
  import { browser } from '$app/environment'
  import { onMount } from 'svelte'

  let coolFeature = $state(false)

  onMount(() => {
    if (browser) {
      coolFeature = posthog.isFeatureEnabled('cool-feature')
    }
  })
</script>

{#if coolFeature}
  <p>Welcome to the cool feature!</p>
{/if}

To use server-side feature flags, import PostHog into your SvelteKit load function and check if the feature is enabled.

routes/+page.server.js

import { PostHog } from 'posthog-node';

const client = new PostHog(
  '<ph_project_token>',
  { host: 'https://us.i.posthog.com' }
);

export async function load() {
  const distinctId = 'distinct_id_of_the_user';

  const megaFeature = await client.isFeatureEnabled(
    'mega-feature',
    distinctId
  );

  return {
    megaFeature
  };
}

See our JavaScript Web (/docs/libraries/js/usage.md#feature-flags) and Node (/docs/libraries/node.md#feature-flags) docs for more details.

Configuring session replay for server-side rendered apps

By default, Svelte uses relative asset paths during server-side rending. This causes issues with PostHog's ability to record sessions.

To fix this, set the config to not use relative paths in svelte.config.js:

JavaScript

kit: {
     paths: {
         relative: false,
     },
 },

Next steps

For any technical questions for how to integrate specific PostHog features into Svelte (such as analytics, feature flags, A/B testing, surveys, etc.), have a look at our JavaScript Web (/docs/libraries/js/usage.md) and Node (/docs/libraries/node.md) SDK docs.

Alternatively, the following tutorials can help you get started:

  • How to set up Svelte analytics, feature flags, and more (/tutorials/svelte-analytics.md)
  • How to set up A/B tests in Svelte (/tutorials/svelte-ab-tests.md)
  • How to set up surveys in Svelte (/tutorials/svelte-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/tanstack-start.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

TanStack Start

This tutorial shows how to integrate PostHog with a TanStack Start app for both client-side and server-side analytics.

Installation

Install the required packages:

Terminal

npm install @posthog/react posthog-node
  • @posthog/react - React package for our JS Web SDK (/docs/libraries/js.md) for client-side usage
  • posthog-node - PostHog Node.js SDK (/docs/libraries/node.md) for server-side event capture

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Initialize PostHog on the client

Wrap your app with PostHogProvider in your root route with your project token, host, and other options.

import CspAllowancesCallout from "../_snippets/csp-allowances-callout.mdx"

<CspAllowancesCallout />tsx file=src/routes/__root.tsx
// src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { PostHogProvider } from '@posthog/react'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
    ],
  }),
  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <PostHogProvider
          apiKey="<ph_project_token>"
          options={{
            api_host: 'https://us.i.posthog.com',
            defaults: '2026-05-30',
            capture_exceptions: true
          }}
        >
          {children}
        </PostHogProvider>
        <Scripts />
      </body>
    </html>
  )
}

Once the provider is in place, PostHog automatically captures pageviews, sessions, and web vitals.

Capture events on the client

Use the usePostHog hook from @posthog/react in any component to capture custom events:

src/routes/checkout.tsx

import { usePostHog } from '@posthog/react'

function CheckoutButton({ orderId, total }: { orderId: string; total: number }) {
  const posthog = usePostHog()

  const handleClick = () => {
    posthog.capture('checkout_started', {
      order_id: orderId,
      total: total,
    })
  }

  return <button onClick={handleClick}>Checkout</button>
}
Identify users

Call posthog.identify() when a user logs in to link their events to a user ID:

TSX

import { usePostHog } from '@posthog/react'

function LoginForm() {
  const posthog = usePostHog()

  const handleLogin = async (userId: string, email: string) => {
    // ... your login logic

    posthog.identify(userId, {
      email: email,
    })

    posthog.capture('user_logged_in')
  }
}

Call posthog.reset() on logout to clear the identified user.

Initialize PostHog on the server

Create a server-side PostHog client using posthog-node. Use a singleton pattern so you reuse the same client across requests:

src/utils/posthog-server.ts

// src/utils/posthog-server.ts
import { PostHog } from 'posthog-node'

let posthogClient: PostHog | null = null

export function getPostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(
      '<ph_project_token>',
      {
        host: 'https://us.i.posthog.com',
        flushAt: 1,
        flushInterval: 0,
      },
    )
  }
  return posthogClient
}

Capture events on the server

Use the server client in TanStack Start API routes to capture events server-side. Server-side capture is useful for tracking events that shouldn't be spoofable from the client, like purchases or authentication:

src/routes/api/checkout.ts

// src/routes/api/checkout.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getPostHogClient } from '../../utils/posthog-server'

export const Route = createFileRoute('/api/checkout')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const body = await request.json()

        const posthog = getPostHogClient()

        posthog.capture({
          distinctId: body.userId,
          event: 'item_purchased',
          properties: {
            item_id: body.itemId,
            price: body.price,
            source: 'api',
          },
        })

        return json({ success: true })
      },
    },
  },
})

The server-side capture call requires a distinctId (the user identifier), an event name, and optional properties.

Next steps

Installing the JS Web SDK and Node SDK means all of their functionality is available in your TanStack Start project. To learn more about this, have a look at our JS Web SDK docs (/docs/libraries/js/usage.md) and Node SDK docs (/docs/libraries/node.md).

Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/usage.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

iOS SDK usage

Capturing events

You can send custom events using capture:

Swift

PostHogSDK.shared.capture("user_signed_up")

Tip: We recommend using a [object] [verb] format for your event names, where [object] is the entity that the behavior relates to, and [verb] is the behavior itself. For example, project created, user signed up, or invite sent.

Setting event properties

Optionally, you can include additional information with the event by including a properties (/docs/data/events.md#event-properties) object:

Swift

PostHogSDK.shared.capture("user_signed_up", properties: ["login_type": "email"], userProperties: ["is_free_trial": true])

Autocapture

PostHog autocapture automatically tracks the following events for you:

  • Application Opened – when the app is opened from a closed state or when the app comes to the foreground (e.g. from the app switcher)
  • Application Backgrounded – when the app is sent to the background by the user
  • Application Installed – when the app is installed
  • Application Updated – when the app is updated
  • $screen – when the user navigates (if using UIViewController)
  • $autocapture – when the user interacts with elements in a screen (UIKit based) and captureElementInteractions is enabled
  • $rageclick – when the user rapidly taps in the same area (iOS/macCatalyst, UIKit based)

🚧 Note: $autocapture and $rageclick are captured from UIKit interactions. Some SwiftUI views use UIKit under the hood (for example, TextField → UITextField and Toggle → UISwitch), so those interactions may also be autocaptured. In other SwiftUI cases, interactions might still be captured, but element metadata (such as $elements_chain) may be incomplete.

Capturing screen views

With configuration.captureScreenViews (/docs/libraries/ios/configuration.md#all-configuration-options) set as true, PostHog will try to record all screen changes automatically.

If you want to manually send a new screen capture event, use the screen function.

Swift

PostHogSDK.shared.screen("Dashboard", properties: ["fromIcon": "bottom"])

Important: While captureScreenViews works with both UIKit and SwiftUI, the screen names captured in SwiftUI may not be very meaningful as they are based on internal SwiftUI view identifiers. For SwiftUI applications, we recommend turning this option off and instead using the .postHogScreenView() view modifier (see next section) to capture screen views with meaningful names.

Note: You can use the BeforeSendBlock to filter or drop any undesired screen events, giving you control over which screen views are sent to PostHog. See Amending, dropping or sampling events (/docs/libraries/ios.md#amending-dropping-or-sampling-events) for implementation examples.

Capturing screen views in SwiftUI

To track a screen view in SwiftUI, apply the postHogScreenView modifier to your full-screen views. PostHog will send a $screen event when the onAppear action is executed and will infer a screen name based on the view's type. You can provide a custom name and event properties if needed.

HomeView.swift

// This will trigger a screen view event with $screen_name: "HomeViewContent"
struct HomeView: View {
    var body: some View {
        HomeViewContent()
            .postHogScreenView()
    }
}

// This will trigger a screen view event with $screen_name: "My Home View" and an additional event property from_button: "start"
struct HomeView: View {
    var body: some View {
        HomeViewContent()
            .postHogScreenView("My Home View", ["from_button": "start"])
    }
}

In SwiftUI, views can range from entire screens to small UI components. Unlike UIKit, SwiftUI doesn't clearly distinguish between these levels, which makes automatic tracking of full-screen views harder.

Adding a custom label on autocaptured elements

PostHog automatically captures interactions with various UI elements in your app, but these interactions are often identified by element type names (e.g., UIButton, UITextField, UILabel).

While this provides basic tracking, it can be challenging to pinpoint specific interactions with particular elements in your analytics. To make your data more meaningful and actionable, you can assign custom labels to any autocaptured element. These labels act as descriptive identifiers, making it easier to identify, filter, and analyze events in your reports.

Adding a custom label in UIKit

To assign a custom label to a UIView, use the postHogLabel property:

Swift

let view = UIView()
view.postHogLabel = "usernameTextField"

In this example, interactions with the UITextField will be captured with an additional identifier "usernameTextField".

Adding a custom label in SwiftUI

In SwiftUI, use the .postHogLabel(_:) modifier instead:

Swift

var body: some View {
    ...
    TextField("username", text: $username)
        .postHogLabel("usernameTextField")
}

Since SwiftUI's TextField uses UITextField under the hood, interactions with it will be autocaptured with the additional identifier "usernameTextField".

Example of generated analytics data

The generated analytics element in the examples above will have the following form:

Swift

<UITextField id="usernameTextField">text value</UITextField>

Filtering for labeled autocaptured elements in reports

To locate and filter interactions with specific elements in PostHog reports, you can use Autocapture element filters, such as:

  • Tag Name (UITextField in this example)
  • Text (text value in this example)
  • CSS Selector (the generated id attribute in this example)

In the examples above, we can filter for the specific text field using the CSS Selector #usernameTextField

Interaction autocapture

Interaction autocapture records when users interact with UI elements in your app. This includes:

  • User interactions like touch, swipe, pan, pinch, rotation, long_press, scroll
  • Control types value_changed, submit, toggle, primary_action, menu_action, change

Interaction autocapture is not enabled by default. You can enable it by setting captureElementInteractions to true in the config.

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.captureElementInteractions = true // Disabled by default
PostHogSDK.shared.setup(config)
Rage click autocapture

Note: Rage click autocapture for iOS/macCatalyst is available in version 3.51.0+.

A rage click is when a user taps an area multiple times in quick succession (e.g more than 3 taps in 1 second).

This is captured as a $rageclick event. You can use this event to identify opportunities to improve your UI, since it's a good indication that users may be frustrated with your product.

It is enabled by default (rageClickConfig.enabled = true).

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.rageClickConfig.enabled = true // Enabled by default
config.rageClickConfig.minimumTapCount = 3 // Optional, default is 3
config.rageClickConfig.thresholdPoints = 30 // Optional, default is 30
config.rageClickConfig.timeoutInterval = 1.0 // Optional, default is 1.0s
PostHogSDK.shared.setup(config)
Autocapture configuration

You can enable or disable autocapture through the PostHogConfig object. Find more details about autocapture configuration in the configuration page (/docs/libraries/ios/configuration.md#autocapture-configuration).

Preventing sensitive data capture

To exclude specific UI elements from autocapture or Session Replay, add ph-no-capture as either an accessibilityLabel or accessibilityIdentifier. See privacy controls (/docs/session-replay/privacy?tab=iOS.md) for masking behavior and iOS examples.

Identifying users

We highly recommend reading our section on Identifying users (/docs/integrate/identifying-users.md) to better understand how to correctly use this method.

Using identify, you can associate events with specific users. This enables you to gain full insights as to how they're using your product across different sessions, devices, and platforms.

An identify call has the following arguments:

  • distinct_id which uniquely identifies your user in your database

  • userProperties: Optional. A dictionary with key:value pairs to set the person properties (/docs/product-analytics/person-properties.md)

  • userPropertiesSetOnce: Optional. Similar to userProperties. See the difference between userProperties and userPropertiesSetOnce (/docs/product-analytics/person-properties.md#what-is-the-difference-between-set-and-set_once)

Swift

PostHogSDK.shared.identify("user_id_from_your_database",
                            userProperties: ["name": "Peter Griffin", "email": "peter@familyguy.com"],
                            userPropertiesSetOnce: ["date_of_first_log_in": "2024-03-01"])

You should call identify as soon as you're able to. Typically, this is after your user logs in. This ensures that events sent during your user's sessions are correctly associated with them.

When you call identify, all previously tracked anonymous events will be linked to the user.

Get the current user's distinct ID

You may find it helpful to get the current user's distinct ID. For example, to check whether you've already called identify for a user or not.

To do this, call getDistinctId(). This returns either the ID automatically generated by PostHog or the ID that has been passed by a call to identify().

Alias

Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.

In this case, you can use alias to assign another distinct ID to the same user.

Swift

PostHogSDK.shared.alias("alias_id")

We strongly recommend reading our docs on alias (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.

Anonymous vs identified events

PostHog captures two types of events: anonymous and identified (/docs/data/anonymous-vs-identified-events.md)

Identified events enable you to attribute events to specific users, and attach person properties (/docs/product-analytics/person-properties.md). They're best suited for logged-in users.

Scenarios where you want to capture identified events are:

  • Tracking logged-in users in B2B and B2C SaaS apps
  • Doing user segmented product analysis
  • Growth and marketing teams wanting to analyze the complete conversion lifecycle

Anonymous events are events without individually identifiable data. They're best suited for web analytics (/docs/web-analytics.md) or apps where users aren't logged in.

Scenarios where you want to capture anonymous events are:

  • Tracking a marketing website
  • Content-focused sites
  • B2C apps where users don't sign up or log in

Under the hood, the key difference between identified and anonymous events is that for identified events we create a person profile (/docs/data/persons.md) for the user, whereas for anonymous events we do not.

Important: Due to the reduced cost of processing them, anonymous events can be up to 4x cheaper than identified ones, so we recommended you only capture identified events when needed.

How to capture anonymous events

The iOS SDK captures anonymous events by default. However, this may change depending on your personProfiles config (/docs/libraries/ios/configuration.md#all-configuration-options) when initializing PostHog:

  1. personProfiles: .identifiedOnly (recommended) (default) - Anonymous events are captured by default. PostHog only captures identified events for users where person profiles (/docs/data/persons.md) have already been created.

  2. personProfiles: .always - Capture identified events for all events.

  3. personProfiles: .never - Capture anonymous events for all events.

For example:

iOS

let config = PostHogConfig(
    projectToken: POSTHOG_PROJECT_TOKEN,
    host: POSTHOG_HOST
)
config.personProfiles = .identifiedOnly
PostHogSDK.shared.setup(config)
How to capture identified events

If you've set the personProfiles config (/docs/libraries/ios/configuration.md#all-configuration-options) to .identifiedOnly (the default option), anonymous events are captured by default. Then, to capture identified events, call any of the following functions:

  • identify() (/docs/product-analytics/identify.md)
  • alias() (/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user)
  • group() (/docs/product-analytics/group-analytics.md)

When you call any of these functions, it creates a person profile (/docs/data/persons.md) for the user. Once this profile is created, all subsequent events for this user will be captured as identified events.

Alternatively, you can set personProfiles to .always to capture identified events by default.

Setting person properties

To set properties (/docs/product-analytics/person-properties.md) on your users via an event, you can leverage the event properties userProperties and userPropertiesSetOnce.

When capturing an event, you can pass a property called $set as an event property, and specify its value to be an object with properties to be set on the user that will be associated with the user who triggered the event.

Swift

PostHogSDK.shared.capture("signed_up", properties: ["plan": "Pro++"], userProperties: ["user_property_name": "your_value"])

userPropertiesSetOnce works just like userProperties, except that it will only set the property if the user doesn't already have that property set.

Swift

PostHogSDK.shared.capture("signed_up", properties: ["plan": "Pro++"], userPropertiesSetOnce: ["user_property_name": "your_value"])

Use setPersonProperties when you want to update the current person's profile without also capturing a custom event. This sends a $set event to PostHog.

Swift

PostHogSDK.shared.setPersonProperties(userPropertiesToSet: ["plan": "Pro++"])

PostHogSDK.shared.setPersonProperties(
    userPropertiesToSet: ["plan": "Pro++"],
    userPropertiesToSetOnce: ["first_seen_source": "ios"]
)

Super properties

Super properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, or anything else.

They are set using PostHogSDK.shared.register, which takes a properties object as a parameter, and they persist across sessions.

For example, take a look at the following call:

Swift

PostHogSDK.shared.register(["team_id": 22])

The call above ensures that every event sent by the user will include "team_id": 22. This way, if you filtered events by property using team_id = 22, it would display all events captured on that user after the PostHogSDK.shared.register call, since they all include the specified Super Property.

However, please note that this does not store properties against the User, only against their events. To store properties against the User object, you should use PostHogSDK.shared.identify. More information on this can be found on the Sending User Information section (#sending-user-information).

Removing stored super properties

Super properties persist across sessions so you have to explicitly remove them if they are no longer relevant. To stop sending a super property with events, you can use PostHogSDK.shared.unregister, like so:

Swift

PostHogSDK.shared.unregister("team_id")

This removes the super property and subsequent events will not include it.

If you are doing this as part of a user logging out, you can instead simply use PostHogSDK.shared.reset which clears all super properties and more.

Reset after logout

To reset the user's ID and anonymous ID after logout, call reset. See Identifying users (/docs/product-analytics/identify.md#reset) for the shared reset guidance and iOS example.

Group analytics

Group analytics allows you to associate the events for that person's session with a group (e.g. teams, organizations, etc.). See Group Analytics (/docs/product-analytics/group-analytics.md) for iOS examples and implementation details.

Note: This is a paid feature and is not available on the open-source or free cloud plan. Learn more on the pricing page (/pricing.md).

Opt out of data capture

You can completely opt users out from data capture by default or on a per-person basis. See Complete opt-out (/docs/product-analytics/privacy.md#complete-opt-out) for iOS examples.

Feature flags

PostHog's feature flags (/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.

Boolean feature flags

Swift

if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"), result.enabled {
    // Do something differently for this user

    // Optional: fetch the payload from the same evaluation result
    let matchedFlagPayload = result.payload
}
Multivariate feature flags

Swift

if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"), result.variant == "variant-key" { // replace "variant-key" with the key of your variant
    // Do something differently for this user

    // Optional: fetch the payload from the same evaluation result
    let matchedFlagPayload = result.payload
}
Typed payloads

If your payload is a JSON object, you can decode it into a Decodable type:

Swift

struct FlagPayload: Decodable {
    let title: String
}

if let result = PostHogSDK.shared.getFeatureFlagResult("flag-key"),
   let payload = result.payloadAs(FlagPayload.self) {
    // Use payload.title
}
Inspecting all feature flags

You can inspect all currently loaded feature flags with getAllFeatureFlags(). It returns each flag's key, enabled state, variant, and payload, and does not send a $feature_flag_called event, so calling it won't affect your experiment results or flag usage analytics:

Swift

for flag in PostHogSDK.shared.getAllFeatureFlags() ?? [] {
    print(flag.key, flag.enabled, flag.variant as Any, flag.payload as Any)
}
Reloading feature flags

Feature flag values are cached. If something has changed with your user and you'd like to refetch their flag values, call:

Swift

PostHogSDK.shared.reloadFeatureFlags()
Ensuring flags are loaded before usage

Every time a user opens the app, we send a request in the background to fetch the feature flags that apply to that user. We store those flags in the storage.

This means that for most screens, the feature flags are available immediately – except for the first time a user visits.

To handle this, you can use the didReceiveFeatureFlags notification to wait for the feature flag request to finish:

Swift

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        // register for `didReceiveFeatureFlags` notification before SDK initialization
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(receiveFeatureFlags),
            name: PostHogSDK.didReceiveFeatureFlags,
            object: nil
        )

        let POSTHOG_PROJECT_TOKEN = "<ph_project_token>"
        // usually 'https://us.i.posthog.com' or 'https://eu.i.posthog.com'
        let POSTHOG_HOST = "https://us.i.posthog.com"

        let config = PostHogConfig(projectToken: POSTHOG_PROJECT_TOKEN, host: POSTHOG_HOST)

        PostHogSDK.shared.setup(config)

        return true
    }

    // The "receiveFeatureFlags" method will be called when the SDK receives the feature flags from the server.
    @objc func receiveFeatureFlags() {
        print("receiveFeatureFlags called")
    }
}

Alternatively, you can use the completion block of the reloadFeatureFlags(_:) method. This allows you to execute logic immediately after the flags are reloaded:

Swift

// Reload feature flags and check if a specific feature is enabled
PostHogSDK.shared.reloadFeatureFlags {
    if PostHogSDK.shared.isFeatureEnabled("flag-key") {
        // do something
    }
}
Tracking feature usage

To track when someone sees or interacts with a feature, use captureFeatureView and captureFeatureInteraction.

Swift

PostHogSDK.shared.captureFeatureView(flag: "flag-key", flagVariant: "variant-key")
PostHogSDK.shared.captureFeatureInteraction(flag: "flag-key", flagVariant: "variant-key")
Bootstrapping flags

Since there is a delay between initializing PostHog and fetching feature flags, feature flags are not always available immediately. This makes them unusable if you want to do something like redirecting a user to a different page based on a feature flag.

To have your feature flags available immediately, you can initialize PostHog with precomputed values until it has had a chance to fetch them. This is called bootstrapping. After the SDK fetches feature flags from PostHog, it will use those flag values instead of bootstrapped ones.

Set config.bootstrap before calling setup() to seed identity and flag values before the first /flags response (requires iOS SDK 3.66.0+):

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.bootstrap = PostHogBootstrapConfig(
    distinctId: "distinct_id_of_your_user",
    isIdentifiedId: true,
    featureFlags: [
        "flag-1": true,
        "variant-flag": "control"
    ],
    featureFlagPayloads: nil
)
PostHogSDK.shared.setup(config)
  • Bootstrapped identity applies during setup. On a fresh install, setting it before setup() means events captured synchronously during initialization (like Application Installed) carry your distinct ID instead of the SDK-generated UUID.
    • An anonymous bootstrap (isIdentifiedId: false, the default) seeds the anonymous ID only when none is persisted yet. Once an anonymous ID exists on disk, or the person has been identified, the SDK ignores it.
    • An identified bootstrap (isIdentifiedId: true) is for a signed-in identity available to your app (for example, from a backend session token). On a fresh install, it seeds the distinct ID, marks the person identified, and generates a separate device ID. On a returning install, a matching anonymous ID is marked identified without emitting $identify; a different anonymous ID is merged via identify() when person profiles are enabled. This emits $identify unless capturing is opted out. A different, already-identified person is left untouched.
  • Bootstrapped flags are served until the first /flags response, then replaced. A complete /flags response takes over entirely, so bootstrapped-only keys don't persist past it. Only enabled flags are seeded: a true boolean or a non-empty variant string. A false or empty value is dropped, matching posthog-js. Seed payloads with the separate featureFlagPayloads option. Flag values and payloads must be JSON-serializable, or they're dropped. Bootstrapped flags are cleared on reset().

The feature-flags-loaded signal fires as soon as bootstrapped flags are applied, so startup logic can read them immediately. These SDKs don't support the sessionID bootstrap option. When person profiles are set to never, the SDK preserves a different anonymous identity instead of merging it into an identified bootstrap.

See the SDK bootstrapping guide (/docs/libraries/bootstrapping.md) for the cross-SDK overview.

Experiments (A/B tests)

Since experiments (/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code. See adding experiment code (/docs/experiments/adding-experiment-code.md) for iOS examples.

It's also possible to run experiments without using feature flags (/docs/experiments/running-experiments-without-feature-flags.md).

A note about IDFA (identifier for advertisers) collection in iOS 14

Starting with iOS 14, Apple will further restrict apps that track users. Any references to Apple's AdSupport framework, even in strings, will trip the App Store's static analysis.

Hence starting with posthog-ios version 1.2.0 we have removed all references to Apple's AdSupport framework.

Session replay

Note: Session replay is currently only available on iOS. For future macOS support, please follow and upvote this GitHub issue.

To set up session replay (/docs/session-replay/mobile.md) in your project, all you need to do is install the iOS SDK, enable "Record user sessions" in your project settings and enable the sessionReplay option.

Surveys

Surveys (/docs/surveys.md) launched with popover presentation (/docs/surveys/creating-surveys.md#presentation) are automatically shown to users matching the display conditions (/docs/surveys/creating-surveys.md#display-conditions) you set up.

Error tracking

To set up error tracking in your project, see the error tracking docs (/docs/error-tracking.md).

Debug mode

If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.

You can enable debug mode by setting the debug option to true in the PostHogConfig object. A common pattern is to set this to true in development environments only for local development.

Swift

let config = PostHogConfig(projectToken: "<ph_project_token>", host: "https://us.i.posthog.com")
config.debug = true
PostHogSDK.shared.setup(config)

This will enable verbose logs about the inner workings of the SDK.

You can also toggle debug by calling the PostHogSDK.shared.debug() method in your code.

Swift

// Enable debug mode
PostHogSDK.shared.debug(true)

// Disable debug mode
PostHogSDK.shared.debug(false)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

references/vue-js.md

AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

Vue.js

PostHog makes it easy to get data about usage of your Vue.js app. Integrating PostHog into your app enables analytics about user behavior, custom events capture, session replays, feature flags, and more.

This guide walks you through integrating PostHog into your app for both Vue 2 and Vue 3. We'll use the JavaScript Web SDK (/docs/libraries/js.md) for this.

For integrating PostHog into a Nuxt.js app, see our Nuxt guide (/docs/libraries/nuxt-js.md).

Prerequisites

To follow this guide along, you need:

  1. A PostHog account
  2. A running Vue.js app

Setting up PostHog

Start by installing posthog-js using your package manager:

npm
npm install --save posthog-js
Yarn
yarn add posthog-js
pnpm
pnpm add posthog-js
Bun
bun add posthog-js

If your site sets a Content-Security-Policy, it needs to allow PostHog. This applies to the snippet and to package installs alike: the SDK lazy-loads extra bundles (session replay, surveys) from PostHog's CDN, and sends events to the ingestion host. PostHog serves from subdomains of posthog.com that change over time, so allow the wildcard:

script-src 'self' https://*.posthog.com;
connect-src 'self' https://*.posthog.com;
worker-src 'self' blob: data:;

script-src covers the snippet and the lazy-loaded bundles, connect-src covers event ingestion and feature flags, and worker-src covers session replay. The toolbar needs a few more (/docs/advanced/content-security-policy.md), or use a reverse proxy (/docs/advanced/proxy.md) so everything is first-party. Failing to do so causes silent failures where capture and identify calls never send, so the integration looks complete while zero events arrive. Remember connect-src falls back to default-src, so default-src 'self' blocks event delivery even when the script itself is bundled.

Next, depending on your Vue version, we recommend initializing PostHog using the composition API or as a plugin.

Vue 3: Composition API

We use the Composition API as it provides better accessibility, maintainability, and type safety.

PostHog initializes as a singleton, so you can initialize it in your main.ts file before you mount your app. This ensures PostHog is initialized before any other code runs.

src/main.ts

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'
import posthog from "posthog-js";

const app = createApp(App);

posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_TOKEN || '<ph_project_token>', {
  api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
  defaults: '2026-05-30',
});

app.use(createPinia())
app.use(router)

app.config.errorHandler = (err, instance, info) => {
  posthog.captureException(err)
}

app.mount('#app')

Then, you can access PostHog throughout your app just by importing it from posthog-js.

TypeScript

// src/App.vue
<script setup>
import posthog from 'posthog-js'

const handleClick = () => {
  posthog.capture('button_clicked')
}
</script>

Once done, PostHog will begin autocapturing (/docs/product-analytics/autocapture.md) events and pageviews (if enabled) and is ready to use throughout your app.

Vue 2: Plugins

Start by creating a plugins folder and adding a posthog.js file to that folder. In posthog.js, initialize PostHog using the install method with your project token and host. You can find these in your project settings.

JavaScript

// src/plugins/posthog.js
import posthog from 'posthog-js'

export default {
  install(Vue) {
    posthog.init('<ph_project_token>', {
      api_host: 'https://us.i.posthog.com',
      defaults: '2026-05-30'
    })

    Vue.prototype.$posthog = posthog
  }
}

Next, in main.js, import and use the plugin.

JavaScript

// src/main.js
import Vue from 'vue'
import App from './App.vue'
import PosthogPlugin from './plugins/posthog'

Vue.config.productionTip = false
Vue.use(PosthogPlugin)

new Vue({
  render: h => h(App),
}).$mount('#app')

This makes PostHog available as this.$posthog in any Vue component.

Identifying users

Identifying users is required. Call posthog.identify('your-user-id') after login to link events to a known user. This is what connects frontend event captures, session replays (/docs/session-replay.md), LLM traces (/docs/ai-engineering.md), and error tracking (/docs/error-tracking.md) to the same person — and lets backend events link back too.

Use a stable ID from your auth system when possible, not an email or display name. Send those as person properties instead. If your app has no other stable key, email works as a fallback if they are unique. Never a shared literal like "anonymous" or "user", which pools many people onto one person and corrupts their data. When no ID is available at all, skip the identify and retain the anonymous distinct ID that's automatically assigned.

Call posthog.reset() on logout, so the next person to use the browser doesn't inherit the last one's identity.

See our guide on identifying users (/docs/getting-started/identify-users.md) for how to set this up.

If your app calls your own backend, tracing_headers adds X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID to matching fetch and XMLHttpRequest requests. This lets server-side SDKs link backend events, errors, and LLM traces back to frontend sessions and replays. Use hostnames only, without protocols or paths.

JavaScript

posthog.init('<ph_project_token>', {
  api_host: 'https://us.i.posthog.com',
  // Optional: send PostHog session/user context to your backend
  tracing_headers: ['api.example.com'],
})

This works in local development too, but match on the hostname alone: use 'localhost', not 'localhost:3000'. Ports are never part of a hostname, so a value with one in it never matches anything. localhost and 127.0.0.1 are also different hostnames — use whichever your app actually calls.

Tracing headers help you attribute events across front and backend consistently. When this isn't available, use your server-side stable IDs to deduce the matching distinctId, and pass it in when capturing the event.

Capturing custom events, using feature flags, and more

Once you have PostHog initialized, there is a lot more you can do with it beyond autocapture, pageviews, and pageleaves. You can find the full details in our JavaScript SDK docs (/docs/libraries/js/usage.md), but we'll cover a few examples here.

Vue 3: Composition API

To capture custom events, evaluate feature flags, and use any of the other PostHog features, you can use the posthog object returned from the usePostHog composable like this:

JavaScript

// src/App.vue
<script setup>
import { RouterView } from 'vue-router'
import { usePostHog } from './composables/usePostHog'

const { posthog } = usePostHog()

const handleClick = () => {
  posthog.capture('button_clicked', { location: 'homepage' })
}
</script>

<template>
  <div>
    <button @click="handleClick">Click me!</button>
  </div>

  <RouterView />
</template>
Feature flags with reactive updates

When using feature flags on pages that users navigate to directly, the flags may not be loaded when the component first renders. To ensure your UI updates reactively when flags load, create a composable that returns a reactive ref:

JavaScript

// src/composables/usePostHogFeatureFlag.ts
import { ref, type Ref } from 'vue'
import { usePostHog } from './usePostHog'

export function usePostHogFeatureFlag(
  feature: string,
): Ref<string | boolean | undefined> {
  const { posthog } = usePostHog()
  const flag = ref(posthog.getFeatureFlag(feature))

  posthog.onFeatureFlags(() => {
    flag.value = posthog.getFeatureFlag(feature)
  })

  return flag
}

Then use it in your components:

JavaScript

// src/App.vue
<script setup>
import { RouterView } from 'vue-router'
import { usePostHog } from './composables/usePostHog'
import { usePostHogFeatureFlag } from './composables/usePostHogFeatureFlag'

const { posthog } = usePostHog()
const isFeatureEnabled = usePostHogFeatureFlag('test-flag')

const handleClick = () => {
  posthog.capture('button_clicked', { location: 'homepage' })
}
</script>

<template>
  <div>
    <button @click="handleClick">Click me!</button>
    <p>Is feature flag enabled? {{ isFeatureEnabled ? 'Yes' : 'No' }}</p>
  </div>

  <RouterView />
</template>

This ensures your component will automatically update when feature flags load, even if the page is accessed directly.

Vue 2: Plugins

To capture custom events, evaluate feature flags, and use any of the other PostHog features, you can use the $posthog object returned from the plugin like this:

JavaScript

// src/components/AboutPage.vue
<template>
  <div class="about">
    <h1>About Page</h1>
    <button @click="handleClick">Test PostHog</button>
    <router-link to="/">Go to Home</router-link>
    <p>Feature enabled? {{ isFeatureEnabled ? 'Yes' : 'No' }}</p>
  </div>
</template>

<script>
export default {
  name: 'AboutPage',
  data() {
    return {
      isFeatureEnabled: false
    }
  },
  created() {
    this.isFeatureEnabled = this.$posthog.isFeatureEnabled('test-flag')
  },
  methods: {
    handleClick() {
      this.$posthog.capture('button_clicked')
    }
  }
}
</script>

Set up a reverse proxy (recommended)

We recommend setting up a reverse proxy (/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.

We have our own managed reverse proxy service (/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.

If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using Cloudflare (/docs/advanced/proxy/cloudflare.md), AWS Cloudfront (/docs/advanced/proxy/cloudfront.md), and Vercel (/docs/advanced/proxy/vercel.md).

Grouping products in one project (recommended)

If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and group them in one project (/docs/settings/projects.md).

This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.

Add IPs to Firewall/WAF allowlists (recommended)

For certain features like heatmaps (/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog's requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.

EU: 3.75.65.221, 18.197.246.42, 3.120.223.253

US: 44.205.89.55, 52.4.194.122, 44.208.188.173

These are public, stable IPs used by PostHog services.

PostHog captures heatmap screenshots using Browserless, which has its own IP addresses. Browserless publishes the current list here.

An allowlist does not help when your app has a private address. For apps on an internal network, see internal and intranet applications (/docs/session-replay/troubleshooting.md#internal-and-intranet-applications).

Next steps

For any technical questions for how to integrate specific PostHog features into Vue (such as analytics, feature flags, A/B testing, or surveys), have a look at our JavaScript Web (/docs/libraries/js/usage.md) SDK docs.

Alternatively, the following tutorials can help you get started:

  • How to set up analytics in Vue (/tutorials/vue-analytics.md)
  • How to set up feature flags in Vue (/tutorials/vue-feature-flags.md)
  • How to set up A/B tests in Vue (/tutorials/vue-ab-tests.md)
  • How to set up surveys in Vue (/tutorials/vue-surveys.md)
Still have questions?

Ask PostHog AI

Was this page useful?

HelpfulCould be better

Frontmatter written into each target's SKILL.md.

Common

No fields set for this target.

Ready to ship better, together?

Spec it. Decompose it. Ship it. All with your AI agent.

Start for free

Join engineers building with Athenode today.