All posts

Google Tag Manager under a strict CSP in Next.js

CentralCSP Team ·

Last update:

You can run Google Tag Manager (GTM) in a Next.js App Router app under a strict Content Security Policy (CSP) without adding any Google domain to your script directive. A CSP is an HTTP response header that tells the browser which scripts and other resources a page may load and run. The strict approach is a per-request nonce that Next.js generates, copied onto the GTM bootstrap script, with 'strict-dynamic' propagating that trust to gtm.js and every tag GTM loads.

This is the Next.js-specific companion to two posts you should read first. How to set up a CSP nonce in Next.js covers the base nonce wiring in proxy.ts, and CSP for Google Analytics and Tag Manager covers the framework-agnostic mechanism: why the nonce on the bootstrap is what matters, and where the Google hosts belong. This post connects the two and calls out the Next.js gotchas.

The flow in one minute

  1. Generate a per-request nonce in proxy.ts and set it in the CSP header on both the request and the response.
  2. Read that nonce in the layout and put it on the GTM bootstrap script.
  3. 'strict-dynamic' trusts the bootstrap, then gtm.js and the tags GTM injects, because they descend from a nonced script.
  4. Keep Google hosts out of script-src. They go in connect-src, img-src, and frame-src.

The first three steps are pure Next.js plumbing. The fourth is the part people get wrong.

The nonce comes from the proxy

In a Next.js App Router app you generate a fresh nonce per request in proxy.ts (the exported proxy function, renamed from middleware.ts in Next.js 16 and later) using Web Crypto, set the CSP on the forwarded request headers so Next.js can read the nonce, and set it again on the response so the browser enforces it. That setup is covered end to end in how to set up a CSP nonce in Next.js, so here is just the part that matters for GTM:

proxy.ts
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'` // [!code highlight]
// set csp on requestHeaders and on the response, plus 'x-nonce' for your components

Next.js applies that nonce to the scripts it renders for you. The one thing it does not do automatically is put the nonce on a third-party bootstrap you write by hand, like GTM. That is the step you own.

Read the nonce and put it on the GTM bootstrap

GTM works by injecting a <script> tag for gtm.js, which then injects more scripts for every tag in your container. Under a strict policy, the browser will only run the bootstrap if it carries the matching nonce. Once it does, 'strict-dynamic' trusts the bootstrap and everything it loads downstream, so gtm.js and the tags run without any Google host in script-src.

Read the nonce from the request headers in your root layout (a Server Component), then render Google's nonce-propagating bootstrap with that value:

