How to add PostHog to Next.js with GDPR-aware consent

Guides10 min read

Implement PostHog in a Next.js App Router project with c15t, then use a GDPR-aware consent setup that waits for measurement consent before loading analytics.

Analytics code should match the consent model of the site that loads it. A basic PostHog install can be enough for an internal tool, but an EU-facing app may need to stop PostHog from loading until the user grants measurement consent. c15t handles that boundary in Next.js by tying the PostHog script to the measurement consent category.

This guide uses the c15t PostHog script helper first. That path keeps PostHog loading, consent updates, revocation, and script removal in one place. It also gives you a stricter EU configuration with region: 'eu' and loadMode: 'after-consent'.

This article gives implementation and legal information, not legal advice. Ask counsel to review the consent copy, privacy notice, PostHog settings, and regional behavior before release.

What you will build

By the end, you can:

  • Install c15t in a Next.js App Router project.
  • Register PostHog through @c15t/scripts/posthog.
  • Keep PostHog out of the page until measurement consent is granted.
  • Track client events without throwing before PostHog loads.
  • Explain what happens when measurement consent is withdrawn.
  • Decide when cookieless PostHog tracking is a better fit.
  • Explain why EU analytics setups often need a stricter consent design.

How the consent lifecycle works

The strict setup has one rule: PostHog does not load until the user grants measurement consent.

text
User visits ↓ c15t initializes ↓ Consent given for measurement? ├─ No → PostHog never loads └─ Yes → c15t injects PostHog → events start flowing

Your app should not call posthog.init() directly in this model. c15t owns the script lifecycle.

ts
// ❌ Loads PostHog immediately, outside the c15t script lifecycle. posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: 'https://eu.i.posthog.com', });

Register PostHog as a c15t script instead.

ts
// ✅ c15t loads PostHog only after measurement consent. const scripts = [ posthog({ id: process.env.NEXT_PUBLIC_POSTHOG_KEY!, region: 'eu', loadMode: 'after-consent', }), ];

That difference matters. A direct posthog.init() can create a script request before c15t has a chance to evaluate consent. The c15t helper waits for the consent state first.

Choose the PostHog loading model

c15t exposes two PostHog integration patterns.

Use the script helper for a new Next.js app. The helper loads PostHog's bootstrap script, calls posthog.init() for you, and synchronizes measurement consent with PostHog's opt-in and opt-out APIs. You configure it with posthog({ id, region, loadMode }) and pass the result to ConsentManagerProvider.

Use the SDK pattern when your app already loads posthog-js. In that setup, your app owns SDK initialization and c15t only synchronizes consent.

The script helper has three loading modes:

ModeWhat happensUse it when
after-consentc15t does not request PostHog until measurement consent is granted.Your policy requires no PostHog script or network request before consent.
alwaysc15t loads PostHog early and forwards consent changes to PostHog.You intentionally use PostHog cookieless behavior after rejection.
disabledc15t creates an inert integration with no PostHog request.You need an environment flag or temporary rollout switch.

This guide starts with after-consent because the behavior is easy to test: no consent, no PostHog script.

Install the packages

Start from an existing Next.js App Router project.

bash
npm install @c15t/nextjs @c15t/scripts

Import the c15t stylesheet in your global CSS file. You can skip this only if you use the headless API or fully custom styling.

css
/* app/globals.css */ @import "@c15t/nextjs/styles.css";

Add the public values the browser needs.

bash
# .env.local NEXT_PUBLIC_C15T_BACKEND_URL="https://your-instance.c15t.dev" NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

The PostHog project API key is a browser key. Keep it scoped to the right PostHog project and environment.

Create the c15t provider

Create a client component for the consent UI and the PostHog script list.

tsx
// components/consent-manager/provider.tsx 'use client'; import { type ReactNode } from 'react'; import { ConsentBanner, ConsentDialog, ConsentManagerProvider, } from '@c15t/nextjs'; import { posthog } from '@c15t/scripts/posthog'; const backendURL = process.env.NEXT_PUBLIC_C15T_BACKEND_URL; const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const scripts = posthogKey ? [ posthog({ id: posthogKey, region: 'eu', loadMode: 'after-consent', }), ] : []; export default function ConsentManagerClient({ children, }: { children: ReactNode; }) { if (!backendURL) { throw new Error('Missing NEXT_PUBLIC_C15T_BACKEND_URL'); } return ( <ConsentManagerProvider options={{ mode: 'hosted', backendURL, consentCategories: ['necessary', 'measurement', 'marketing'], scripts, overrides: process.env.NODE_ENV === 'development' ? { country: 'DE' } : undefined, }} > <ConsentBanner /> <ConsentDialog /> {children} </ConsentManagerProvider> ); }

The region: 'eu' option keeps PostHog's API, UI, and bootstrap hosts aligned with PostHog Cloud EU. The loadMode: 'after-consent' option stops c15t from injecting PostHog until the user grants measurement consent.

