Installing PostHog in a Cloudflare Workers + React Router Project
This guide covers setting up PostHog for analytics and feature flags in a React Router application deployed on Cloudflare Workers.
Table of Contents
- Install Dependencies
- File Structure
- Server-Side Setup
- Client-Side Setup
- Feature Flags
- Worker Integration
- Root Layout Integration
- Usage Examples
Install Dependencies
bun add posthog-node posthog-js @posthog/react
| Package | Purpose |
|---|---|
posthog-node | Server-side SDK for Cloudflare Workers (feature flags, server events) |
posthog-js | Client-side SDK for browser analytics |
@posthog/react | React hooks and context provider |
File Structure
Create the following files in your app/posthog/ directory:
app/posthog/
├── index.ts # Re-exports for convenient imports
├── server.ts # Server-side PostHog client and utilities
├── provider.tsx # Client-side React provider and hooks
└── feature-flags.ts # Feature flag constants and types
Server-Side Setup
app/posthog/server.ts
import { PostHog } from "posthog-node";
import { FLAG_KEY_TO_DEFAULT, type FeatureFlagKey } from "./feature-flags";
const POSTHOG_HOST = "https://us.i.posthog.com";
/**
* Create a PostHog client for Cloudflare Workers
* Following: https://posthog.com/docs/libraries/cloudflare-workers
*
* IMPORTANT: We set flushAt to 1 and flushInterval to 0 to send data immediately.
* Cloudflare Workers can terminate before batched data is sent, causing data loss.
*/
export function createPostHogClient(
apiKey: string | undefined
): PostHog | null {
if (!apiKey) {
return null;
}
return new PostHog(apiKey, {
host: POSTHOG_HOST,
flushAt: 1, // Send events immediately in edge environment
flushInterval: 0, // Don't wait for interval
});
}
/**
* Evaluate a feature flag for a specific user
*/
export async function getFeatureFlag(
client: PostHog | null,
distinctId: string,
flagKey: FeatureFlagKey,
defaultValue: boolean = false
): Promise<boolean> {
// In local development, use the default values
if (import.meta.env.DEV) {
return FLAG_KEY_TO_DEFAULT[flagKey] ?? defaultValue;
}
if (!client || !distinctId) {
return defaultValue;
}
try {
const result = await client.isFeatureEnabled(flagKey, distinctId);
return result ?? defaultValue;
} catch (error) {
console.error(`[PostHog] Error evaluating feature flag "${flagKey}":`, error);
return defaultValue;
}
}
/**
* Capture a server-side event
* Should be wrapped in ctx.waitUntil() to not block the response
*/
export async function captureEvent(
client: PostHog | null,
distinctId: string,
event: string,
properties?: Record<string, unknown>
): Promise<void> {
if (!client || !distinctId) {
return;
}
try {
await client.captureImmediate({
distinctId,
event,
properties,
});
} catch (error) {
console.error(`[PostHog] Error capturing event "${event}":`, error);
}
}
/**
* Shutdown the PostHog client - call in ctx.waitUntil()
* at the end of the request to ensure all events are flushed
*/
export async function shutdownPostHog(client: PostHog | null): Promise<void> {
if (!client) {
return;
}
try {
await client.shutdown();
} catch (error) {
console.error("[PostHog] Error during shutdown:", error);
}
}
Key Points for Cloudflare Workers
- Immediate flushing: Set
flushAt: 1andflushInterval: 0because Workers can terminate before async batches complete - Graceful shutdown: Always call
shutdownPostHog()inctx.waitUntil()to ensure events flush - Development mode: Return default values in dev to avoid hitting PostHog API
Client-Side Setup
app/posthog/provider.tsx
"use client";
import { useEffect } from "react";
import posthog from "posthog-js";
import { PostHogProvider, usePostHog } from "@posthog/react";
const POSTHOG_HOST = "https://us.i.posthog.com";
interface User {
id: string;
email: string;
name: string;
}
interface PHProviderProps {
children: React.ReactNode;
apiKey: string | undefined;
user: User | null;
}
/**
* Component that handles user identification with PostHog
*/
function PostHogIdentify({ user }: { user: User | null }) {
const posthogClient = usePostHog();
useEffect(() => {
if (!posthogClient) return;
if (user) {
posthogClient.identify(user.id, {
email: user.email,
name: user.name,
});
}
}, [user, posthogClient]);
return null;
}
/**
* PostHog Provider component
* Wraps the application with PostHog context and handles user identification
*/
export function PHProvider({ children, apiKey, user }: PHProviderProps) {
useEffect(() => {
if (!apiKey || typeof window === "undefined") return;
// Only initialize if not already initialized
if (posthog.__loaded) return;
posthog.init(apiKey, {
api_host: POSTHOG_HOST,
person_profiles: "identified_only",
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
},
},
loaded: (ph) => {
if (import.meta.env.DEV) {
console.log("PostHog initialized in development mode");
}
},
});
}, [apiKey]);
if (!apiKey || typeof window === "undefined") {
return <>{children}</>;
}
return (
<PostHogProvider client={posthog}>
<PostHogIdentify user={user} />
{children}
</PostHogProvider>
);
}
/**
* Hook to track custom events
*/
export function useAnalytics() {
const posthogClient = usePostHog();
const trackEvent = (eventName: string, properties?: Record<string, unknown>) => {
if (posthogClient) {
posthogClient.capture(eventName, properties);
}
};
const reset = () => {
if (posthogClient) {
posthogClient.reset();
}
};
return { trackEvent, reset, posthog: posthogClient };
}
export { posthog };
Feature Flags
app/posthog/feature-flags.ts
/**
* Feature flag keys - centralized constants for type-safe feature flag access
*/
export const FEATURE_FLAGS = {
/** Enable document upload modal */
DOCUMENT_UPLOAD: "feature-document-upload",
/** Enable new dashboard UI */
NEW_DASHBOARD: "feature-new-dashboard",
} as const;
/**
* Type for feature flag keys
*/
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS];
/**
* Type for feature flags passed from loaders to components
*/
export interface FeatureFlags {
documentUploadEnabled: boolean;
newDashboardEnabled: boolean;
}
/**
* Default feature flag values (used in development or when PostHog unavailable)
*/
export const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
documentUploadEnabled: false,
newDashboardEnabled: false,
};
/**
* Mapping from PostHog flag keys to their default values
*/
export const FLAG_KEY_TO_DEFAULT: Record<FeatureFlagKey, boolean> = {
[FEATURE_FLAGS.DOCUMENT_UPLOAD]: DEFAULT_FEATURE_FLAGS.documentUploadEnabled,
[FEATURE_FLAGS.NEW_DASHBOARD]: DEFAULT_FEATURE_FLAGS.newDashboardEnabled,
};
Re-exports
app/posthog/index.ts
// Client-side exports
export { PHProvider, useAnalytics, posthog } from "./provider";
// Server-side exports
export {
createPostHogClient,
getFeatureFlag,
captureEvent,
shutdownPostHog,
} from "./server";
// Feature flag exports
export {
FEATURE_FLAGS,
DEFAULT_FEATURE_FLAGS,
type FeatureFlagKey,
type FeatureFlags,
} from "./feature-flags";
Worker Integration
workers/app.ts
import { createRequestHandler } from "react-router";
import type { PostHog } from "posthog-node";
import { createPostHogClient, shutdownPostHog } from "@/posthog/server";
// Extend the AppLoadContext type to include posthog
declare module "react-router" {
export interface AppLoadContext {
cloudflare: {
env: Env;
ctx: ExecutionContext;
};
posthog: PostHog | null;
// ... other context properties
}
}
const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"),
import.meta.env.MODE
);
export default {
async fetch(request, env, ctx) {
// Create PostHog client for this request
const posthog = createPostHogClient(env.POSTHOG_CLIENT_KEY);
const response = await requestHandler(request, {
cloudflare: { env, ctx },
posthog,
// ... other context
});
// IMPORTANT: Ensure PostHog events are flushed before worker terminates
// Using ctx.waitUntil() so it doesn't block the response
ctx.waitUntil(shutdownPostHog(posthog));
return response;
},
} satisfies ExportedHandler<Env>;
Root Layout Integration
app/root.tsx
import { Outlet } from "react-router";
import type { Route } from "./+types/root";
import { PHProvider } from "./posthog/provider";
import { getFeatureFlag } from "./posthog/server";
import { FEATURE_FLAGS } from "./posthog/feature-flags";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.auth.api.getSession({
headers: request.headers,
});
// Example: Check a feature flag in the root loader
const maintenanceModeEnabled = await getFeatureFlag(
context.posthog,
session?.user?.id ?? "anonymous",
FEATURE_FLAGS.MAINTENANCE_MODE,
false
);
return {
posthogApiKey: context.cloudflare.env.POSTHOG_CLIENT_KEY,
maintenanceModeEnabled,
user: session?.user
? {
id: session.user.id,
email: session.user.email,
name: session.user.name,
}
: null,
};
}
export default function App({ loaderData }: Route.ComponentProps) {
return (
<PHProvider apiKey={loaderData.posthogApiKey} user={loaderData.user}>
<Outlet />
</PHProvider>
);
}
Environment Variables
Add to your wrangler.jsonc:
{
"vars": {
"POSTHOG_CLIENT_KEY": "phc_your_project_api_key"
}
}
Or use secrets for production:
wrangler secret put POSTHOG_CLIENT_KEY
Usage Examples
Evaluating Feature Flags in Loaders
import { getFeatureFlag } from "@/posthog/server";
import { FEATURE_FLAGS } from "@/posthog/feature-flags";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.auth.api.getSession({
headers: request.headers,
});
const userId = session?.user?.id ?? "";
// Evaluate multiple flags in parallel
const [featureA, featureB] = await Promise.all([
getFeatureFlag(context.posthog, userId, FEATURE_FLAGS.FEATURE_A, false),
getFeatureFlag(context.posthog, userId, FEATURE_FLAGS.FEATURE_B, false),
]);
return {
flags: {
featureAEnabled: featureA,
featureBEnabled: featureB,
},
};
}
Using Flags in Components
import { useLoaderData } from "react-router";
export default function MyPage() {
const { flags } = useLoaderData<typeof loader>();
return (
<div>
{flags.featureAEnabled && <NewFeatureComponent />}
</div>
);
}
Tracking Events Client-Side
import { useAnalytics } from "@/posthog";
function MyComponent() {
const { trackEvent } = useAnalytics();
const handleClick = () => {
trackEvent("button_clicked", { buttonName: "signup" });
};
return <button onClick={handleClick}>Sign Up</button>;
}
Tracking Events Server-Side
import { captureEvent } from "@/posthog/server";
// In a loader or action
ctx.cloudflare.ctx.waitUntil(
captureEvent(context.posthog, user.id, "invention_created", {
inventionId: "123",
})
);
Best Practices
Security
- NEVER evaluate sensitive flags client-side only
- ALWAYS use server-side evaluation for access control
- ALWAYS default flags to
falsefor new features
Performance
- Evaluate all needed flags in a single loader (batch with
Promise.all) - Use flag values from loader data, don't re-fetch in components
- PostHog client is configured for immediate flush in Cloudflare Workers
Naming Conventions
- Flag keys in PostHog:
feature-{feature-name}or{TICKET-ID}-{Feature-Name} - TypeScript constants:
SCREAMING_SNAKE_CASE - Interface properties:
camelCaseEnabled
Cleanup
- Remove flag checks after feature is fully rolled out
- Update
FEATURE_FLAGS,FeatureFlagsinterface, andDEFAULT_FEATURE_FLAGS - Search codebase for all usages before removing
Troubleshooting
Events not being captured in production
- Ensure
ctx.waitUntil(shutdownPostHog(posthog))is called - Verify
POSTHOG_CLIENT_KEYenvironment variable is set - Check PostHog project settings for the correct region (US vs EU)
Feature flags not working
- Verify the flag key matches exactly (case-sensitive)
- Check that the user ID is being passed correctly
- In development, flags use default values from
DEFAULT_FEATURE_FLAGS
PostHog not initializing client-side
- Ensure
posthogApiKeyis being passed from the loader - Check browser console for initialization errors
- Verify the API key is a public project key (starts with
phc_)