app/layout.tsx
import { headers } from 'next/headers'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? ''
  return (
    <html lang="en">
      <head>
        <script
          nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `(function(w,d,s,l,i){w[l]=w[l]||[];
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0], j=d.createElement(s),
dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;
var n=d.querySelector('[nonce]');
n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

Two details make this strict-CSP-safe. The nonce={nonce} attribute is what lets 'strict-dynamic' trust the inline bootstrap. The d.querySelector('[nonce]') lines copy the nonce onto the injected gtm.js tag, which keeps the nonce flowing for tags that look for it. The nonce here must be the exact value the proxy put in the CSP header, which is why you read it from x-nonce rather than generating a new one. This bootstrap is Google's official nonce-aware snippet; the only Next.js-specific part is reading the nonce from headers().

Reading headers() opts the route into dynamic rendering, which a per-request nonce needs anyway. A reused nonce across a statically cached response defeats the policy.

The next/script ordering gotcha

It is tempting to load GTM with next/script instead of an inline tag, and next/script does accept a nonce prop. The catch is ordering and runtime.

next/script with the default afterInteractive strategy injects the tag from a client component after hydration. The nonce you pass has to be the same per-request value from the proxy, so you still have to read it from headers() in a Server Component and thread it down as a prop. If you generate or hardcode a nonce on the client, it will not match the header and the script is blocked. The inline bootstrap in the layout avoids this entirely because it renders server-side with the request nonce already in scope.

If you do use next/script, set strategy="afterInteractive" (not beforeInteractive, which runs before the nonce-bearing markup is in place reliably for third parties) and pass the server-read nonce:

app/layout.tsx
import Script from 'next/script'
import { headers } from 'next/headers'

const nonce = (await headers()).get('x-nonce') ?? ''

<Script id="gtm" nonce={nonce} strategy="afterInteractive">{`/* GTM bootstrap here */`}</Script>

For most setups the inline server-rendered bootstrap is simpler and less error-prone. Pick one, not both, or you load the container twice.

Where the Google hosts actually go

'strict-dynamic' only governs scripts. GTM and the tags it loads also send analytics beacons, fetch pixels, and open a debug frame, and none of those is a script load. Those requests are governed by other directives, and they still need the Google hosts listed. This is the part that trips up the Next.js setup just as much as any other stack, because the strict script-src is correct and the page still breaks until you fill these in.

  • connect-src for the analytics beacons and fetches. Use wildcards so regional collection endpoints are allowed: https://*.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com https://www.google.com.
  • img-src for pixel requests: https://*.google-analytics.com https://*.googletagmanager.com.
  • frame-src for the GTM Preview and Debug frame: https://www.googletagmanager.com.

A full strict policy from the proxy, with script-src carrying only the nonce and 'strict-dynamic':

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{SERVER-GENERATED-NONCE}' 'strict-dynamic';
  connect-src 'self' https://*.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com https://www.google.com;
  img-src 'self' https://*.google-analytics.com https://*.googletagmanager.com;
  frame-src https://www.googletagmanager.com;
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint

Tags that fire advertising or conversion pixels (Google Ads, Floodlight) reach more hosts. Add only the ones your container actually fires, which Report-Only will show you, rather than allowlisting the full set up front.

Test in Report-Only first

A GTM container changes whenever someone adds a tag, and each tag can reach a host your policy has not allowed. Ship the policy on the Content-Security-Policy-Report-Only header first by swapping the header name in the proxy and adding a Reporting-Endpoints header:

Reporting-Endpoints: csp-endpoint="https://<Endpoint-ID>.report.centralcsp.com"

The browser blocks nothing and reports everything it would have blocked, so you see the exact host a new tag needs before it can break analytics in production.

CentralCSP collects those Report-Only reports, groups them by directive and origin, and inventories the scripts running on each page, so you can see precisely which Google host a tag wants and which scripts GTM is loading before you enforce. You can start a free trial, point a Report-Only header from your Next.js app at it, and watch the reports arrive from real traffic.

Frequently asked questions

Do I need to add googletagmanager.com to script-src in Next.js?

No. With a per-request nonce and 'strict-dynamic' in script-src, the browser ignores host allowlists for scripts. You put the nonce on the GTM bootstrap, and 'strict-dynamic' trusts gtm.js and the tags it loads.

How does Next.js pass the nonce to GTM?

It does not automatically. Next.js auto-nonces its own scripts, but a third-party bootstrap you write is yours to handle. Read the nonce from headers() (the x-nonce header the proxy set) in your layout and put it on the GTM bootstrap script.

Should I load GTM with next/script or an inline script?

Either works under a strict CSP, but you must pass the per-request nonce from the proxy in both cases. The inline server-rendered bootstrap is simpler because the nonce is already in scope; next/script needs the nonce threaded down as a prop and the afterInteractive strategy. Do not use both, or the container loads twice.

Why does GTM still break with a correct script-src?

Because GTM and its tags also send beacons, fetch pixels, and open a debug frame, which script-src does not govern. Add the Google hosts to connect-src, img-src, and frame-src.

The takeaway

Running GTM under a strict CSP in Next.js is the base nonce setup plus one extra step. Generate the nonce in proxy.ts, read it from headers() in the layout, and put it on the GTM bootstrap so 'strict-dynamic' trusts gtm.js and every tag. Keep Google hosts out of script-src, where they are ignored, and list them in connect-src, img-src, and frame-src, where the fetches actually happen. Test in Report-Only first so a new tag never breaks analytics in production.

Further reading: the Next.js CSP guide and Google's GTM and CSP guidance.

Sources