---
title: "Add Google Analytics with a consent banner"
audience: "app developers"
description: "This continues the shop app from 330. You will add a cookie consent banner, load Google Analytics only after the analytics category is accepted and keep the…"
snippet_caption: "app/src/consent.ts"
snippet: "import { acceptedCategory, run } from \"vanilla-cookieconsent\";\n\nimport { loadAnalytics } from \"./analytics\";\n\nfunction onConsent() {\n  if (acceptedCategory(\"analytics\")) {\n    loadAnalytics();\n  }\n}\n\nrun({\n  onConsent,\n  onChange: onConsent,\n  categories: {\n    necessary: { enabled: true, readOnly: true },\n    analytics: {},\n  },\n  language: {\n    default: \"en\",\n    translations: {\n      en: {\n        consentModal: {\n          title: \"Cookies\",\n          description: \"We use cookies to measure traffic.\",\n          acceptAllBtn: \"Accept all\",\n          acceptNecessaryBtn: \"Reject all\",\n        },\n        preferencesModal: {\n          title: \"Preferences\",\n          acceptAllBtn: \"Accept all\",\n          acceptNecessaryBtn: \"Reject all\",\n          savePreferencesBtn: \"Save\",\n          sections: [\n            { title: \"Necessary\", description: \"Needed for the site to work.\", linkedCategory: \"necessary\" },\n            { title: \"Analytics\", description: \"Anonymous traffic measurement.\", linkedCategory: \"analytics\" },\n          ],\n        },\n      },\n    },\n  },\n});"
---

import { Install } from "@src/ui/Install";

