How to properly initialize PostHog with Remix/React Router v7

Why your PostHog identify calls aren't working and how to fix them

When integrating PostHog with React Router v7 (formerly Remix), you might encounter a frustrating race condition where your analytics calls fail silently so analytics events don’t appear in your PostHog dashboard. The issue manifests when you try to call posthog.identify() or posthog.capture() immediately after initialization, but PostHog hasn’t fully loaded yet.

You can verify this issue by adding some debugging:

console.log('PostHog object:', posthog); // returns `undefined`
console.log('PostHog loaded?', posthog?.__loaded); // if `posthog` is defined but still not loaded, will return `false`

This happens because:

  1. Asynchronous loading: PostHog’s client-side library loads asynchronously, but there’s no built-in way to know when it’s ready
  2. SSR complications: In server-side rendered applications like Remix, the PostHog client needs to initialize after hydration
  3. Race conditions: Your application code might try to use PostHog before the library has fully initialized

If you check the official PostHog Remix documentation, you’ll notice the comments are full of people experiencing this exact same issue. Unfortunately, the provided solution doesn’t actually work reliably.

The solution: a custom event system

Rather than polling or using timeouts (which are unreliable), I implemented a custom event system that leverages PostHog’s built-in loaded callback. This approach:

  • Uses native DOM events for reliable communication
  • Provides a clean API for waiting on PostHog initialization
  • Handles cleanup properly for React’s strict mode
  • Works consistently across different loading scenarios

Server-side

import { PostHog } from "posthog-node";

let posthogClient = null;

// To capture any server-side events, import this function (e.g. into loader/action for a route)
// https://github.com/PostHog/posthog.com/blob/64b2c23029c121d21870b6c9eb50ecb7481d8aa1/contents/tutorials/remix-analytics.md#adding-posthog-on-the-server-side
export default function PostHogClient() {
  if (!posthogClient) {
    posthogClient = new PostHog(process.env.POSTHOG_API_KEY, {
      host: "https://us.i.posthog.com",
    });
  }
  return posthogClient;
}

Client-side

import posthog from "posthog-js";
import { useEffect } from "react";
import { useLocation } from "react-router";

const POSTHOG_LOADED_EVENT = "posthog_loaded";

const dispatchPosthogLoadedEvent = () => {
  document.dispatchEvent(new CustomEvent(POSTHOG_LOADED_EVENT));
};

/**
 * Adds a listener for the posthog_loaded event.
 * @param callback The function to call when the event is fired.
 * @param once Whether the listener should be removed after it is called.
 * @returns A function to remove the listener for useEffect cleanup.
 */
const addPostHogLoadedListener = (callback: () => void) => {
  document.addEventListener(POSTHOG_LOADED_EVENT, callback, { once: true });
  return {
    cleanup: () => {
      document.removeEventListener(POSTHOG_LOADED_EVENT, callback);
    },
  };
};

// https://posthog.com/docs/libraries/remix
export const PosthogInit = () => {
  useEffect(() => {
    posthog.init(window.ENV.POSTHOG_API_KEY, {
      api_host: "https://us.i.posthog.com",
      person_profiles: "always",
      loaded: () => {
        dispatchPosthogLoadedEvent();
      },
    });
  }, []);

  return null;
};

// https://github.com/PostHog/posthog.com/blob/64b2c23029c121d21870b6c9eb50ecb7481d8aa1/contents/tutorials/remix-analytics.md#capturing-pageviews
export const usePosthogPageview = () => {
  const location = useLocation();

  useEffect(() => {
    if (posthog.__loaded) {
      posthog.capture("$pageview");
    } else {
      const onLoad = () => {
        posthog.capture("$pageview");
      };

      const { cleanup } = addPostHogLoadedListener(onLoad);
      return cleanup;
    }
  }, [location]);
};

// https://posthog.com/docs/product-analytics/identify
export const usePosthogIdentify = (email: string | undefined) => {
  useEffect(() => {
    if (!email) {
      return;
    }

    if (posthog.__loaded) {
      posthog.identify(email, { email });
    } else {
      // If not loaded, set up a listener for the load event
      const onLoad = () => {
        posthog.identify(email, { email });
      };

      const { cleanup } = addPostHogLoadedListener(onLoad);
      return cleanup;
    }
  }, [email]);
};

How it works

The solution consists of three main parts:

1. Custom Event Dispatch

When PostHog finishes loading, it triggers the loaded callback in the posthog.init() configuration. At this point, we dispatch a custom DOM event:

loaded: () => {
  dispatchPosthogLoadedEvent();
}

This creates a reliable signal that PostHog is ready to use.

2. Event Listener Helper

The addPostHogLoadedListener function provides a clean way to listen for the PostHog loaded event:

  • Uses { once: true } to automatically remove the listener after it fires once
  • Returns a cleanup function for manual removal if needed
  • Handles edge cases where PostHog might load before the listener is attached

3. React Hooks

The custom hooks (usePosthogPageview and usePosthogIdentify) follow this pattern:

  1. Check if PostHog is already loaded (posthog.__loaded)
  2. If loaded, execute the analytics call immediately
  3. If not loaded, set up an event listener to execute when ready
  4. Return cleanup function to prevent memory leaks

Usage Example

Here’s how you’d use these hooks in your React Router v7 app:

// app/entry.client.tsx or your main app component
import { PosthogInit } from "~/utils/posthog";

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
      <PosthogInit />
    </StrictMode>,
  );
});

// app/root.tsx
import { usePosthogPageview, usePosthogIdentify } from "~/utils/posthog";

export default function App() {
  // assuming you are returning your data from your root loader
  const data = useRootLoaderData();
  
  // Track page views automatically
  usePosthogPageview();
  
  // Identify users when they're available
  usePosthogIdentify(data?.user?.email);
  
  return <html>{/* */}</html>;
}

Why this approach works better

Compared to other solutions you might find online:

  • No polling: Avoids wasteful setInterval checks
  • No arbitrary timeouts: Won’t fail if PostHog takes longer than expected to load
  • Proper cleanup: Prevents memory leaks in React’s development strict mode
  • Type-safe: Works well with TypeScript
  • Reliable: Uses PostHog’s own loading callback as the source of truth

Happy coding!