If your PostHog project uses Cloud US, change the region.

ts
posthog({ id: posthogKey, region: 'us', loadMode: 'after-consent', });

Hosted mode is the recommended production setup in the c15t Next.js docs. It lets the backend resolve jurisdiction and policy, keep durable consent records, and recover from temporary network failures.

Mount the provider in the app root

Keep the client boundary in one file and export a small wrapper.

tsx
// components/consent-manager/index.tsx import type { ReactNode } from 'react'; import ConsentManagerClient from './provider'; export function ConsentManager({ children }: { children: ReactNode }) { return <ConsentManagerClient>{children}</ConsentManagerClient>; }

Wrap the app from app/layout.tsx.

tsx
// app/layout.tsx import type { ReactNode } from 'react'; import './globals.css'; import { ConsentManager } from '@/components/consent-manager'; export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> <ConsentManager>{children}</ConsentManager> </body> </html> ); }

The provider should sit above the routes that need consent state or managed scripts. Do not add a separate next/script tag for PostHog, because that bypasses c15t's lifecycle for the vendor.

Track events safely

With loadMode: 'after-consent', window.posthog is unavailable until measurement consent is granted and the script loads. Optional chaining prevents a runtime error during that pre-consent window.

Add a small type if you do not already import PostHog types.

ts
// types/posthog.d.ts export {}; declare global { interface Window { posthog?: { capture: (event: string, properties?: Record<string, unknown>) => void; }; } }

Then read c15t consent state before calling PostHog.

tsx
// components/signup-button.tsx 'use client'; import { useCallback } from 'react'; import { useConsentManager } from '@c15t/nextjs'; export function SignupButton() { const { has } = useConsentManager(); const trackSignupClick = useCallback(() => { if (has('measurement')) { window.posthog?.capture('signup_clicked', { source: 'homepage_hero', }); } }, [has]); return <button onClick={trackSignupClick}>Sign up</button>; }

This pattern is strict. If the user rejects measurement, PostHog stays absent and the event is not sent. There is no app-level event queue in this example because the call exits before PostHog exists.

What happens when consent is withdrawn

Consent withdrawal is different from a first rejection.

If a user rejects measurement on the first visit, PostHog has not loaded in the after-consent setup. c15t records the choice, and the page can continue without a reload.

If a user previously granted measurement and later withdraws it, c15t reloads the page by default. The reload creates a fresh JavaScript context. On the next load, c15t reads the updated consent state and does not inject PostHog.

That default reload matters because JavaScript cannot reliably clean up every trace of a third-party script after it has run. Some cookies may be httpOnly, scoped to another domain, or stored outside normal cookies. A loaded SDK can also keep timers, event listeners, and in-memory state until the page unloads.

For the strict after-consent setup, expect this behavior:

QuestionAnswer
Does PostHog stop?Yes. c15t stops loading it after the reload because measurement consent is no longer present.
Are cookies removed?c15t gates the script that creates most third-party cookies. It does not promise to delete every cookie or storage entry a vendor may have set before withdrawal.
Is the page reloaded?Yes, by default, when consent is revoked after scripts have already run. c15t documents this as the reliable cleanup path.
What happens to queued events?With the guarded after-consent example, rejected-consent calls are not queued. In other PostHog modes, pending events before the bootstrap or consent sync may be dropped.

You can change revocation reload behavior with reloadOnConsentRevoked: false, but only do that if you have your own cleanup strategy for every loaded SDK.

Verify the consent boundary

Run the app locally.

bash
npm run dev

Use a clean browser profile and open DevTools.

  1. Open the Network tab and filter for posthog.
  2. Refresh the page before making a consent choice.
  3. Confirm that no posthog.js or PostHog API request appears.
  4. Accept the measurement category in the c15t banner or dialog.
  5. Confirm that the PostHog script loads.
  6. Click the UI that calls window.posthog?.capture().
  7. Confirm that the event appears in PostHog.
  8. Reopen privacy preferences and withdraw measurement consent.
  9. Confirm that the page reloads by default.
  10. Confirm that PostHog does not load after the reload.

The development override in the provider forces a Germany-like test path. Keep it scoped to development or remove it before production.

Why EU setups need more care

EU analytics work often has two layers.

The first layer is device storage and access. Article 5(3) of the ePrivacy Directive covers storing information on, or accessing information from, a user's terminal equipment. Consent is commonly required unless a narrow exception applies, such as technical transmission or a service that is strictly necessary and explicitly requested by the user. The EDPB's guidance on Article 5(3) explains that the rule is not limited to traditional cookies.

The second layer is personal-data processing. GDPR can apply when analytics events include personal data or online identifiers. If it applies, you need to assess lawful basis, transparency, purpose limitation, data minimization, retention, security, and user rights. If you rely on consent under GDPR, the consent needs to be freely given, specific, informed, unambiguous, and as easy to withdraw as to give.

