# Google Tag Manager under a strict CSP in Next.js (/en/blog/gtm-csp-nextjs)



You can run [Google Tag Manager (GTM)](https://developers.google.com/tag-platform/tag-manager) in a [Next.js](https://nextjs.org/docs) 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'`](/en/blog/strict-dynamic-csp) 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](/en/blog/csp-nonce-nextjs) covers the base nonce wiring in `proxy.ts`, and [CSP for Google Analytics and Tag Manager](/en/blog/csp-google-analytics-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 [#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`](/en/docs/web-security/policies/content-security-policy/directives/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 [#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](/en/blog/csp-nonce-nextjs), so here is just the part that matters for GTM:

```ts title="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 [#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:

```tsx title="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](https://developers.google.com/tag-platform/security/guides/csp); 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 [#the-nextscript-ordering-gotcha]

It is tempting to load GTM with [`next/script`](https://nextjs.org/docs/app/api-reference/components/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:

```tsx title="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 [#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`](/en/docs/web-security/policies/content-security-policy/directives/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`](/en/docs/web-security/policies/content-security-policy/directives/img-src) for pixel requests: `https://*.google-analytics.com https://*.googletagmanager.com`.
* [`frame-src`](/en/docs/web-security/policies/content-security-policy/directives/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'`:

```http
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 [#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`](/en/blog/csp-enforce-vs-report-only) header first by swapping the header name in the proxy and adding a `Reporting-Endpoints` header:

```http
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](/platform/csp-builder) 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](/register), point a Report-Only header from your Next.js app at it, and watch the reports arrive from real traffic.

## Frequently asked questions [#frequently-asked-questions]

### Do I need to add googletagmanager.com to script-src in Next.js? [#do-i-need-to-add-googletagmanagercom-to-script-src-in-nextjs]

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? [#how-does-nextjs-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? [#should-i-load-gtm-with-nextscript-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? [#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 [#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](https://nextjs.org/docs/app/guides/content-security-policy) and Google's [GTM and CSP guidance](https://developers.google.com/tag-platform/security/guides/csp).

## Sources [#sources]

* [Next.js, Content Security Policy guide](https://nextjs.org/docs/app/guides/content-security-policy)
* [Next.js, Script component](https://nextjs.org/docs/app/api-reference/components/script)
* [Google, Use a CSP with Tag Manager](https://developers.google.com/tag-platform/security/guides/csp)
* [W3C, Content Security Policy Level 3](https://www.w3.org/TR/CSP3/)

## Related [#related]

* [How to set up a CSP nonce in Next.js](/en/blog/csp-nonce-nextjs)
* [CSP for Google Analytics and Tag Manager](/en/blog/csp-google-analytics-tag-manager)
* [What strict-dynamic does in a CSP](/en/blog/strict-dynamic-csp)
