Skip to main content

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

  1. Install Dependencies
  2. File Structure
  3. Server-Side Setup
  4. Client-Side Setup
  5. Feature Flags
  6. Worker Integration
  7. Root Layout Integration
  8. Usage Examples

Install Dependencies

bun add posthog-node posthog-js @posthog/react
PackagePurpose
posthog-nodeServer-side SDK for Cloudflare Workers (feature flags, server events)
posthog-jsClient-side SDK for browser analytics
@posthog/reactReact 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

  1. Immediate flushing: Set flushAt: 1 and flushInterval: 0 because Workers can terminate before async batches complete
  2. Graceful shutdown: Always call shutdownPostHog() in ctx.waitUntil() to ensure events flush
  3. 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 false for 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, FeatureFlags interface, and DEFAULT_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_KEY environment 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 posthogApiKey is being passed from the loader
  • Check browser console for initialization errors
  • Verify the API key is a public project key (starts with phc_)

Written by

Sean Stuart Urgel
Senior Software Engineer @ Casper Studios