This is why an EU-facing PostHog setup can be more complicated than a normal script install. A script request can happen before the user clicks a banner. A product analytics event can include URLs, referrers, UTM parameters, browser details, account identifiers, and IP-derived signals depending on your settings.

For a stricter EU design, keep the PostHog helper on this shape:

ts
posthog({ id: posthogKey, region: 'eu', loadMode: 'after-consent', });

That configuration supports two practical requirements:

  • PostHog Cloud uses the EU region.
  • c15t does not inject the PostHog script until measurement consent is granted.

Do not describe this as making the app GDPR compliant. It supports a stricter consent posture. Your team still needs the right notice text, PostHog data settings, retention rules, vendor disclosures, transfer analysis, and user-rights process.

Use cookieless mode only when it matches your policy

PostHog can also collect cookieless analytics. In c15t, that usually means loadMode: 'always'. PostHog loads on page start, c15t syncs consent through PostHog APIs, and PostHog can switch to cookieless capture after a rejection when your PostHog project supports it.

Use this only when legal review and your privacy notice allow PostHog to load before measurement consent.

tsx
// components/consent-manager/provider.tsx 'use client'; import { type ReactNode } from 'react'; import { ConsentManagerProvider } from '@c15t/nextjs'; import { posthog } from '@c15t/scripts/posthog'; const scripts = [ posthog({ id: process.env.NEXT_PUBLIC_POSTHOG_KEY!, region: 'eu', loadMode: 'always', initOptions: { cookieless_mode: 'on_reject', }, }), ]; export function ConsentProvider({ children }: { children: ReactNode }) { return ( <ConsentManagerProvider options={{ mode: 'hosted', backendURL: process.env.NEXT_PUBLIC_C15T_BACKEND_URL!, scripts, }} > {children} </ConsentManagerProvider> ); }

Before using cookieless_mode, enable Cookieless server hash mode in PostHog under Project Settings > Web analytics. The c15t PostHog docs state that PostHog ignores cookieless events unless that project setting is enabled.

Cookieless mode reduces browser persistence. It does not remove the need to assess notice, legal basis, data minimization, retention, transfers, or whether a pre-consent network request is allowed for your site.

Also expect analytics edge cases at the consent boundary. PostHog may start a new session when a user moves between cookieless and cookie-based capture, which can split pre-consent and post-consent activity.

If you already load posthog-js

For an app that already uses posthog-js, keep the SDK and let c15t synchronize consent. The c15t PostHog docs recommend an initial sync after c15t resolves consent, then subscribeToConsentChanges() for real preference changes.

ts
// lib/posthog-consent.ts import { getOrCreateConsentRuntime } from 'c15t'; import posthog from 'posthog-js'; function syncPostHogMeasurementConsent(hasMeasurementConsent: boolean) { if (hasMeasurementConsent) { posthog.opt_in_capturing(); } else { posthog.opt_out_capturing(); } } export function setupPostHogConsentSync() { const runtime = getOrCreateConsentRuntime({ mode: 'hosted', callbacks: { onBannerFetched() { syncPostHogMeasurementConsent( runtime.consentStore.getState().has('measurement') ); }, }, }); runtime.consentStore .getState() .subscribeToConsentChanges(({ allowedCategories }) => { syncPostHogMeasurementConsent( allowedCategories.includes('measurement') ); }); }

Do not use posthog.has_opted_in_capturing() or posthog.has_opted_out_capturing() to decide whether to show your banner. The c15t PostHog docs warn that has_opted_in_capturing() can return true while consent is still pending in recent PostHog versions. Use c15t as the source of truth for banner state.

Checks before launch

Review these items before you ship:

  • PostHog is registered only through c15t, not through next/script or a tag manager that bypasses c15t.
  • The helper uses the correct region for your PostHog project.
  • The chosen loadMode matches your privacy notice and legal review.
  • Event calls are guarded when you use loadMode: 'after-consent'.
  • Users have a persistent way to reopen privacy preferences and withdraw measurement consent.
  • PostHog settings for IP handling, person profiles, session replay, retention, and deletion match your policy.
  • Your c15t backend records consent and preference changes as required for your setup.
  • EU/EEA wording has legal review, especially if you make country-specific claims.

Source links

Next steps

You now have a Next.js consent setup where:

  • c15t owns the PostHog script lifecycle.
  • PostHog loads only after measurement consent in the strict setup.
  • Client event calls are guarded before PostHog exists.
  • Consent withdrawal reloads the page by default so revoked scripts do not run in the fresh context.
  • EU privacy claims are scoped to implementation support, not compliance guarantees.

Your application code does not need to decide when PostHog should load. c15t manages that lifecycle, and your app captures events only after analytics is available.

Next, read the c15t PostHog integration docs for the full option reference. If you need server awareness, read the c15t Next.js server-side data guide.

#c15t#PostHog#Next.js#Consent#Analytics