---
title: Scripts
description: Register vendor scripts in a Next.js ConsentRoot, check how each
  vendor loads, let visitors turn off one vendor, clear stored data after
  revocation and replace @next/third-parties.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Install the script helpers

The [App Router](/docs/frameworks/next/app-router),
[Pages Router](/docs/frameworks/next/pages-router) and
[static export](/docs/frameworks/next/static-export) guides already register
scripts. Use this page to add vendors to an existing setup or to change how
they load. Install the helpers if you have not:

|Package manager|Command|
|:--|:--|
|npm|`npm install @c15t/integrations@alpha`|
|pnpm|`pnpm add @c15t/integrations@alpha`|
|yarn|`yarn add @c15t/integrations@alpha`|
|bun|`bun add @c15t/integrations@alpha`|

Keep `next.config.ts` and the layout from your router guide. Adding a vendor
needs no server change. Server-rendered state, a streamed promise and
browser initialization without `state` all reach the same `ConsentRoot`, and
no script loads until the browser has a resolved policy and the visitor's
choice allows it.

## Register scripts in c15t.config.ts

The [runnable Next.js example](/docs/examples) loads PostHog under the
measurement category. Replace the placeholder `phc_your_project_key` with your
project key, or replace the helper with the
[integration](/docs/integrations/overview) your application uses. Include the
measurement category in your policy.

Put the scripts in `c15t.config.ts`, next to `next.config.ts`:

```ts title="c15t.config.ts"
import { posthog } from '@c15t/integrations/posthog';
import { defineConsentConfig } from 'c15t/next';

export default defineConsentConfig({
	scripts: [
		posthog({
			id: 'phc_your_project_key',
			initOptions: { cookieless_mode: 'never' },
			loadMode: 'after-consent',
		}),
	],
});
```

PostHog waits for measurement consent here. `cookieless_mode: 'never'` disables
cookieless capture after rejection. Remove any existing PostHog loader,
including `next/script` and tag-manager entries, so the integration loads once.

`withConsentManifest` in `next.config.ts` finds the file, and `ConsentRoot`
reads its `scripts` in the browser, so a Server Component layout renders
`ConsentRoot` without passing them. The file is bundled into the browser as
well as the server, so it must hold no secrets.

`scripts`, `vendors`, `clearOnRevocation`, `networkBlocker`, `persistence`,
`scriptLoader` and `options` can also be passed to `ConsentRoot` as props,
which win over the config. Functions can't cross from a Server Component to a
Client Component, so pass them as props only from a `'use client'` file.

## Register several vendors

Every helper goes in the same `scripts` array in `c15t.config.ts`, in the App
Router and the Pages Router alike. This config loads Google Tag Manager and
Meta Pixel together:

```ts title="c15t.config.ts"
import { googleTagManager } from '@c15t/integrations/google-tag-manager';
import { metaPixel } from '@c15t/integrations/meta-pixel';
import { defineConsentConfig } from 'c15t/next';

export default defineConsentConfig({
	scripts: [
		googleTagManager({ id: 'GTM-XXXXXXX' }),
		metaPixel({ pixelId: 'YOUR_PIXEL_ID' }),
	],
});
```

