Building things
how to run posthog analytics without a cookie consent banner
Reproduction script · posthog-cookieless-storage-check.mjs · updated 2026-08-06
If the plan is "add PostHog, then bolt a consent banner on top", you can often drop the banner, and the reason is narrower than "PostHog is privacy-friendly". A tracker needs consent when it reads or writes on the visitor's device. Configure posthog-js so it writes nothing, and that trigger never fires. We run exactly this configuration on this site, and the artifact on this page proves the "writes nothing" half in a real browser: zero cookies, zero localStorage, zero sessionStorage after a pageview. The catch is that "cookieless" means two different things in PostHog, and the obvious one quietly makes your visitor counts fiction. That gap is the whole subject of this page.
Prerequisites
A PostHog project (this site uses EU Cloud), and posthog-js loaded in a client bundle. Nothing here needs a server SDK.
One decision, made before any code, because it is load-bearing: you are giving up person profiles, session replay, and any notion of a returning visitor. No cohorts, no retention curve, no multi-visit funnel. If you need any of those, this approach does not apply to you and you owe a banner. Say it out loud now, because every option below exists to hold that line, and reopening one of them reopens the consent question. This is the same cut we documented for our own funnel: we measure transitions inside a single session, and we accept that absolute visitor numbers are a ceiling, not a count.
You also need a way to hand the project key to the client at build time, not only at runtime. Step 4 explains why that is not the same thing.
Steps
1. Initialise with the five options that write nothing. This is the exact init this site ships:
import posthog from 'posthog-js'
posthog.init(key, {
api_host: 'https://eu.i.posthog.com',
persistence: 'memory', // not cookie, not localStorage
person_profiles: 'never', // no individual identifier is ever sent
capture_pageview: false, // captured manually, see step 2
capture_pageleave: false,
disable_session_recording: true,
})
persistence: 'memory' keeps the session id in JavaScript memory only. person_profiles: 'never' stops any identified profile being created. disable_session_recording: true matters more than it looks, because replay captures the screen, which on a form or a chat means capturing what the visitor types, and that alone would put you back inside the consent regime.
2. Capture pageviews by hand on route change. With capture_pageview: false you fire them yourself. In the Next.js App Router, read usePathname, not useSearchParams:
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
export function PageviewTracker() {
const pathname = usePathname()
useEffect(() => {
posthog.capture('$pageview', { $current_url: window.location.href })
}, [pathname])
return null
}
useSearchParams in a client component forces the whole route out of static prerendering and into dynamic rendering, which is a cost you pay on every page for a signal you rarely need. usePathname alone does not suspend and leaves the prerender intact.
3. Provide the key at build time, not only at runtime. If your marketing pages are prerendered, the key is baked into the HTML during next build. A key injected only when the container starts leaves analytics dead across every static page, with no error anywhere. Pass it as a Docker build argument and read it in the server component that renders the provider, then hand it down as a prop. A client-side process.env read will not work: without the NEXT_PUBLIC_ prefix the value is never inlined, and adding that prefix publishes the key in the bundle, which for a project key is acceptable but should be a choice, not an accident.
4. Turn session replay off in the project settings too. posthog-js can start a recording when the feature is enabled server-side, so the code flag is only half the lock. Disable it in the PostHog project as well. Two locks, because either one alone can be undone without touching the other.
The trap the docs skip
"Cookieless" is two features, and picking the wrong one silently breaks your numbers. The approach above (persistence: 'memory') writes nothing, but memory persistence lasts exactly one page view. Every full page load, and every visitor who lands directly on a URL, starts a fresh session that PostHog counts as a new person. Internal soft navigations stay in one session, so trends hold, but your absolute unique-visitor number is inflated and should be read as a ceiling. Most tutorials that show persistence: 'memory' never mention this, and it is the single most important thing to know before you trust a dashboard built on it.
PostHog's other cookieless feature actually solves the counting problem: cookieless server hash mode, set with cookieless_mode: 'always', which you must first switch on under Project Settings → Web analytics. It identifies uniques with a daily-salted server-side hash of IP and user agent, so it still stores nothing on the device but counts far better than memory persistence. The trade is real: you cannot call identify(), the daily salt means a returning visitor looks new the next day, IP and user-agent collisions merge two people into one, and GeoIP, bot detection, session replay and surveys are unavailable. It is the right tool when the count matters more than the identity, which is a different decision from the one on this page.
The library default is not cookieless. Leave the options out and posthog-js runs persistence: 'localStorage+cookie'. The artifact measures exactly this: the default drops a ph_..._posthog cookie plus localStorage and sessionStorage keys, while our config leaves all three empty. Forget the two options above and you are setting a cookie again without noticing, banner and all. An older report (posthog-js issue #614) claimed "cookieless" setups still dropped a cookie, which is precisely why the artifact measures rather than trusts. On posthog-js 1.405.3 it does not reproduce.
"No banner" covers PostHog, not your page. Fonts from a third party, an embedded map or video, a support widget, marketing tags: each keeps its own consent question. Removing PostHog from the list does not empty the list.
Writing nothing is not the CNIL "audience-measurement exemption", and the difference is in your favour. The CNIL exemption is a narrow allowance for trackers you do place: audience measurement or A/B testing only, no cross-checking with other data, scope limited to a single publisher, the last byte of the IP truncated, a 13-month lifetime, and users informed with a way to object. The CNIL itself notes most large audience offerings do not qualify. Storing nothing on the device sidesteps that whole test, because the read/write trigger never fires. That is a cleaner position, but it is not an automatic pass: compliance is not a boolean, so keep the audience-measurement line in your privacy policy and keep the data minimal. We are engineers, not your lawyers, and this is the framing, not legal advice.
Verifying
Run the artifact. npm i posthog-js@1.405.3 playwright, npx playwright install chromium, then node posthog-cookieless-storage-check.mjs. It loads the real browser build, initialises once with this site's config and once with the library default, fires a pageview in each, and reads every client store. You should see the same result we do: the cookieless config writes nothing, the default writes a ph_* cookie and storage keys. It exits non-zero if the cookieless side ever leaks a key, so it doubles as a regression test when you upgrade posthog-js.
Check your own app in devtools. Open Application → Storage. After several soft navigations and one full reload, Cookies, Local Storage and Session Storage should all stay empty of ph_* entries. Then flip persistence to 'localStorage+cookie' for one run and watch the cookie appear, so you have proof your check can fail, not just pass.
Confirm the key actually reached the client. The missing-key path is a silent no-op by design, so a green build proves nothing here. The only real evidence is a $pageview landing in PostHog's live events after you deploy. This is the same reasoning that makes instrumentation worth the trouble in the first place: you cannot fix an Anthropic 529 overloaded_error you never recorded, or a runaway recursion loop that only ever shows up as cost, and you cannot read a funnel you were too cautious to measure. This is the analytics we run on our own agentic systems, chosen so that measuring them costs the visitor nothing.
Sources
The persistence option names and their exact behaviour come from PostHog's JavaScript persistence docs; the two cookieless modes, the required project setting and the identify() restriction from the cookieless tracking tutorial and the GDPR compliance docs. The historical cookie-leak claim is posthog-js issue #614. The consent-exemption conditions are the CNIL's Sheet n°16. The storage behaviour reported here was measured on posthog-js 1.405.3 with Playwright and Chromium on 2026-08-06 by running the artifact, which prints your own installed version's behaviour and is the reason it ships.
Reported cases
Just your email, nothing else to fill in. Alex writes back.