Writing

Adding PostHog analytics to a static TanStack Start app

How I set up PostHog in a statically prerendered TanStack Start site, covering SSR-safe initialisation, SPA pageview tracking, and proxying through Vercel.

18 Mar 2026

·

5 min read

·
TanStack StartVercelAnalytics

Share

I wanted proper event analytics on my personal site, not just page counts, but the ability to track things like which posts get clicked, which navigation paths people take, and whether the RSS link I added actually gets any use. I went with PostHog because it has a generous free tier and the SDK is well-documented. There were a few things to get right with a statically prerendered TanStack Start app, so here's what I ended up with.

Tested with TanStack Start 1.163.2 and posthog-js 1.237.1.

Installing posthog-js

bun add posthog-js

PostHog also has a @posthog/react package with a PostHogProvider and usePostHog hook. I tried it but ended up removing it, as the slim posthog-js bundle initialised at module level turned out to be simpler and worked fine for what I needed.

Initialising PostHog

The main concern with initialisation in a TanStack Start app is SSR safety. The posthog.init() call references window, which doesn't exist during server-side rendering. The pattern I settled on is to initialise inside a useEffect in a component that renders once at the root:

src/components/analytics.tsx
import posthog from "posthog-js";
import { useEffect } from "react";

function PosthogProvider() {
  useEffect(() => {
    if (!import.meta.env.VITE_PUBLIC_POSTHOG_KEY) {
      console.warn("PostHog key not found, analytics will be disabled");
      return;
    }
    posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
      api_host: "/ph",
      defaults: "2026-01-30",
      capture_pageview: "history_change",
    });
  }, []);

  return null;
}

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <PosthogProvider />
    </>
  );
}

A few things worth calling out:

  • api_host: "/ph", proxies PostHog requests through your own domain. Covered in the next section.
  • defaults: "2026-01-30", PostHog periodically ships new default configurations. Setting this to a specific date pins the defaults to what was current then, so your config doesn't silently change when PostHog ships updates. Set it to today's date when you first install.
  • capture_pageview: "history_change", this is the key setting for SPAs. By default PostHog fires a $pageview on init only. Setting it to "history_change" tells PostHog to also fire $pageview on every history.pushState / history.replaceState call, which is how TanStack Router navigates between pages. Without this, you only get one pageview per hard load.

Then wrap the app in AnalyticsProvider at the root:

src/routes/__root.tsx
import { AnalyticsProvider } from "@/components/analytics";

function RootDocument({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <AnalyticsProvider>
          <Navbar />
          <main>{children}</main>
          <Footer />
        </AnalyticsProvider>
        <Scripts />
      </body>
    </html>
  );
}

Proxying through Vercel

Some ad blockers and browser extensions block requests to us.i.posthog.com directly. Proxying PostHog through your own domain sidesteps this. With Vercel, it's two rewrites in vercel.json:

vercel.json
{
  "rewrites": [
    {
      "source": "/ph/static/:path(.*)",
      "destination": "https://us-assets.i.posthog.com/static/:path"
    },
    {
      "source": "/ph/:path(.*)",
      "destination": "https://us.i.posthog.com/:path"
    }
  ]
}

This is what allows api_host: "/ph" in the init config. PostHog's requests go to /ph/... on your domain, and Vercel forwards them to PostHog's servers. The PostHog docs recommend this setup and it's straightforward to add.

Tracking custom events

For custom events I exported a typed capture function wrapping posthog.capture. Keeping a typed EVENT_NAMES list means TypeScript will catch typos:

src/components/analytics.tsx
export const EVENT_NAMES = [
  "nav_click",
  "post_card_click",
  "share_post",
  "copy_link",
  "rss_click",
] as const;

export type EventName = (typeof EVENT_NAMES)[number];

export function capture(event: EventName, properties?: Record<string, unknown>) {
  posthog.capture(event, properties);
}

Using it in a component:

src/components/navbar.tsx
import { capture } from "@/components/analytics";

// Inside a click handler:
capture("nav_click", { section: "posts" });

One thing to watch: posthog.capture is safe to call before posthog.init completes, as PostHog queues events internally and flushes them once initialisation finishes. I initially worried about race conditions but this turned out to be a non-issue.

Setting the environment variable

The PostHog project key needs to be available at build time since it's inlined via import.meta.env:

.env
VITE_PUBLIC_POSTHOG_KEY=phc_your_key_here

Add the same variable in Vercel's project settings under Environment Variables. Without it the console.warn in PosthogProvider fires and analytics silently does nothing, which is fine for local development, but make sure the key is set before deploying.

Gotchas

A couple of things that tripped me up.

capture_pageview defaults to init-only

The PostHog docs suggest wrapping a $pageview event in a React component that calls posthog.capture("$pageview") on route changes. I found capture_pageview: "history_change" cleaner for TanStack Router since it hooks into the browser's native History API directly, with no need for a separate route-change listener.

Don't import posthog directly in components

The PostHog React docs warn against importing posthog directly from posthog-js in components and recommend using usePostHog() instead. In practice I found the simpler capture() wrapper approach, which calls posthog directly, worked without issues. The concern is primarily around calling posthog before init(), which PostHog handles via its internal queue.

Wrapping up

The two settings worth double-checking are capture_pageview: "history_change" (without it you'll miss most pageviews in a SPA) and the Vercel proxy rewrites (without them ad blockers will drop a meaningful share of your events). Everything else is standard PostHog setup.

Continue reading