This continues the `shop` app from [330](https://snapfirers.com/fsr/learn/330-add-a-javascript-library). You will add a cookie consent banner, load Google Analytics only after the analytics category is accepted and keep the measurement id in configuration so each deployment sets its own.

<Install captured="0.x" />

## Add the banner library

The banner is a library, so it arrives the way Chart.js did in [330](https://snapfirers.com/fsr/learn/330-add-a-javascript-library):

```shell-session
$ fsr add shop/app vanilla-cookieconsent@3.1.0
added     vanilla-cookieconsent        vanilla-cookieconsent/vanilla-cookieconsent.bundle.mjs  23538 bytes

$ fsr types shop/app
types     vanilla-cookieconsent        vanilla-cookieconsent 3.1.0
kept      react
kept      react-dom
```

The import map gains `vanilla-cookieconsent` and the package's own declarations land under `app/types/`. Its stylesheet is a file to serve like any other: copy `dist/cookieconsent.css` from the package into `app/styles/` and the host links it.

## Declare the id

Google Analytics needs a measurement id and the id is different in production than on your machine. Declare it under `[public]` in `config/app.toml` with the value development uses:

```toml title="config/app.toml"
[public]
analytics_id = ""
```

Every key under `[public]` is readable in a loader as `ctx.config.<key>`, typed from the value written here. Development runs with the empty string, which turns analytics off there.

## Seed the browser with it

The browser needs the id and a loader is how data reaches a page. Add `app/routes/layout.loader.ts` so every route carries it:

```ts title="app/routes/layout.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load({ config }: Ctx) {
  return { analyticsId: config.analytics_id };
}

export const store = ({ data }: { data: { analyticsId: string } }) => ({
  "site/analytics": data.analyticsId,
});
```

`store` seeds the store the islands share, from [020](https://snapfirers.com/fsr/learn/020-build-a-counter). Name the key once in `app/src/store.ts`:

```ts title="app/src/store.ts"
import { key } from "@snapfire/fsr-client/store";

export const analytics = key<string>("site/analytics");
```

## Write the analytics module

Google's snippet is four lines of inline script. As a module it is a function that reads the id from the store, builds the queue the same way and appends the loader script:

```ts title="app/src/analytics.ts"
import { get } from "@snapfire/fsr-client/store";

import { analytics } from "./store";

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

export function loadAnalytics() {
  const id = get(analytics);
  if (!id || window.dataLayer) {
    return;
  }
  const dataLayer: unknown[] = [];
  window.dataLayer = dataLayer;
  // gtag.js reads only `Arguments` entries off the queue and skips arrays.
  function gtag(..._: unknown[]) {
    dataLayer.push(arguments);
  }
  gtag("js", new Date());
  gtag("config", id);
  const script = document.createElement("script");
  script.async = true;
  script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
  document.head.append(script);
}
```

Save it as `app/src/analytics.ts`. The guard and the shim work like this:

- The first guard returns when there is no id, so development never loads gtag.js. It also returns when `dataLayer` already exists, so a second call does nothing.
- The shim pushes `arguments`, as Google's snippet does. gtag.js checks each queue entry for that type and ignores an array, so a shim written with a rest parameter queues calls that never fire and no page view is sent.

## Write the consent module

One module runs the banner and decides what loads. It knows nothing about Google beyond the function it calls:

```ts title="app/src/consent.ts"
import { acceptedCategory, run } from "vanilla-cookieconsent";

import { loadAnalytics } from "./analytics";

function onConsent() {
  if (acceptedCategory("analytics")) {
    loadAnalytics();
  }
}

run({
  onConsent,
  onChange: onConsent,
  categories: {
    necessary: { enabled: true, readOnly: true },
    analytics: {},
  },
  language: {
    default: "en",
    translations: {
      en: {
        consentModal: {
          title: "Cookies",
          description: "We use cookies to measure traffic.",
          acceptAllBtn: "Accept all",
          acceptNecessaryBtn: "Reject all",
        },
        preferencesModal: {
          title: "Preferences",
          acceptAllBtn: "Accept all",
          acceptNecessaryBtn: "Reject all",
          savePreferencesBtn: "Save",
          sections: [
            { title: "Necessary", description: "Needed for the site to work.", linkedCategory: "necessary" },
            { title: "Analytics", description: "Anonymous traffic measurement.", linkedCategory: "analytics" },
          ],
        },
      },
    },
  },
});
```

Save it as `app/src/consent.ts`. `onConsent` runs when a visitor answers the banner and again on a later visit once the stored answer is read. `onChange` runs when they change the answer in the preferences dialog, so someone who accepts later is measured from then on.

The usual way to gate a tag is to write it as `type="text/plain"` with a category attribute and let the banner library rewrite it once the category is accepted. That approach exists for tags written in HTML. This app has no tag, so the banner's callback does the gating.

## Import it from the entry

`app/src/main.ts` is the one script the document loads. Import the consent module for its effect:

```ts title="app/src/main.ts" {4}
import { boot, enableNavigation } from "@snapfire/fsr-client";
import { registerIslands } from "@generated/islands.js";

import "./consent";

registerIslands();

boot();
enableNavigation();
```

## Build it

```shell-session
$ fsr build shop/app --verbose
typecheck tsc 7.0.2 from cache, clean
```

The generated `Config` interface has one field, `analytics_id: string`, because that is what `[public]` declared. Misspell the read in the loader and the build says so:

```shell-session
$ fsr build shop/app
typecheck: tsc 7.0.2 from cache, 1 error
routes/layout.loader.ts(4,32): error TS2551: Property 'analytic_id' does not exist on type 'Config'. Did you mean 'analytics_id'?
```

Misspell the declaration instead, so the loader reads a key no configuration sets and `fsr doctor` reports it:

```shell-session
$ fsr doctor shop/app
ctx.config   a body reads `ctx.config.analytics_id` while `[public]` does not declare `analytics_id`, so it always answers null
             declare `analytics_id` under `[public]` in app.toml and set it per deployment in an overlay
doctor       1 of 14 checks found something
```

## Set it per deployment

The configuration is a list of files chosen by environment variables and applied in order: `app.toml`, then `<RELEASE_ENV>.toml`, then `<APP_ENV>.toml`, then `<APP_REGION>.toml`. `APP_ENV` defaults to `local`, so a `config/local.toml` overrides `app.toml` on your machine:

```toml title="config/local.toml"
[public]
analytics_id = "G-XXXXXXXXXX"
```

Boot the host and the report lists both files and the value in force:

```shell-session
$ fsr serve shop/app --verbose
config    shop/config/app.toml
          shop/config/local.toml
public    analytics_id           "G-XXXXXXXXXX"
```

The page's store seed carries it, `{"site/analytics":"G-XXXXXXXXXX"}`. Accepting the banner loads gtag.js with that id. A production deployment writes its own value into its own file and nothing in the application changes.

> Every `[public]` value reaches the browser, which is why the section has that name. Don't put a key that must stay on the server under `[public]`.

Next up: [350. Protect a route](https://snapfirers.com/fsr/learn/350-protect-a-route).
