{
  "$type": "site.standard.document",
  "description": "How I set up production PostHog analytics across frontend and backend apps, including event tracking, user identification, privacy choices, and server events.",
  "path": "/building-production-analytics-with-posthog-a-complete-implementation-guide/",
  "publishedAt": "2025-11-11T02:53:00.000Z",
  "site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
  "tags": [
    "Web",
    "Tools"
  ],
  "textContent": "I needed analytics for my projects, but I wanted something that wouldn't break the bank or require a PhD to set up. I tried a few options, but PostHog ended up being my go-to solution because it combines everything I need in one package—product analytics, session recordings, feature flags, and error tracking.\n\nIt has a generous free tier and you can self-host if you need to. After implementing it across multiple projects, here's how I set up a production analytics system that works well.\n\nMY ARCHITECTURE: FRONTEND AND BACKEND WORKING TOGETHER\n\nI capture events on both frontend and backend:\n\n * Frontend — user interactions, page views, button clicks, form submissions, and client-side errors\n * Backend — payment events, email campaigns, user lifecycle events, and server-side operations\n\nI maintain consistent user identification across both, which gives me a complete picture from initial visit to long-term engagement.\n\nI use Vue for my frontend and Fastify for my backend, but the principles apply to any frontend and backend stack.\n\nENVIRONMENT SETUP\n\nFirst, I set up my environment variables like this:\n\n# Frontend (.env)\nVITE_POSTHOG_TOKEN=phc_your_project_api_key\n\n# Backend (.env)\nPOSTHOG_API_KEY=phc_your_project_api_key\nPOSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key\n\nThe PERSONAL_API_KEY is needed for backend data queries and user management operations.\n\nFRONTEND IMPLEMENTATION\n\nSMART INITIALIZATION\n\nI don't slap PostHog everywhere—it pays to initialize it thoughtfully:\n\nimport posthog from \"posthog-js\"\n\nexport default {\n  install(app: App) {\n    if (!env.VITE_POSTHOG_TOKEN) {\n      return\n    }\n    const storageAvailable = isStorageAvailable()\n    app.config.globalProperties.$posthog = posthog.init(env.VITE_POSTHOG_TOKEN, {\n      api_host: \"https://us.i.posthog.com\",\n      person_profiles: \"identified_only\", // or 'always' to create profiles for anonymous users as well\n      disable_surveys: !storageAvailable,\n      session_recording: {\n        maskAllInputs: false,\n        maskInputOptions: {\n          password: true,\n        },\n      },\n    })\n    if (env.VITE_RENDER_GIT_COMMIT && env.VITE_RENDER_GIT_COMMIT.trim() !== \"\") {\n      posthog.register_for_session({\n        git: env.VITE_RENDER_GIT_COMMIT,\n      })\n      posthog.setPersonProperties({ lastSeen: new Date().toISOString() })\n    }\n  },\n}\n\nA few decisions worth explaining:\n\nI set person_profiles: 'identified_only' so I only track users I can identify -- protects privacy while still getting meaningful data.\n\nI tag each session with the git commit hash, which helps correlate issues with specific deployments. I also set lastSeen here and firstSeen (as a set-once property) elsewhere as soon as I can. My site supports light and dark themes so it's one of the first places PostHog has a chance to run.\n\nI disable surveys when localStorage isn't available. I added this because I suspected it caused errors in production.\n\nSESSION RECORDING WITH PRIVACY\n\nSession recordings are invaluable for understanding user behavior, but I'm careful about privacy:\n\nposthog.startSessionRecording()\n// Password fields automatically masked\n// Check storage availability before enabling\n\nI make sure to always check localStorage availability first—nothing worse than breaking your app because analytics tried to start in an unsupported environment.\n\nBACKEND IMPLEMENTATION\n\nSINGLETON PATTERN\n\nI use a singleton pattern for my backend PostHog client to avoid multiple instances:\n\nimport { PostHog } from 'posthog-node'\n\nlet posthogClient: PostHog | null = null\n\nexport function getPostHogClient(): PostHog | null {\n  if (posthogClient) return posthogClient\n\n  if (!env.POSTHOG_API_KEY) {\n    logger.info(\"PostHog API key not configured\")\n    return null\n  }\n\n  posthogClient = new PostHog(env.POSTHOG_API_KEY, {\n    apiHost: 'https://us.i.posthog.com',\n    flushAt: 20,\n    flushInterval: 10000\n  })\n\n  return posthogClient\n}\n\nThis approach prevents connection issues and ensures consistent configuration across my backend services.\n\nEVENT STRATEGY: QUALITY OVER QUANTITY\n\nCENTRALIZED EVENT MANAGEMENT\n\n// frontend/src/lib/posthog-events.ts\nexport const LOGIN_BLUESKY_EVENT = \"login_bluesky\"\nexport const SCHEDULED_POST_EVENT = \"scheduled_post\"\nexport const SUBSCRIBED_EVENT = \"subscribed\"\n// ... organize all events by category\n\nThis prevents typos, makes refactoring easier, and gives me TypeScript autocomplete.\n\nRICH EVENT PROPERTIES\n\nI always include context with events:\n\nposthog.capture(SCHEDULED_POST_EVENT, {\n  count: promises.length,\n  source: \"follow back cta\",\n  socialNetwork: \"bluesky\",\n  scheduledAt: timestamp\n})\n\nThis makes the data actually useful when I look at it later.\n\nUSER IDENTIFICATION STRATEGY\n\nEMAIL-BASED CONSISTENCY\n\nThe most important thing about analytics is maintaining user identity across devices and platforms. I use a normalized email-based approach:\n\nexport function trackingIdentifierForEmail(email: string): string {\n  return email.toLowerCase().trim()\n}\n\n// Frontend identification\nfunction identifyUserInPosthog(email: string, subscriptionData: any) {\n  const distinctID = trackingIdentifierForEmail(email)\n\n  posthog.identify(distinctID, {\n    email,\n    hasSubscription: subscriptionData.hasSubscription,\n    subscriptionType: subscriptionData.type,\n    // Add other relevant properties\n  })\n}\n\nHANDLING USER ID MIGRATIONS\n\nUsers change identifiers over time. I handle transitions gracefully (because I used to use Bluesky handles as identity):\n\n// Migrate from .bluesky suffix to email-based IDs\nif (oldDistinctID.endsWith('.bluesky')) {\n  posthog.alias(newDistinctID, oldDistinctID)\n}\nposthog.identify(newDistinctID, personProperties)\n\nIt is critical to call .alias() before .identify(). I made the mistake of doing it the other way around previously.\n\nThis ensures historical data stays linked to users as my identification strategy evolves.\n\nERROR TRACKING INTEGRATION\n\nError tracking is one of PostHog's most powerful features. But I don't capture everything—I'm strategic:\n\ntry {\n  await riskyOperation()\n} catch (error) {\n  logger.error(\"Operation failed\", { error, stack: error.stack })\n  if (!isExpectedError(error)) {\n    posthog.captureException(error)\n  }\n}\n\nI track unexpected errors, API failures, and validation issues. I skip expected failures like expired links, Safari compatibility issues, and intentional rejections. This keeps the error dashboard useful instead of noisy.\n\nADVANCED BACKEND EVENTS\n\nIMMEDIATE FLUSHING FOR CRITICAL EVENTS\n\nSome events need immediate attention:\n\nexport function capturePaymentEvent(email: string, event: string, properties: object) {\n  const posthog = getPostHogClient()\n  if (!posthog) return\n\n  posthog.capture({\n    distinctId: trackingIdentifierForEmail(email),\n    event,\n    properties\n  })\n\n  // Ensure events are sent immediately for critical events\n  void posthog.flush()\n}\n\nEMAIL CAMPAIGN ANALYTICS\n\nI track my email effectiveness systematically:\n\nposthog.capture(SEND_ACTIVATION_EMAIL_EVENT, {\n  email,\n  reason: \"inactive_user\",\n  daysSinceLastLogin: 30\n})\n\nThis helps me understand which campaigns drive real user engagement. I create PostHog funnels to observe the impact of (re)activation emails.\n\nDATA QUERY AND ANALYSIS\n\nBACKEND QUERY LIBRARY\n\nSometimes I need to pull analytics data programmatically:\n\nexport async function fetchPersonByDistinctID(distinctID: string) {\n  const response = await fetch(\n    `https://us.i.posthog.com/api/persons/?distinct_id=${distinctID}`,\n    {\n      headers: { 'Authorization': `Bearer ${env.POSTHOG_PERSONAL_API_KEY}` }\n    }\n  )\n  return response.json()\n}\n\nThis enables custom dashboards, user lookup tools, and automated reporting.\n\nOPERATIONAL BEST PRACTICES\n\nENVIRONMENT-SPECIFIC BEHAVIOR\n\nI disable analytics in development to prevent noise:\n\nif (!import.meta.env.PROD && !posthogToken) {\n  console.log(\"PostHog disabled in development\")\n  return\n}\n\nDEBUGGING AND MAINTENANCE\n\nI include context in all events for easier troubleshooting:\n\n// Structured error logging\nlogger.error({\n  error: errorObject,\n  stack: errorObject.stack,\n  distinctId: userIdentifier\n}, \"PostHog event capture failed\")\n\nCONVERSION TRACKING\n\nI prevent duplicate conversions with careful state management:\n\nif (!conversionShown.value) {\n  posthog.capture(SUBSCRIBED_EVENT, {\n    plan: priceID,\n    value: amount,\n    currency: \"USD\"\n  })\n  conversionShown.value = true\n}\n\nWith this setup, I can trace a user from first visit through to conversion, see where they drop off, and catch errors before they report them. PostHog's free tier covers all of this, which is great when you don't want to pay hundreds of dollars a month for analytics.\n\n----------------------------------------\n\nWhat's your experience with analytics implementation? Send me your tips and challenges via email at hboon@motionobj.com. Thanks!",
  "title": "How I Set Up PostHog Analytics Across Frontend and Backend",
  "updatedAt": "2026-06-04T00:00:00.000Z"
}