How to properly initialize PostHog with Remix/React Router v7
Why your PostHog identify calls aren't working and how to fix themWhen 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:
- Asynchronous loading: PostHog’s client-side library loads asynchronously, but there’s no built-in way to know when it’s ready
- SSR complications: In server-side rendered applications like Remix, the PostHog client needs to initialize after hydration
- 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:
- Check if PostHog is already loaded (
posthog.__loaded
) - If loaded, execute the analytics call immediately
- If not loaded, set up an event listener to execute when ready
- 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!