Analytics answers a different question than logging or Sentry. Logging tells you what your application did internally. Sentry tells you when something broke. Analytics tells you what people actually do once they reach your product, which pages they visit, which features they use, and where they drop off.
Oolvay ships with PostHog wired in for this. It is entirely optional. If enabled is left false, or the required credentials are missing, the application runs exactly as it would otherwise. Nothing else in the codebase depends on analytics being configured.
Analytics in Oolvay is built from a few cooperating pieces, each with a narrow responsibility.
| File | Responsibility |
|---|---|
components/providers/posthog-provider.tsx | Initializes PostHog, gates it behind consent, and tracks pageviews. |
components/analytics/posthog-identify.tsx | Identifies the current user once their session is known. |
components/providers/consent-provider.tsx | Tracks whether the visitor has consented to analytics, and notifies the app when it changes. |
next.config.ts | Rewrites /ingest/* requests to PostHog’s servers, so analytics calls are same origin. |
PostHogProvider wraps the entire application in app/layout.tsx, but it does not start sending data the moment the app loads. It waits for consent.
The provider reads consent?.analytics from useConsent(). Until the visitor has made a choice, consent is null and PostHog stays uninitialized. Once a visitor accepts analytics through the cookie banner, the provider calls posthog.init() for the first time and opts in to capturing. If a visitor later declines or withdraws consent, the provider calls posthog.opt_out_capturing(), which stops PostHog from sending further events without needing to reload the page.
0 / 2,000 characters
Consent state itself is read through a server action, getConsent(), and kept
in sync across the app with a custom consentUpdated window event. Whenever
the visitor’s choice changes, anything that depends on useConsent()
re-renders automatically.
Once initialized, a separate component, PostHogPageView, watches the current route with usePathname() and useSearchParams(). Every time the URL changes, it fires a $pageview event, deduplicated against the last URL it already tracked so the same page is never counted twice in a row.
This entire behavior is controlled by a single config flag: capturePageActivity. PostHog’s own automatic pageview tracking is always disabled at the library level (capture_pageview: false), so this flag is the only thing turning pageview and pageleave tracking on or off.
analytics: {
postHog: {
enabled: true,
capturePageActivity: true,
},
},Setting capturePageActivity to false stops both pageview and pageleave events from being sent, while leaving identification and any manually captured custom events untouched. This is useful in a few real scenarios, not just to reduce noise. Pageviews and pageleaves are typically the highest volume event type PostHog receives, since they fire on every navigation for every visitor, so turning them off conserves your event quota for the custom events you actually care about. It also helps if page level traffic is not meaningful for your product, for example a single page dashboard where the URL barely changes, or if you are intentionally minimizing what you collect beyond what consent alone requires.
PostHog treats anonymous visitors and known users differently. By default, every event is attributed to an anonymous, randomly generated ID. PostHogIdentify is what connects that anonymous activity to an actual signed-in user.
Rather than living once in the root layout, where no user session exists yet, PostHogIdentify is rendered inside the layout of each authenticated route group, after the session has already been resolved server-side. Here is what that looks like in the admin section:
const session = await getServerSession()
const user = session.user
return (
<SidebarProvider>
<PostHogIdentify userId={user.id} email={user.email} name={user.name} />
{/* ...rest of the layout */}
</SidebarProvider>
)PostHogIdentify calls identify() exactly once per mount, tracked with a ref, so re-renders of the surrounding layout do not re-send the identify call. If you add a new authenticated section to the app, for instance a separate layout for a teams area, drop PostHogIdentify into that layout the same way, with whatever user fields are available in that session.
If you go looking for a route handler that receives analytics calls, you will not find one. None exists, and none is needed.
next.config.ts defines a rewrites() rule that runs before any of the app’s own routes are matched:
async rewrites() {
return {
beforeFiles: [
{
source: "/ingest/static/:path*",
destination: `${posthogAssetHost}/static/:path*`,
},
{
source: "/ingest/:path*",
destination: `${posthogHost}/:path*`,
},
],
}
},When the browser calls PostHog with api_host: "/ingest", the request hits your own domain first, and Next.js silently forwards it server side to PostHog’s real ingestion host before your app code ever sees it. The browser only ever talks to your own domain, never to posthog.com directly.
This exists for the same reason tunnelRoute exists for Sentry elsewhere in the same file: posthog.com and i.posthog.com are commonly blocked by ad blockers and privacy extensions, while a same origin path like yourdomain.com/ingest is not.
proxy.ts is configured to never run on this path, since it carries outbound analytics traffic rather than a page a visitor is navigating to:
export const config = {
matcher: ["/((?!api|ingest|_next/static|...).*)"],
}The rewrite above happens entirely on the server. From the browser’s perspective, it only ever calls /ingest on your own domain, and never initiates a request to PostHog directly. That is what keeps it invisible to ad blockers.
The Content Security Policy is a separate, browser enforced check, and it evaluates network requests against where they are actually forwarded to, not just the path the browser called. So even though the browser never directly addresses PostHog, the policy still needs PostHog’s real hosts allowed, or the browser will block the request once Next.js forwards it there. lib/csp.ts grants this using the same host values as next.config.ts:
const posthogHost = env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"
const posthogAssetHost = posthogHost.replace(
".i.posthog.com",
"-assets.i.posthog.com"
)These two values are added to two separate directives, for two separate reasons:
connect-src includes both posthogHost and posthogAssetHost. This is what allows the browser’s network requests to actually reach PostHog once the /ingest rewrite forwards them there. The policy is checked against the real destination, not the original same origin path the browser called.img-src includes only posthogAssetHost, since that host serves PostHog’s static assets rather than event data.If you change NEXT_PUBLIC_POSTHOG_HOST to a self-hosted or region-specific domain, both of these update automatically, since they are derived from the same environment variable rather than hardcoded.
Whether analytics is active at all, and whether pageview tracking runs, are both controlled in config/site.ts.
analytics: {
postHog: {
enabled: true,
capturePageActivity: true,
},
},Defaults to false. PostHog stays entirely inactive until you explicitly set this to true. While false, PostHogProvider renders its children directly and does nothing else: PostHog is never initialized, no script loads, and no network requests are made, regardless of whether credentials are present in .env.local.
Controls pageview and pageleave tracking specifically. Identification and any custom events you capture elsewhere in the app are unaffected by this flag. See Pageview tracking above.
Two environment variables are required to actually send data to PostHog. Both are optional in env.ts, since the application functions without them, just with enabled effectively doing nothing.
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=Both are prefixed NEXT_PUBLIC_ because they are read in the browser, not just on the server. This is safe. The value you put in NEXT_PUBLIC_POSTHOG_KEY is PostHog’s project API key, which is write only. It can send new events but cannot read anything back out of your PostHog project, so there is no meaningful risk in it being visible in client side code.
Do not confuse the project API key with a PostHog personal API key. A personal API key can read and modify data through PostHog’s private API and must never be exposed to the browser. Oolvay only ever needs the project API key.
Create an account at posthog.com if you do not already have one, and create a project for this application.
Find your project token.
Navigate to your PostHog account's project settings. Under General, the Project token field is shown directly below Project token & ID, labeled "Write-only key for use in client libraries. Safe to use in public apps." Copy it into your environment variables:
NEXT_PUBLIC_POSTHOG_KEY=phc_...Set your ingestion host.
Which host you use depends on which region your PostHog project was created in:
| Region | Host |
|---|---|
| US Cloud | https://us.i.posthog.com |
| EU Cloud | https://eu.i.posthog.com |
| Self-hosted | Your own PostHog instance domain |
You can confirm which region your project uses from the same Project Settings page. Copy the correct host into your environment variables:
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comIf this variable is left empty, Oolvay falls back to the US Cloud host automatically.
Confirm analytics is enabled in config/site.ts.
analytics: {
postHog: {
enabled: true,
},
},Restart your development server, then accept analytics consent through the cookie banner when prompted.
bun devCheck, in order: that analytics.postHog.enabled is true in config/site.ts, that NEXT_PUBLIC_POSTHOG_KEY is set and correct, that you have actually accepted the analytics consent option in the cookie banner, and that you are looking at the correct PostHog project and region.
This means capturePageActivity is set to false. This flag only controls pageview and pageleave events. Identification, triggered separately by PostHogIdentify, is unaffected by it.
This usually means PostHogIdentify is not rendered in the layout for that section of the app. Check that the route group the user is on actually renders PostHogIdentify with the current session’s userId, the same way app/(protected)/admin/layout.tsx does.
Confirm the visitor explicitly accepted analytics consent, not just dismissed the banner. consent?.analytics must be true, not merely non-null, for PostHogProvider to call posthog.init() and begin capturing.
Analytics, logging, and Sentry serve different purposes and are safe to run together. Analytics tells you what people do. Logging tells you what your application did. Sentry tells you when something broke. None of the three depends on the others being configured.
Events will not be sent until consent is granted, even with valid credentials in place. Visit a few pages, then check your PostHog project’s events page to confirm events are arriving.