← cd /blog

Article

Adding Umami to Next.js Without the CSP Blocking It

·
metatools

Umami goes into a Next.js site as one Script tag in the root layout. Under a Content-Security-Policy the script still loads, but it posts page views to a second host that the policy blocks. Allow that host in connect-src, then confirm the send in the browser's network tab.

// src/app/layout.tsx
import Script from "next/script";
// ...
<Script
  src="https://cloud.umami.is/script.js"
  data-website-id="your-website-id"
  strategy="lazyOnload"
/>

// next.config.ts, inside the Content-Security-Policy value
"connect-src 'self' https://cloud.umami.is https://api-gateway.umami.dev https://gateway.umami.is",

What Umami collects

It records page views, referrers, browser, OS and screen size, and sets no cookies, so it needs no consent banner. Umami's FAQ says it collects no personally identifiable information. It beat Plausible, PostHog and Matomo on the single script tag, a free cloud plan and the option to self-host.

Load the script with lazyOnload

The first version, in February, used defer and strategy="afterInteractive". In August an agent-run audit of the live site found it preloaded at high priority, with about 245 ms of cold connection setup in the critical path. lazyOnload waits for the window load event and drops the preload. The audit also called defer redundant next to a strategy.

Note: lazyOnload can miss very short visits. If those matter more than load time, the audit's alternative was afterInteractive plus a preconnect to cloud.umami.is. Pick one, not both.

Allow the beacon's host in connect-src, not only the script's

script-src decides whether script.js may load from cloud.umami.is. The script then sends each page view with fetch to https://gateway.umami.is/api/send, and connect-src decides whether that request may leave. Miss that host and every page view is blocked in the browser. The console on this site, on Next.js 16.3.0, logged two errors on 12 August:

Connecting to 'https://gateway.umami.is/api/send' violates the following Content Security Policy directive: "connect-src 'self' https://cloud.umami.is https://api-gateway.umami.dev". The action has been blocked.
Fetch API cannot load https://gateway.umami.is/api/send. Refused to connect because it violates the document's Content Security Policy.

The policy as it stands now, with the directives Umami doesn't touch elided:

// next.config.ts
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://cloud.umami.is`,
      // ...
      "connect-src 'self' https://cloud.umami.is https://api-gateway.umami.dev https://gateway.umami.is",
      // ...
    ].join("; "),
  },

That connect-src line took three commits. On 16 February the first policy named only the script's host, https://cloud.umami.is. On 19 February a commit titled "Fix CSP: allow Umami api-gateway.umami.dev in connect-src" added the second host. By 12 August the script was posting to gateway.umami.is instead, the 19 February policy blocked it, and the third commit added that host.

A blocked beacon sends nothing, so the Umami dashboard cannot show the failure. After any CSP change, load the live site and confirm a request to /api/send completes. After the August fix, https://gateway.umami.is/api/send was back in the page's loaded resources.

Test from a browser without a DNS blocker

The AdGuard DNS filter includes umami.is, and the AdGuard app on my Mac blocks it. A DNS block shows up as ERR_NAME_NOT_RESOLVED. A CSP block names the directive in the console instead.

dig umami.is @1.1.1.1 can still succeed in that state, because network extensions and content filters intercept system lookups before they reach upstream DNS. Compare against curl, which takes the full system path. AdGuard's block rules take the form ||domain.tld^. To allow Umami again, add @@||umami.is^ as a user rule.