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

*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.*

Guides | 2026-07-09T05:05:17.180Z

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:

| Mode | What happens | Use it when |
| --- | --- | --- |
| `after-consent` | c15t does not request PostHog until `measurement` consent is granted. | Your policy requires no PostHog script or network request before consent. |
| `always` | c15t loads PostHog early and forwards consent changes to PostHog. | You intentionally use PostHog cookieless behavior after rejection. |
| `disabled` | c15t 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:

| Question | Answer |
| --- | --- |
| 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

- [c15t Next.js quickstart](https://c15t.com/docs/frameworks/next/quickstart)
- [c15t Next.js script loader](https://c15t.com/docs/frameworks/next/script-loader)
- [c15t cookie management](https://c15t.com/docs/frameworks/next/concepts/cookie-management)
- [c15t PostHog integration](https://c15t.com/docs/integrations/posthog)
- [PostHog data storage and privacy controls](https://posthog.com/docs/privacy/data-storage)
- [PostHog GDPR guidance](https://posthog.com/docs/privacy/gdpr-compliance)
- [EU ePrivacy Directive](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02002L0058-20091219)
- [GDPR text](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02016R0679-20160504)
- [EDPB Guidelines 05/2020 on consent](https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en)
- [EDPB Guidelines 2/2023 on the technical scope of ePrivacy Article 5(3)](https://www.edpb.europa.eu/documents/guideline/guidelines-22023-on-technical-scope-of-art-53-of-eprivacy-directive_en)

## 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](https://c15t.com/docs/integrations/posthog) for the full option reference. If you need server awareness, read the [c15t Next.js server-side data guide](https://c15t.com/docs/frameworks/next/server-side).

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