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:
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:
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:
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
ConsentGateor the iframe blocker. - Network blocker holds
fetchand 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/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. |
<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:
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. |
auth, preview, gtmScriptUrl | No option. Use a custom integration for GTM environments or a server-side tagging URL. |
nonce | options={{ 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:
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.
Set it on each Google helper in your scripts array:
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:
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:
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.
- Under an opt-in policy, no vendor requests anything before you choose.
- Open Privacy settings and turn on Analytics (the
measurementcategory) only. PostHog loads, and marketing vendors such as Meta Pixel stay blocked. - Reject, reload, and reopen Privacy settings. The rejection is still selected and no vendor loads.
- 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.