Setup PostHog reverse proxy with Remix/React Router


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:
Instead of the browser sending analytics directly to PostHog servers (where they can be blocked): Browser → PostHog (❌ blocked by adblockers)
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:
CORS errors: If there are CORS-related errors, ensure the server is properly handling OPTIONS requests and forwarding the appropriate headers.
Missing data: Check the browser's Network tab to see if requests to the proxy endpoint are succeeding with 200 status codes.
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.
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.
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!