Each helper keeps its own consent rules. Meta Pixel waits for marketing, and
Google Tag Manager follows the
[Consent Mode contract](/docs/integrations/google-tag-manager). Remove the
container and pixel snippets from `pages/_document.tsx` or your layout, so each
vendor loads once. If they come from `@next/third-parties`, follow
[migrate from `@next/third-parties`](#migrate-from-nextthird-parties). With an
empty ID, a helper logs an error and its script does not load, so leave a
helper out of the array until you have its ID.

## Check each vendor's loading behavior

The example configures PostHog to load after consent and turns off cookieless
capture. Its default helper can load before consent and use the SDK's own
consent controls. Read the [PostHog guide](/docs/integrations/posthog) before
you change those settings.

Ordinary scripts wait for their category's permission. Give each script a
stable, unique `id`, and remove any other loader for the same vendor. Helpers
with `alwaysLoad` can load an SDK before permission is granted, so a category
alone does not guarantee that no request happens. See the
[vendor guides](/docs/integrations/overview) for each helper's contract.

Removing a script element cannot undo JavaScript that already ran or requests
already sent. When a visitor turns off a category they had granted,
`ConsentRoot` reloads the page, so the next page runs only permitted code. See
[reload after revocation](/docs/frameworks/next/components/consent-root#reload-after-revocation).

Use [custom integrations](/docs/integrations/building-integrations) for a
vendor without a helper. Google helpers follow the
[Consent Mode contract](/docs/integrations/google-tag-manager).

## Embeds and other requests

Scripts cover vendor code c15t loads for you. For the rest:

* [Embeds](/docs/frameworks/next/embeds) keeps iframes out of the page with
  `ConsentGate` or the iframe blocker.
* [Network blocker](/docs/frameworks/next/network-blocker) holds `fetch` and
  XHR calls that match a rule until their category is allowed.

## Let visitors turn off one vendor

A visitor can allow marketing and still switch off one vendor in it. Declare
the vendors and pass them to `ConsentRoot` as `vendors`; helpers from
`@c15t/integrations` already carry their vendor slug. See
[vendor consent](/docs/frameworks/next/vendor-consent).

## Clear stored tracking data

Script gating does not remove cookies or Web Storage entries a script already
wrote. Pass `clearOnRevocation` to `ConsentRoot` to remove declared data when
its category is denied. See
[clear on revocation](/docs/frameworks/next/clear-on-revocation) for
configuration and browser limits.

To send your own events only to allowed integrations, see
[send events only to allowed integrations](/docs/integrations/overview#send-events-only-to-allowed-integrations).

## Migrate from `@next/third-parties`

The components in `@next/third-parties/google` add `next/script` tags and
embeds when the page renders. c15t never sees them, so the banner
cannot hold them back, and `GoogleAnalytics` and `GoogleTagManager` send no
Consent Mode signals. Replace each export, keep your IDs, then remove the
package.

|`@next/third-parties/google`|c15t replacement|
|--|--|
|`<GoogleAnalytics gaId="G-XXXXXXXXXX" />`|`gtag({ id: 'G-XXXXXXXXXX', category: 'measurement' })` from `@c15t/integrations/google-tag`|
|`<GoogleTagManager gtmId="GTM-XXXXXXX" />`|`googleTagManager({ id: 'GTM-XXXXXXX' })` from `@c15t/integrations/google-tag-manager`|
|`sendGAEvent(...args)`|`window.gtag?.(...args)`|
|`sendGTMEvent(data)`|`window.dataLayer?.push(data)`|
|`<YouTubeEmbed videoid="VIDEO_ID" />`|`ConsentGate` around a `youtube-nocookie.com` iframe. See [YouTube](/docs/integrations/youtube).|
|`<GoogleMapsEmbed apiKey="API_KEY" mode="place" />`|`ConsentGate` around a Maps Embed API iframe. See [Google Maps](/docs/integrations/google-maps).|

### Replace GoogleAnalytics and GoogleTagManager

Delete the components from `app/layout.tsx`, `pages/_app.tsx` or
`pages/_document.tsx`. Add a helper for each one to the `scripts` array your
`ConsentRoot` receives, with the same measurement ID and container ID:

```ts title="lib/scripts.ts"
import { gtag } from '@c15t/integrations/google-tag';
import { googleTagManager } from '@c15t/integrations/google-tag-manager';
import type { Script } from 'c15t';

export const scripts: Script[] = [
	// Replaces <GoogleAnalytics gaId="G-XXXXXXXXXX" />
	gtag({ category: 'measurement', id: 'G-XXXXXXXXXX' }),
	// Replaces <GoogleTagManager gtmId="GTM-XXXXXXX" />
	googleTagManager({ id: 'GTM-XXXXXXX' }),
];
```

Keep only the helpers for the tags you used. If GA4 already runs inside your
Tag Manager container, register `googleTagManager` alone, or each page view
counts twice.

The component props map to helper options:

|Prop|c15t|
|--|--|
|`gaId`|`id` on `gtag`|
|`debugMode`|`config: { debug_mode: true }` on `gtag`|
|`dataLayerName` on `GoogleAnalytics`|No equivalent. `gtag` always queues on `window.dataLayer`.|
|`gtmId`|`id` on `googleTagManager`|
|`dataLayerName` on `GoogleTagManager`|`dataLayer` on `googleTagManager`. Keep the same name.|
|`dataLayer`|No option. Seed the data layer before the container loads. See [keep initial data layer values](#keep-initial-data-layer-values).|
|`auth`, `preview`, `gtmScriptUrl`|No option. Use a [custom integration](/docs/integrations/building-integrations) for GTM environments or a server-side tagging URL.|
|`nonce`|`options={{ nonce }}` on `ConsentRoot`. See [Content Security Policy](/docs/frameworks/next/content-security-policy).|

### Keep initial data layer values

`GoogleTagManager` pushes its `dataLayer` object in the same inline snippet
that pushes the `gtm.js` start event, before the container script runs, so the
container's startup triggers can read those values. Pushing the object later,
like an event, can arrive after those triggers have run.

Seed the queue in the initial HTML instead, before c15t loads the container.
Add a `beforeInteractive` script to the root `app/layout.tsx`, or to
`pages/_document.tsx` with the Pages Router. Keep `ConsentRoot` and the rest
of your layout as they are:

```tsx title="app/layout.tsx (partial)"
import Script from 'next/script';
import type { ReactNode } from 'react';

const initialDataLayer = { pageType: 'product' };

export default function RootLayout({ children }: { children: ReactNode }) {
	return (
		<html lang="en">
			<body>
				<Script id="gtm-initial-data-layer" strategy="beforeInteractive">
					{`window.dataLayer = window.dataLayer || [];
window.dataLayer.push(${JSON.stringify(initialDataLayer)});`}
				</Script>
				{children}
			</body>
		</html>
	);
}
```

`googleTagManager` keeps an array that already exists, so the queue holds your
object, then Consent Mode `default`, then the `gtm.js` start event. The script
sends nothing to Google, so it also works with `loadMode: 'after-consent'`.
Use the name you pass as `dataLayer` on `googleTagManager`, and pass your
Content Security Policy nonce to `Script` as `nonce`.

### Choose when Google loads

The `gtag` and `googleTagManager` helpers take a `loadMode` option. It
decides whether the page contacts Google before the helper's category is
allowed:

|`loadMode`|Until the category is allowed|Use it when|
|--|--|--|
|`'always'` (default)|c15t loads Google's script, sends Consent Mode `default` with the current permissions, then sends `update` as they change. Google receives requests with the optional consent types denied.|You want Consent Mode signals from visitors who have not allowed the category.|
|`'after-consent'`|c15t sends no request to Google.|Your site must make no request to Google before opt-in. You give up Consent Mode's cookieless pings and conversion modeling for visitors who haven't allowed the category.|

With `'after-consent'`, `gtag` waits for its `category`, and
`googleTagManager` waits for `measurement` or `marketing` because a container
usually holds both kinds of tag. Pass `category` to `googleTagManager` to
change that, for example `category: 'measurement'` for an analytics-only
container. Until the helper loads, `window.gtag` doesn't exist, and neither
does `window.dataLayer` unless you seeded it, so event calls written as
`window.gtag?.(...)` do nothing.

`'after-consent'` waits for the category to be allowed, not for a recorded
choice. Under an `opt-in` policy, that happens when the visitor allows it.
Under an `opt-out` or `none` policy, optional categories are allowed before a
choice, so the helper loads on the first page.

In either mode, the container runs every tag in it once it starts. Google
tags inside follow Consent Mode, but Custom HTML tags and third-party pixels
fire on their own triggers. With the default `category`, a visitor who
allowed only measurement starts the container, and a marketing pixel in it
loads too. With `'always'`, both load before any choice. Add a
consent check to each of those tags in GTM, or move the vendor out of the
container to its own c15t helper. See
[configure consent inside the container](/docs/integrations/google-tag-manager#configure-consent-inside-the-container).

Set it on each Google helper in your `scripts` array:

```ts
gtag({
	id: 'G-XXXXXXXXXX',
	category: 'measurement',
	loadMode: 'after-consent',
}),
googleTagManager({ id: 'GTM-XXXXXXX', loadMode: 'after-consent' }),
```

The [Google Tag](/docs/integrations/google-tag) and
[Google Tag Manager](/docs/integrations/google-tag-manager) guides list what
each mode sends and how to check it in DevTools.

### Move sendGAEvent and sendGTMEvent calls

`sendGAEvent` only works after the `GoogleAnalytics` component has rendered.
In `@next/third-parties` 16.x, once you remove the component, every call logs
`@next/third-parties: GA has not been initialized` and drops the event.
`sendGTMEvent` still pushes to `window.dataLayer` without its component, but
importing it keeps the package installed.

Call the `gtag` function and data layer that c15t sets up instead. The
arguments do not change:

```ts
// Before
sendGAEvent('event', 'sign_up', { method: 'email' });
sendGTMEvent({ event: 'sign_up', method: 'email' });

// After: no import needed
window.gtag?.('event', 'sign_up', { method: 'email' });
window.dataLayer?.push({ event: 'sign_up', method: 'email' });
```

Call them from browser code, such as an event handler. `window.gtag` and
`window.dataLayer` exist once c15t has set up the Google helper. Until then the
optional call does nothing and the event is lost, as with `sendGAEvent`. With a
custom `dataLayer` name on `googleTagManager`, push to `window[name]`
instead.

To send one event to every allowed integration rather than to Google alone,
use [`createEventDispatcher`](/docs/integrations/overview#send-events-only-to-allowed-integrations).
It drops the event while measurement is denied.

### Replace YouTubeEmbed and GoogleMapsEmbed

`YouTubeEmbed` loads `lite-youtube-embed` from `cdn.jsdelivr.net` and the
video thumbnail from YouTube before the visitor chooses. `GoogleMapsEmbed`
renders its Google iframe straight away. Render your own iframe inside
`ConsentGate` instead:

```tsx title="components/video-embed.tsx"
'use client';

import { ConsentGate } from 'c15t/next';

export const VideoEmbed = () => (
	<ConsentGate category="measurement">
		<iframe
			className="video-frame"
			sandbox="allow-scripts allow-same-origin allow-presentation"
			src="https://www.youtube-nocookie.com/embed/czTksCF6X8Y"
			title="YouTube video"
			allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
			allowFullScreen
		/>
	</ConsentGate>
);
```

Put the `videoid` in the URL path and the `params` string in its query, and use
the `playlabel` text as the iframe `title`. For a map, keep the URL
`GoogleMapsEmbed` built. In `place` mode that is
`https://www.google.com/maps/embed/v1/place?key=<apiKey>&q=<q>`, with
`center`, `zoom`, `maptype`, `language` and `region` as further query
parameters. Other modes need their own parameters, such as `center` and
`zoom` for `view`, so copy every parameter your component set. The
[YouTube](/docs/integrations/youtube) and
[Google Maps](/docs/integrations/google-maps) guides cover categories, sizing
and placeholders.

### Remove the package

Search the project for `@next/third-parties`. When nothing imports it,
uninstall it, for example with `npm uninstall @next/third-parties`. Then open
the production build in a private window under an opt-in policy. With
`loadMode: 'after-consent'`, nothing requests `googletagmanager.com` before you
choose. With the default, Google Tag Assistant shows a `consent` `default`
command before any tag fires. No request goes to `cdn.jsdelivr.net`, YouTube
or Google Maps until you allow the embed's category.

## Verify the integration

Open the production build in a fresh browser session with DevTools open.

1. Under an opt-in policy, no vendor requests anything before you choose.
2. Open **Privacy settings** and turn on **Analytics** (the `measurement`
   category) only. PostHog loads, and marketing vendors such as Meta Pixel stay
   blocked.
3. Reject, reload, and reopen **Privacy settings**. The rejection is still
   selected and no vendor loads.
4. Turn a granted category off. The page reloads and that vendor does not load
   again.

The [runnable example](/docs/examples) registers PostHog, and
[the consent checks](/docs/guides/verify-consent) cover the release checklist.
