Setup PostHog reverse proxy with Remix/React Router

Arpit DalalArpit Dalal
5 min read
💡
This is not a sponsored or feature post. I genuinely like PostHog and its products.

Introduction

Analytics are crucial for understanding user behavior resulting in better products and higher user satisfaction. But, sometimes these analytics tools are blocked by adblockers. This article explains how to ensure that analytics data is collected reliably while respecting user privacy.

PostHog is an excellent tool for web/product analytics, session replays, feature flags, A/B testing, and much more. It's also quite cheap compared to alternatives in this space.

The Problem

The main issue with analytics tools like PostHog is that they get blocked by adblockers, which results in the loss of valuable data.

The Solution: A Backend Proxy

This can be solved by setting up a reverse proxy in the backend that forwards analytics events to PostHog, bypassing adblockers completely. While this guide shows implementation with Remix/react-router, the same concepts apply to any modern JS framework.

Note: PostHog has official Remix integration docs but in my experience it doesn’t work as is, I had to tweak some things to make it work.

How it works

The proxy works by redirecting analytics traffic through the server:

  1. Instead of the browser sending analytics directly to PostHog servers (where they can be blocked): Browser → PostHog (❌ blocked by adblockers)

  2. The traffic can be routed through the server first: Browser → Server → PostHog (✅ not blocked)

This simple redirection makes adblockers ineffective against the analytics collection.

Implementation Steps

1. Create the endpoint

First, create an endpoint that won't trigger adblockers. It's recommended to avoid names like "analytics" or "posthog”. Instead, use something generic like /resources/ingest.

Create a file named ingest.$.tsx under app/routes/resources. The $ creates a catch-all route that will handle all PostHog endpoints like /resources/ingest/e, /resources/ingest/decide, etc. Official react-router docs call these Splat routes.

The splat route is essential here because PostHog's SDK sends requests to multiple endpoints. Using a catch-all route allows us to handle all these different PostHog endpoints with a single file rather than creating separate handlers for each one.

2. Set up constants

const API_HOST = "us.i.posthog.com"; // use eu.i.posthog.com for EU
const ASSET_HOST = "us-assets.i.posthog.com"; // use eu-assets.i.posthog.com for EU

type RequestInitWithDuplex = RequestInit & {
  duplex?: "half"; // https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1483
};

These constants define which PostHog servers to connect to. The custom type RequestInitWithDuplex adds the duplex property needed for handling larger POST requests properly.

The duplex: 'half' property is necessary when working with request bodies in a streaming context. It tells the fetch API that the server will only be reading from the stream (not writing to it), which is important for properly handling PostHog's data payloads, especially larger ones.

3. Create the proxy function

async function posthogProxy(request: Request) {
  const url = new URL(request.url);

  // Choose the right host based on the request path
  const hostname = url.pathname.startsWith("/resources/ingest/static")
    ? ASSET_HOST
    : API_HOST;

  // Build the new URL to forward to PostHog
  const newUrl = new URL(url);
  newUrl.protocol = "https";
  newUrl.hostname = hostname;
  newUrl.port = "443";
  newUrl.pathname = newUrl.pathname.replace(/^\/resources\/ingest/, "");
  //                                        ^ depends on the endpoint

  // Prepare headers
  const headers = new Headers(request.headers);
  headers.set("host", hostname);
  headers.delete("accept-encoding"); // to let the fetch handle compression

  // Setup fetch options
  const fetchOptions: RequestInitWithDuplex = {
    method: request.method,
    headers,
    redirect: "follow", // enable fetch to follow the redirect in case PostHog throws a redirect
  };

  // Add body for non-GET/HEAD requests
  if (!["GET", "HEAD"].includes(request.method)) {
    fetchOptions.body = request.body;
    fetchOptions.duplex = "half"; // needed for larger POST bodies
  }

  try {
    // Forward the request to PostHog
    const response = await fetch(newUrl, fetchOptions);

    // Clean up response headers
    const responseHeaders = new Headers(response.headers);
    responseHeaders.delete("content-encoding");
    responseHeaders.delete("content-length");
    responseHeaders.delete("transfer-encoding");
    responseHeaders.delete("Content-Length");

    // Get response data if needed
    const data =
      response.status === 304 || response.status === 204
        ? null
        : await response.arrayBuffer();

    // Return the response
    return new Response(data, {
      status: response.status,
      statusText: response.statusText,
      headers: responseHeaders,
    });
  } catch (error) {
    // Track and log the error
    console.error("Proxy error:", error);
    return new Response("Proxy Error", { status: 500 });
  }
}

This function:

  • Determines which PostHog host to use based on the request path

  • Constructs a new URL with the correct host and modified path

  • Sets up the proper headers for the proxied request

  • Handles the request body for non-GET/HEAD methods

  • Makes the fetch request to PostHog

  • Cleans up the response headers to avoid encoding issues

  • Returns the proxied response or handles any errors

4. Set up the loader and action

import { type LoaderFunctionArgs, type ActionFunctionArgs } from 'react-router'

export async function loader({ request }: LoaderFunctionArgs) {
  return await posthogProxy(request);
}

export async function action({ request }: ActionFunctionArgs) {
  return await posthogProxy(request);
}

In Remix/react-router, the loader function handles GET/HEAD requests, while the action function handles all other HTTP methods (POST, PUT, DELETE, etc.). Both functions simply call the posthogProxy function created earlier.

5. Configuring the PostHog Client

Once the proxy is set up, the PostHog client needs to be configured to use it:

posthog.init('<YOUR_API_KEY>', {
  api_host: '/resources/ingest' // point to the proxy endpoint instead of PostHog's servers
  ui_host: 'https://us.posthog.com', // use https://eu.posthog.com for EU
  // ^ Necessary when using reverse proxy - https://posthog.com/docs/libraries/js#config
})

Troubleshooting

Here are some common issues one might encounter:

  1. CORS errors: If there are CORS-related errors, ensure the server is properly handling OPTIONS requests and forwarding the appropriate headers.

  2. Missing data: Check the browser's Network tab to see if requests to the proxy endpoint are succeeding with 200 status codes.

  3. Encoding issues: If there are any corrupted data or encoding errors, make sure the response headers are properly handled as shown in the proxy function.

  4. Feature flags not working: Feature flags require the /decide endpoint to work properly. Verify that requests to this endpoint are being correctly proxied.

Why This Matters

With this reverse proxy in place, analytics data will flow to PostHog even when users have adblockers enabled. This gives more accurate insights into how people are using products and helps make better data-driven decisions.

For the complete code, check out this gist.

For other frameworks or providers, see PostHog's official proxy documentation.

0
Subscribe to my newsletter

Read articles from Arpit Dalal directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arpit Dalal
Arpit Dalal

I am a web developer enthusiastic about all things about React and TS!