Skip to main content

Next.js Scripts and embeds

Scripts

Install the script helpers

The App Router, Pages Router and 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:

npm install @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 loads PostHog under the measurement category. Replace the placeholder phc_your_project_key with your project key, or replace the helper with the integration your application uses. Include the measurement category in your policy.

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

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:

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

Use custom integrations for a vendor without a helper. Google helpers follow the Consent Mode contract.

Embeds and other requests

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

  • Embeds keeps iframes out of the page with ConsentGate or the iframe blocker.
  • 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.

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 for configuration and browser limits.

To send your own events only to allowed integrations, see 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/googlec15t 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.
<GoogleMapsEmbed apiKey="API_KEY" mode="place" />ConsentGate around a Maps Embed API iframe. See 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:

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:

Propc15t
gaIdid on gtag
debugModeconfig: { debug_mode: true } on gtag
dataLayerName on GoogleAnalyticsNo equivalent. gtag always queues on window.dataLayer.
gtmIdid on googleTagManager
dataLayerName on GoogleTagManagerdataLayer on googleTagManager. Keep the same name.
dataLayerNo option. Seed the data layer before the container loads. See keep initial data layer values.
auth, preview, gtmScriptUrlNo option. Use a custom integration for GTM environments or a server-side tagging URL.
nonceoptions={{ nonce }} on ConsentRoot. See 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:

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:

loadModeUntil the category is allowedUse 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.

Set it on each Google helper in your scripts array:

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

The Google Tag and 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:

// 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. 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:

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 and 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 registers PostHog, and the consent checks cover the release checklist.