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-jsPostHog 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:
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$pageviewon init only. Setting it to"history_change"tells PostHog to also fire$pageviewon everyhistory.pushState/history.replaceStatecall, 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:
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:
{
"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:
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:
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:
VITE_PUBLIC_POSTHOG_KEY=phc_your_key_hereAdd 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
Feb 2026
·5 min read
Mar 2026
·4 min read